feat(agent): implement AddAgentPopup

- Add isAgentType function to validate agent type strings
- Update i18n files with new agent type validation messages
- Add common validation error messages and success notifications
This commit is contained in:
icarus 2025-09-14 03:59:21 +08:00
parent fa380619ce
commit 702612e3f9
4 changed files with 221 additions and 62 deletions

View File

@ -2,6 +2,7 @@ import {
Avatar,
Button,
Form,
Input,
Modal,
ModalBody,
ModalContent,
@ -10,26 +11,47 @@ import {
Select,
SelectedItemProps,
SelectedItems,
SelectItem
SelectItem,
Textarea
} from '@heroui/react'
import ClaudeCodeIcon from '@renderer/assets/images/models/claude.png'
import { loggerService } from '@logger'
import ClaudeIcon from '@renderer/assets/images/models/claude.png'
import { TopView } from '@renderer/components/TopView'
import { useAgents } from '@renderer/hooks/useAgents'
import { useTimer } from '@renderer/hooks/useTimer'
import { EVENT_NAMES, EventEmitter } from '@renderer/services/EventService'
import { AgentEntity } from '@renderer/types'
import { AgentEntity, isAgentType } from '@renderer/types'
import { uuid } from '@renderer/utils'
import { useCallback, useMemo, useRef, useState } from 'react'
import { ChangeEvent, FormEvent, useCallback, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { ErrorBoundary } from '../ErrorBoundary'
const logger = loggerService.withContext('AddAgentPopup')
interface Props {
resolve: (value: AgentEntity | undefined) => void
}
type AgentTypeOption = {
interface Option {
key: string
label: string
// img src
avatar: string
}
interface AgentTypeOption extends Option {
key: AgentEntity['type']
name: AgentEntity['name']
avatar: AgentEntity['avatar']
}
type ModelOption = Option
type AgentForm = {
type: AgentEntity['type']
name: AgentEntity['name']
description?: AgentEntity['description']
instructions?: AgentEntity['instructions']
model?: AgentEntity['model']
}
const PopupContainer: React.FC<Props> = ({ resolve }) => {
@ -37,10 +59,17 @@ const PopupContainer: React.FC<Props> = ({ resolve }) => {
const { t } = useTranslation()
const loadingRef = useRef(false)
const { setTimeoutTimer } = useTimer()
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { addAgent } = useAgents()
// default values. may change to undefined.
const [form, setForm] = useState<AgentForm>({
type: 'claude-code',
name: 'Claude Code',
model: 'claude-4-sonnet'
})
const Option = useCallback(
({ option }: { option?: AgentTypeOption | null }) => {
({ option }: { option?: Option | null }) => {
if (!option) {
return (
<div className="flex gap-2">
@ -52,31 +81,29 @@ const PopupContainer: React.FC<Props> = ({ resolve }) => {
return (
<div className="flex gap-2">
<Avatar src={option.avatar} className="h-5 w-5" />
{option.name}
{option.label}
</div>
)
},
[t]
)
const Item = useCallback(
({ item }: { item: SelectedItemProps<AgentTypeOption> }) => <Option option={item.data} />,
[Option]
)
const Item = useCallback(({ item }: { item: SelectedItemProps<Option> }) => <Option option={item.data} />, [Option])
const renderValue = useCallback(
(items: SelectedItems<AgentTypeOption>) => items.map((item) => <Item key={item.key} item={item} />),
const renderOption = useCallback(
(items: SelectedItems<Option>) => items.map((item) => <Item key={item.key} item={item} />),
[Item]
)
// add supported agents type here
// add supported agents type here.
const agentConfig = useMemo(
() =>
[
{
key: 'claude-code',
label: 'Claude Code',
name: 'Claude Code',
avatar: ClaudeCodeIcon
avatar: ClaudeIcon
}
] as const satisfies AgentTypeOption[],
[]
@ -94,74 +121,169 @@ const PopupContainer: React.FC<Props> = ({ resolve }) => {
[Option, agentConfig]
)
const onCreateAgent = useCallback(
async (option: AgentTypeOption) => {
const onAgentTypeChange = useCallback(
(e: ChangeEvent<HTMLSelectElement>) => {
const prevConfig = agentConfig.find((config) => config.key === form.type)
let newName: string = form.name
if (prevConfig && prevConfig.name === form.name) {
const newConfig = agentConfig.find((config) => config.key === e.target.value)
if (newConfig) {
newName = newConfig.name
}
}
setForm((prev) => ({
...prev,
type: e.target.value as AgentForm['type'],
name: newName
}))
},
[agentConfig, form.name, form.type]
)
const onNameChange = useCallback((name: string) => {
setForm((prev) => ({
...prev,
name
}))
}, [])
const onDescChange = useCallback((description: string) => {
setForm((prev) => ({
...prev,
description
}))
}, [])
const onInstChange = useCallback((instructions: string) => {
setForm((prev) => ({
...prev,
instructions
}))
}, [])
const modelOptions = useMemo(() => {
// mocked data. not final version
return [
{
key: 'claude-4-sonnet',
label: 'Claude 4 Sonnet',
avatar: ClaudeIcon
}
] satisfies ModelOption[]
}, [])
const onModelChange = useCallback((e: ChangeEvent<HTMLSelectElement>) => {
setForm((prev) => ({
...prev,
model: e.target.value
}))
}, [])
const onSubmit = useCallback(
async (e: FormEvent<HTMLFormElement>) => {
e.preventDefault()
if (loadingRef.current) {
return
}
loadingRef.current = true
// TODO: update redux state
// Additional validation check besides native HTML validation to ensure security
if (!isAgentType(form.type)) {
window.toast.error(t('agent.add.error.invalid_agent'))
return
}
if (form.model === undefined) {
window.toast.error(t('error.model.not_exists'))
return
}
const agent = {
id: uuid(),
type: option.key,
name: option.name,
created_at: '',
updated_at: '',
model: ''
type: form.type,
name: form.name,
description: form.description,
instructions: form.instructions,
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
model: form.model
} satisfies AgentEntity
logger.debug('Agent', agent)
// addAgent(agent)
window.toast.success(t('common.add_success'))
setTimeoutTimer('onCreateAgent', () => EventEmitter.emit(EVENT_NAMES.SHOW_ASSISTANTS), 0)
resolve(agent)
setOpen(false)
},
[setTimeoutTimer, resolve]
[form.type, form.model, form.name, form.description, form.instructions, t, setTimeoutTimer, resolve]
)
const onClose = async () => {
setOpen(false)
AddAgentPopup.hide()
resolve(undefined)
}
const onSubmit = async () => {
window.toast.info('not implemented :(')
}
return (
<Modal isOpen={open} onClose={onClose}>
<ModalContent>
{(onClose) => (
<>
<ModalHeader>{t('agent.add.title')}</ModalHeader>
<ModalBody>
<Form>
<Select
items={agentOptions}
label={t('agent.add.label')}
placeholder={t('agent.add.placeholder')}
renderValue={renderValue}>
{(option) => (
<SelectItem key={option.key}>
<Option option={option} />
</SelectItem>
)}
</Select>
<ErrorBoundary>
<Modal isOpen={open} onClose={onClose}>
<ModalContent>
{(onClose) => (
<>
<ModalHeader>{t('agent.add.title')}</ModalHeader>
<Form onSubmit={onSubmit} className="w-full">
<ModalBody className="w-full">
<Select
isRequired
selectionMode="single"
selectedKeys={[form.type]}
onChange={onAgentTypeChange}
items={agentOptions}
label={t('agent.add.type.label')}
placeholder={t('agent.add.type.placeholder')}
renderValue={renderOption}>
{(option) => (
<SelectItem key={option.key} textValue={option.label}>
<Option option={option} />
</SelectItem>
)}
</Select>
<Input isRequired value={form.name} onValueChange={onNameChange} label={t('common.name')} />
{/* Model type definition is string. It cannot be related to provider. Just mock a model now. */}
<Select
isRequired
selectionMode="single"
selectedKeys={form.model ? [form.model] : []}
onChange={onModelChange}
items={modelOptions}
label={t('common.model')}
placeholder={t('common.placeholders.select.model')}
renderValue={renderOption}>
{(option) => (
<SelectItem key={option.key} textValue={option.label}>
<Option option={option} />
</SelectItem>
)}
</Select>
<Textarea label={t('common.description')} value={form.description} onValueChange={onDescChange} />
<Textarea label={t('common.prompt')} value={form.instructions} onValueChange={onInstChange} />
</ModalBody>
<ModalFooter className="w-full">
<Button onPress={onClose}>{t('common.close')}</Button>
<Button color="primary" type="submit" isLoading={loadingRef.current}>
{t('common.add')}
</Button>
</ModalFooter>
</Form>
</ModalBody>
<ModalFooter>
<Button onPress={onClose}>{t('common.close')}</Button>
<Button color="primary" onPress={onSubmit}>
{t('common.add')}
</Button>
</ModalFooter>
</>
)}
</ModalContent>
</Modal>
</>
)}
</ModalContent>
</Modal>
</ErrorBoundary>
)
}
// FIXME: Under the current TopView design, the close animation will fail.
export default class AddAgentPopup {
static topviewId = 0
static hide() {

View File

@ -1,4 +1,16 @@
{
"agent": {
"add": {
"error": {
"invalid_agent": "Invalid Agent"
},
"title": "Add Agent",
"type": {
"label": "Agent Type",
"placeholder": "Select an agent type"
}
}
},
"agents": {
"add": {
"button": "Add to Assistant",
@ -731,6 +743,7 @@
},
"common": {
"add": "Add",
"add_success": "Added successfully",
"advanced_settings": "Advanced Settings",
"and": "and",
"assistant": "Assistant",
@ -769,6 +782,7 @@
"go_to_settings": "Go to settings",
"i_know": "I know",
"inspect": "Inspect",
"invalid_value": "Invalid Value",
"knowledge_base": "Knowledge Base",
"language": "Language",
"loading": "Loading...",
@ -780,6 +794,11 @@
"none": "None",
"open": "Open",
"paste": "Paste",
"placeholders": {
"select": {
"model": "Select a model"
}
},
"preview": "Preview",
"prompt": "Prompt",
"provider": "Provider",

View File

@ -1,9 +1,14 @@
{
"agent": {
"add": {
"label": "Agent 类型",
"placeholder": "选择 Agent 类型",
"title": "添加 Agent"
"error": {
"invalid_agent": "无效的 Agent"
},
"title": "添加 Agent",
"type": {
"label": "Agent 类型",
"placeholder": "选择 Agent 类型"
}
}
},
"agents": {
@ -738,6 +743,7 @@
},
"common": {
"add": "添加",
"add_success": "添加成功",
"advanced_settings": "高级设置",
"and": "和",
"assistant": "智能体",
@ -766,6 +772,9 @@
"edit": "编辑",
"enabled": "已启用",
"error": "错误",
"errors": {
"validation": "验证失败"
},
"expand": "展开",
"file": {
"not_supported": "不支持的文件类型 {{type}}"
@ -788,6 +797,11 @@
"none": "无",
"open": "打开",
"paste": "粘贴",
"placeholders": {
"select": {
"model": "选择模型"
}
},
"preview": "预览",
"prompt": "提示词",
"provider": "提供商",

View File

@ -8,6 +8,10 @@ export type PermissionMode = 'readOnly' | 'acceptEdits' | 'bypassPermissions'
export type SessionMessageRole = 'user' | 'agent' | 'system' | 'tool'
export type AgentType = 'claude-code' | 'codex' | 'qwen-cli' | 'gemini-cli' | 'custom'
export function isAgentType(value: string): value is AgentType {
return ['claude-code', 'codex', 'qwen-cli', 'gemini-cli', 'custom'].includes(value)
}
export type SessionMessageType =
| 'message' // User or agent message
| 'thought' // Agent's internal reasoning/planning