feat(agents): add edit functionality and update translations

- Refactor AddAgentModal into AgentModal to support both add and edit operations
- Add edit button to AgentItem with corresponding modal functionality
- Update translations for edit and update success messages
This commit is contained in:
icarus 2025-09-14 09:16:04 +08:00
parent df31629c5f
commit 5f9c2d7f6a
5 changed files with 165 additions and 93 deletions

View File

@ -1,6 +1,7 @@
import {
Avatar,
Button,
cn,
Form,
Input,
Modal,
@ -22,8 +23,7 @@ import { useTimer } from '@renderer/hooks/useTimer'
import { EVENT_NAMES, EventEmitter } from '@renderer/services/EventService'
import { AgentEntity, AgentType, isAgentType } from '@renderer/types'
import { uuid } from '@renderer/utils'
import { Plus } from 'lucide-react'
import { ChangeEvent, FormEvent, useCallback, useMemo, useRef, useState } from 'react'
import { ChangeEvent, FormEvent, ReactNode, useCallback, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { ErrorBoundary } from '../ErrorBoundary'
@ -52,19 +52,52 @@ type AgentForm = {
model?: AgentEntity['model']
}
export const AddAgentModal: React.FC = () => {
const { isOpen, onClose, onOpen } = useDisclosure()
interface BaseProps {
agent?: AgentEntity
}
interface TriggerProps extends BaseProps {
trigger: { content: ReactNode; className?: string }
isOpen?: never
onClose?: never
}
interface StateProps extends BaseProps {
trigger?: never
isOpen: boolean
onClose: () => void
}
type Props = TriggerProps | StateProps
/**
* Modal component for creating or editing an agent.
*
* Either trigger or isOpen and onClose is given.
* @param agent - Optional agent entity for editing mode.
* @param trigger - Optional trigger element that opens the modal. It MUST propagate the click event to trigger the modal.
* @param isOpen - Optional controlled modal open state. From useDisclosure.
* @param onClose - Optional callback when modal closes. From useDisclosure.
* @returns Modal component for agent creation/editing
*/
export const AgentModal: React.FC<Props> = ({ agent, trigger, isOpen: _isOpen, onClose: _onClose }) => {
const { isOpen, onClose, onOpen } = useDisclosure({ isOpen: _isOpen, onClose: _onClose })
const { t } = useTranslation()
const loadingRef = useRef(false)
const { setTimeoutTimer } = useTimer()
const { addAgent } = useAgents()
const { addAgent, updateAgent } = useAgents()
const isEditing = (agent?: AgentEntity) => agent !== undefined
// default values. may change to undefined.
const [form, setForm] = useState<AgentForm>({
type: 'claude-code',
name: 'Claude Code',
model: 'claude-4-sonnet'
})
const [form, setForm] = useState<AgentForm>(
isEditing(agent)
? agent
: {
type: 'claude-code',
name: 'Claude Code',
model: 'claude-4-sonnet'
}
)
const Option = useCallback(
({ option }: { option?: Option | null }) => {
@ -196,46 +229,86 @@ export const AddAgentModal: React.FC = () => {
return
}
const agent = {
id: uuid(),
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,
avatar: getAvatar(form.type)
} satisfies AgentEntity
logger.debug('Agent', agent)
addAgent(agent)
window.toast.success(t('common.add_success'))
let _agent: AgentEntity
if (isEditing(agent)) {
_agent = {
...agent,
// type: form.type,
name: form.name,
description: form.description,
instructions: form.instructions,
updated_at: new Date().toISOString(),
model: form.model
// avatar: getAvatar(form.type)
} satisfies AgentEntity
updateAgent(_agent)
window.toast.success(t('common.update_success'))
} else {
_agent = {
id: uuid(),
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,
avatar: getAvatar(form.type)
} satisfies AgentEntity
addAgent(_agent)
window.toast.success(t('common.add_success'))
}
logger.debug('Agent', _agent)
loadingRef.current = false
setTimeoutTimer('onCreateAgent', () => EventEmitter.emit(EVENT_NAMES.SHOW_ASSISTANTS), 0)
onClose()
},
[form.type, form.model, form.name, form.description, form.instructions, addAgent, t, setTimeoutTimer, onClose]
[
form.type,
form.model,
form.name,
form.description,
form.instructions,
agent,
setTimeoutTimer,
onClose,
t,
updateAgent,
addAgent
]
)
return (
<ErrorBoundary>
{/* NOTE: Hero UI Modal Pattern: Combine the Button and Modal components into a single
encapsulated component. This is because the Modal component needs to bind the onOpen
event handler to the Button for proper focus management. */}
<Button onPress={onOpen} className="justify-start bg-transparent text-foreground-500 hover:bg-accent">
<Plus size={16} style={{ marginRight: 4, flexShrink: 0 }} />
{t('agent.add.title')}
</Button>
event handler to the Button for proper focus management.
Or just use external isOpen/onOpen/onClose to control modal state.
*/}
{trigger && (
<div
onClick={(e) => {
e.stopPropagation()
onOpen()
}}
className={cn('w-full', trigger.className)}>
{trigger.content}
</div>
)}
<Modal isOpen={isOpen} onClose={onClose}>
<ModalContent>
{(onClose) => (
<>
<ModalHeader>{t('agent.add.title')}</ModalHeader>
<ModalHeader>{isEditing(agent) ? t('agent.edit.title') : t('agent.add.title')}</ModalHeader>
<Form onSubmit={onSubmit} className="w-full">
<ModalBody className="w-full">
<Select
isRequired
isDisabled={isEditing(agent)}
selectionMode="single"
selectedKeys={[form.type]}
onChange={onAgentTypeChange}
@ -272,7 +345,7 @@ export const AddAgentModal: React.FC = () => {
<ModalFooter className="w-full">
<Button onPress={onClose}>{t('common.close')}</Button>
<Button color="primary" type="submit" isLoading={loadingRef.current}>
{t('common.add')}
{isEditing(agent) ? t('common.confirm') : t('common.add')}
</Button>
</ModalFooter>
</Form>

View File

@ -833,6 +833,7 @@
"success": "Success",
"swap": "Swap",
"topics": "Topics",
"update_success": "Update successfully",
"upload_files": "Upload file",
"warning": "Warning",
"you": "You"

View File

@ -13,6 +13,9 @@
"delete": {
"content": "删除该 Agent 将强制终止并删除该 Agent 下的所有会话。您确定吗?",
"title": "删除 Agent"
},
"edit": {
"title": "编辑 Agent"
}
},
"agents": {
@ -833,6 +836,7 @@
"success": "成功",
"swap": "交换",
"topics": "话题",
"update_success": "更新成功",
"upload_files": "上传文件",
"warning": "警告",
"you": "用户"

View File

@ -1,7 +1,7 @@
import { DownOutlined, RightOutlined } from '@ant-design/icons'
import { Button, Divider } from '@heroui/react'
import { DraggableList } from '@renderer/components/DraggableList'
import { AddAgentModal } from '@renderer/components/Popups/AddAgentModal'
import { AgentModal } from '@renderer/components/Popups/AgentModal'
import Scrollbar from '@renderer/components/Scrollbar'
import { useAgents } from '@renderer/hooks/useAgents'
import { useAssistants } from '@renderer/hooks/useAssistant'
@ -155,7 +155,20 @@ const Assistants: FC<AssistantsTabProps> = ({
onDragEnd={() => setDragging(false)}>
{(agent) => <AgentItem agent={agent} isActive={false} onDelete={onDeleteAgent} />}
</DraggableList>
{!dragging && <AddAgentModal />}
{!dragging && (
<AgentModal
trigger={{
content: (
<Button
onPress={(e) => e.continuePropagation()}
className="w-full justify-start bg-transparent text-foreground-500 hover:bg-accent">
<Plus size={16} className="mr-1 shrink-0" />
{t('agent.add.title')}
</Button>
)
}}
/>
)}
<Divider className="my-2" />
<DraggableList
list={assistants}

View File

@ -1,6 +1,7 @@
import { Avatar, cn } from '@heroui/react'
import { Avatar, cn, useDisclosure } from '@heroui/react'
import { loggerService } from '@logger'
import { DeleteIcon, EditIcon } from '@renderer/components/Icons'
import { AgentModal } from '@renderer/components/Popups/AgentModal'
import { AgentEntity } from '@renderer/types'
import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuTrigger } from '@renderer/ui/context-menu'
import { FC, memo, useCallback } from 'react'
@ -17,6 +18,7 @@ interface AgentItemProps {
const AgentItem: FC<AgentItemProps> = ({ agent, isActive, onDelete }) => {
const { t } = useTranslation()
const { isOpen, onOpen, onClose } = useDisclosure()
// const { agents } = useAgents()
const AgentLabel = useCallback(() => {
@ -31,36 +33,43 @@ const AgentItem: FC<AgentItemProps> = ({ agent, isActive, onDelete }) => {
const handleClick = () => logger.debug('not implemented')
return (
<ContextMenu>
<ContextMenuTrigger>
<Container onClick={handleClick} className={isActive ? 'active' : ''}>
<AssistantNameRow className="name" title={agent.name}>
<AgentLabel />
</AssistantNameRow>
</Container>
</ContextMenuTrigger>
<ContextMenuContent>
<ContextMenuItem key="edit" onClick={() => window.toast.info('not implemented')}>
<EditIcon size={14} />
{t('common.edit')}
</ContextMenuItem>
<ContextMenuItem
key="delete"
className="text-danger"
onClick={() => {
window.modal.confirm({
title: t('agent.delete.title'),
content: t('agent.delete.content'),
centered: true,
okButtonProps: { danger: true },
onOk: () => onDelete(agent)
})
}}>
<DeleteIcon size={14} className="lucide-custom text-danger" />
<span className="text-danger">{t('common.delete')}</span>
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
<>
<ContextMenu modal={false}>
<ContextMenuTrigger>
<Container onClick={handleClick} className={isActive ? 'active' : ''}>
<AssistantNameRow className="name" title={agent.name}>
<AgentLabel />
</AssistantNameRow>
</Container>
</ContextMenuTrigger>
<ContextMenuContent>
<ContextMenuItem
key="edit"
onClick={() => {
onOpen()
}}>
<EditIcon size={14} />
{t('common.edit')}
</ContextMenuItem>
<ContextMenuItem
key="delete"
className="text-danger"
onClick={() => {
window.modal.confirm({
title: t('agent.delete.title'),
content: t('agent.delete.content'),
centered: true,
okButtonProps: { danger: true },
onOk: () => onDelete(agent)
})
}}>
<DeleteIcon size={14} className="lucide-custom text-danger" />
<span className="text-danger">{t('common.delete')}</span>
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
<AgentModal isOpen={isOpen} onClose={onClose} agent={agent} />
</>
)
}
@ -86,32 +95,4 @@ const AssistantNameRow: React.FC<React.HTMLAttributes<HTMLDivElement>> = ({ clas
/>
)
// const MenuButton: React.FC<React.HTMLAttributes<HTMLDivElement>> = ({ className, ...props }) => (
// <div
// {...props}
// className={cn(
// 'flex flex-row items-center justify-center',
// 'h-[22px] min-h-[22px] min-w-[22px]',
// 'absolute rounded-[11px]',
// 'bg-[var(--color-background)]',
// 'top-[6px] right-[9px]',
// 'px-[5px]',
// 'border-[0.5px] border-[var(--color-border)]',
// className
// )}
// />
// )
// const TopicCount: React.FC<React.HTMLAttributes<HTMLDivElement>> = ({ className, ...props }) => (
// <div
// {...props}
// className={cn(
// 'text-[10px] text-[var(--color-text)]',
// 'rounded-[10px]',
// 'flex flex-row items-center justify-center',
// className
// )}
// />
// )
export default memo(AgentItem)