feat(agent): enhance agent settings with accessible paths management

- Add UI for managing accessible paths in agent settings
- Improve error handling and loading states in agent components
- Update type definitions for better type safety
- Remove outdated comments and fix styling issues
This commit is contained in:
icarus 2025-09-22 13:56:09 +08:00
parent 82c08128b6
commit 4484f39525
7 changed files with 123 additions and 28 deletions

View File

@ -166,7 +166,8 @@ const api = {
openPath: (path: string) => ipcRenderer.invoke(IpcChannel.File_OpenPath, path), openPath: (path: string) => ipcRenderer.invoke(IpcChannel.File_OpenPath, path),
save: (path: string, content: string | NodeJS.ArrayBufferView, options?: any) => save: (path: string, content: string | NodeJS.ArrayBufferView, options?: any) =>
ipcRenderer.invoke(IpcChannel.File_Save, path, content, options), ipcRenderer.invoke(IpcChannel.File_Save, path, content, options),
selectFolder: (options?: OpenDialogOptions) => ipcRenderer.invoke(IpcChannel.File_SelectFolder, options), selectFolder: (options?: OpenDialogOptions): Promise<string | null> =>
ipcRenderer.invoke(IpcChannel.File_SelectFolder, options),
saveImage: (name: string, data: string) => ipcRenderer.invoke(IpcChannel.File_SaveImage, name, data), saveImage: (name: string, data: string) => ipcRenderer.invoke(IpcChannel.File_SaveImage, name, data),
binaryImage: (fileId: string) => ipcRenderer.invoke(IpcChannel.File_BinaryImage, fileId), binaryImage: (fileId: string) => ipcRenderer.invoke(IpcChannel.File_BinaryImage, fileId),
base64Image: (fileId: string): Promise<{ mime: string; base64: string; data: string }> => base64Image: (fileId: string): Promise<{ mime: string; base64: string; data: string }> =>

View File

@ -327,7 +327,6 @@ export const AgentModal: React.FC<Props> = ({ agent, trigger, isOpen: _isOpen, o
)} )}
</Select> </Select>
<Input isRequired value={form.name} onValueChange={onNameChange} label={t('common.name')} /> <Input isRequired value={form.name} onValueChange={onNameChange} label={t('common.name')} />
{/* FIXME: Model type definition is string. It cannot be related to provider. Just mock a model now. */}
<Select <Select
isRequired isRequired
selectionMode="single" selectionMode="single"

View File

@ -1,4 +1,4 @@
import { Button, Spinner } from '@heroui/react' import { Alert, Button, Spinner } from '@heroui/react'
import { AgentModal } from '@renderer/components/Popups/agent/AgentModal' import { AgentModal } from '@renderer/components/Popups/agent/AgentModal'
import { useAgents } from '@renderer/hooks/agents/useAgents' import { useAgents } from '@renderer/hooks/agents/useAgents'
import { useRuntime } from '@renderer/hooks/useRuntime' import { useRuntime } from '@renderer/hooks/useRuntime'
@ -14,7 +14,7 @@ import { SectionName } from './SectionName'
interface AssistantsTabProps {} interface AssistantsTabProps {}
export const Agents: FC<AssistantsTabProps> = () => { export const Agents: FC<AssistantsTabProps> = () => {
const { agents, deleteAgent, isLoading } = useAgents() const { agents, deleteAgent, isLoading, error } = useAgents()
const { t } = useTranslation() const { t } = useTranslation()
const { chat } = useRuntime() const { chat } = useRuntime()
const { activeAgentId } = chat const { activeAgentId } = chat
@ -38,7 +38,9 @@ export const Agents: FC<AssistantsTabProps> = () => {
<div className="agents-tab h-full w-full"> <div className="agents-tab h-full w-full">
<SectionName name={t('common.agent_other')} /> <SectionName name={t('common.agent_other')} />
{isLoading && <Spinner />} {isLoading && <Spinner />}
{error && <Alert color="danger" title={t('agent.list.error.failed')} />}
{!isLoading && {!isLoading &&
!error &&
agents.map((agent) => ( agents.map((agent) => (
<AgentItem <AgentItem
key={agent.id} key={agent.id}

View File

@ -1,14 +1,19 @@
import { Button, Tooltip } from '@heroui/react'
import { loggerService } from '@logger'
import { ApiModelLabel } from '@renderer/components/ApiModelLabel' import { ApiModelLabel } from '@renderer/components/ApiModelLabel'
import { useApiModels } from '@renderer/hooks/agents/useModels' import { useApiModels } from '@renderer/hooks/agents/useModels'
import { useUpdateAgent } from '@renderer/hooks/agents/useUpdateAgent' import { useUpdateAgent } from '@renderer/hooks/agents/useUpdateAgent'
import { AgentEntity, UpdateAgentForm } from '@renderer/types' import { AgentEntity, UpdateAgentForm } from '@renderer/types'
import { Input, Select } from 'antd' import { Input, Select } from 'antd'
import { DefaultOptionType } from 'antd/es/select' import { DefaultOptionType } from 'antd/es/select'
import { FC, useMemo, useState } from 'react' import { Plus } from 'lucide-react'
import { FC, useCallback, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { AgentLabel, SettingsContainer, SettingsItem, SettingsTitle } from './shared' import { AgentLabel, SettingsContainer, SettingsItem, SettingsTitle } from './shared'
const logger = loggerService.withContext('AgentEssentialSettings')
interface AgentEssentialSettingsProps { interface AgentEssentialSettingsProps {
agent: AgentEntity | undefined | null agent: AgentEntity | undefined | null
update: ReturnType<typeof useUpdateAgent> update: ReturnType<typeof useUpdateAgent>
@ -19,14 +24,25 @@ const AgentEssentialSettings: FC<AgentEssentialSettingsProps> = ({ agent, update
const [name, setName] = useState<string>((agent?.name ?? '').trim()) const [name, setName] = useState<string>((agent?.name ?? '').trim())
const { models } = useApiModels({ providerType: 'anthropic' }) const { models } = useApiModels({ providerType: 'anthropic' })
const agentModel = models.find((model) => model.id === agent?.model) const agentModel = models.find((model) => model.id === agent?.model)
const [model, setModel] = useState<string | undefined>(agentModel?.id)
const onUpdate = () => { const updateName = (name: string) => {
if (!agent) return if (!agent) return
const _agent = { ...agent, type: undefined, name: name.trim(), model } satisfies UpdateAgentForm update({ id: agent.id, name: name.trim() })
update(_agent)
} }
const updateModel = (model: UpdateAgentForm['model']) => {
if (!agent) return
update({ id: agent.id, model })
}
const updateAccessiblePaths = useCallback(
(accessible_paths: UpdateAgentForm['accessible_paths']) => {
if (!agent) return
update({ id: agent.id, accessible_paths })
},
[agent, update]
)
const modelOptions = useMemo(() => { const modelOptions = useMemo(() => {
return models.map((model) => ({ return models.map((model) => ({
value: model.id, value: model.id,
@ -34,6 +50,40 @@ const AgentEssentialSettings: FC<AgentEssentialSettingsProps> = ({ agent, update
})) satisfies DefaultOptionType[] })) satisfies DefaultOptionType[]
}, [models]) }, [models])
const addAccessiblePath = useCallback(async () => {
if (!agent) return
try {
const selected = await window.api.file.selectFolder()
if (!selected) {
return
}
if (agent.accessible_paths.includes(selected)) {
window.toast.warning(t('agent.session.accessible_paths.duplicate'))
return
}
updateAccessiblePaths([...agent.accessible_paths, selected])
} catch (error) {
logger.error('Failed to select accessible path:', error as Error)
window.toast.error(t('agent.session.accessible_paths.select_failed'))
}
}, [agent, t, updateAccessiblePaths])
const removeAccessiblePath = useCallback(
(path: string) => {
if (!agent) return
const newPaths = agent.accessible_paths.filter((p) => p !== path)
if (newPaths.length === 0) {
window.toast.error(t('agent.session.accessible_paths.error.at_least_one'))
return
}
updateAccessiblePaths(newPaths)
},
[agent, t, updateAccessiblePaths]
)
if (!agent) return null if (!agent) return null
return ( return (
@ -50,7 +100,7 @@ const AgentEssentialSettings: FC<AgentEssentialSettingsProps> = ({ agent, update
onChange={(e) => setName(e.target.value)} onChange={(e) => setName(e.target.value)}
onBlur={() => { onBlur={() => {
if (name !== agent.name) { if (name !== agent.name) {
onUpdate() updateName(name)
} }
}} }}
className="max-w-80 flex-1" className="max-w-80 flex-1"
@ -60,16 +110,39 @@ const AgentEssentialSettings: FC<AgentEssentialSettingsProps> = ({ agent, update
<SettingsTitle>{t('common.model')}</SettingsTitle> <SettingsTitle>{t('common.model')}</SettingsTitle>
<Select <Select
options={modelOptions} options={modelOptions}
value={model} value={agent.model}
onChange={(value) => { onChange={(value) => {
setModel(value) updateModel(value)
onUpdate()
}} }}
className="max-w-80 flex-1" className="max-w-80 flex-1"
placeholder={t('common.placeholders.select.model')} placeholder={t('common.placeholders.select.model')}
/> />
</SettingsItem> </SettingsItem>
{/* TODO: Add accessible_paths and description */} {/* TODO: Add accessible_paths and description */}
<SettingsItem>
<SettingsTitle
actions={
<Tooltip content={t('agent.session.accessible_paths.add')}>
<Button size="sm" startContent={<Plus />} isIconOnly onPress={addAccessiblePath} />
</Tooltip>
}>
{t('agent.session.accessible_paths.label')}
</SettingsTitle>
<ul className="mt-2 flex flex-col gap-2 rounded-xl border p-2">
{agent.accessible_paths.map((path) => (
<li
key={path}
className="flex items-center justify-between gap-2 rounded-medium border border-default-200 px-3 py-2">
<span className="truncate text-sm" title={path}>
{path}
</span>
<Button size="sm" variant="light" color="danger" onPress={() => removeAccessiblePath(path)}>
{t('common.delete')}
</Button>
</li>
))}
</ul>
</SettingsItem>
</SettingsContainer> </SettingsContainer>
) )
} }

View File

@ -1,5 +1,4 @@
import { Spinner } from '@heroui/react' import { Alert, Spinner } from '@heroui/react'
import { HStack } from '@renderer/components/Layout'
import { TopView } from '@renderer/components/TopView' import { TopView } from '@renderer/components/TopView'
import { useAgent } from '@renderer/hooks/agents/useAgent' import { useAgent } from '@renderer/hooks/agents/useAgent'
import { useUpdateAgent } from '@renderer/hooks/agents/useUpdateAgent' import { useUpdateAgent } from '@renderer/hooks/agents/useUpdateAgent'
@ -28,7 +27,7 @@ const AgentSettingPopupContainer: React.FC<AgentSettingPopupParams> = ({ tab, ag
const { t } = useTranslation() const { t } = useTranslation()
const [menu, setMenu] = useState<AgentSettingPopupTab>(tab || 'essential') const [menu, setMenu] = useState<AgentSettingPopupTab>(tab || 'essential')
const { agent } = useAgent(agentId) const { agent, isLoading, error } = useAgent(agentId)
const updateAgent = useUpdateAgent() const updateAgent = useUpdateAgent()
const onOk = () => { const onOk = () => {
@ -57,15 +56,24 @@ const AgentSettingPopupContainer: React.FC<AgentSettingPopupParams> = ({ tab, ag
).filter(Boolean) ).filter(Boolean)
const ModalContent = () => { const ModalContent = () => {
if (!agent) { if (isLoading) {
// TODO: use skeleton for better ux
return <Spinner /> return <Spinner />
} }
if (error) {
return (
<div>
<Alert color="danger" title={t('agent.get.error.failed')} />
</div>
)
}
return ( return (
<HStack> <div className="flex w-full flex-1">
<LeftMenu> <LeftMenu>
<StyledMenu <StyledMenu
defaultSelectedKeys={[tab || 'essential'] satisfies AgentSettingPopupTab[]} defaultSelectedKeys={[tab || 'essential'] satisfies AgentSettingPopupTab[]}
mode="vertical" mode="vertical"
selectedKeys={[menu]}
items={items} items={items}
onSelect={({ key }) => setMenu(key as AgentSettingPopupTab)} onSelect={({ key }) => setMenu(key as AgentSettingPopupTab)}
/> />
@ -74,7 +82,7 @@ const AgentSettingPopupContainer: React.FC<AgentSettingPopupParams> = ({ tab, ag
{menu === 'essential' && <AgentEssentialSettings agent={agent} update={updateAgent} />} {menu === 'essential' && <AgentEssentialSettings agent={agent} update={updateAgent} />}
{menu === 'prompt' && <AgentPromptSettings agent={agent} update={updateAgent} />} {menu === 'prompt' && <AgentPromptSettings agent={agent} update={updateAgent} />}
</Settings> </Settings>
</HStack> </div>
) )
} }
@ -98,15 +106,19 @@ const AgentSettingPopupContainer: React.FC<AgentSettingPopupParams> = ({ tab, ag
styles={{ styles={{
content: { content: {
padding: 0, padding: 0,
overflow: 'hidden' overflow: 'hidden',
height: '80vh',
display: 'flex',
flexDirection: 'column'
}, },
header: { padding: '10px 15px', borderBottom: '0.5px solid var(--color-border)', margin: 0, borderRadius: 0 }, header: { padding: '10px 15px', borderBottom: '0.5px solid var(--color-border)', margin: 0, borderRadius: 0 },
body: { body: {
padding: 0 padding: 0,
display: 'flex',
flex: 1
} }
}} }}
width="min(800px, 70vw)" width="min(800px, 70vw)"
height="80vh"
centered> centered>
<ModalContent /> <ModalContent />
</StyledModal> </StyledModal>
@ -114,7 +126,7 @@ const AgentSettingPopupContainer: React.FC<AgentSettingPopupParams> = ({ tab, ag
} }
const LeftMenu = styled.div` const LeftMenu = styled.div`
height: calc(80vh - 20px); height: 100%;
border-right: 0.5px solid var(--color-border); border-right: 0.5px solid var(--color-border);
` `
@ -123,7 +135,6 @@ const Settings = styled.div`
flex-direction: column; flex-direction: column;
flex: 1; flex: 1;
padding: 16px 16px; padding: 16px 16px;
height: calc(80vh - 16px);
overflow-y: scroll; overflow-y: scroll;
` `

View File

@ -2,12 +2,21 @@ import { Avatar, AvatarProps, cn } from '@heroui/react'
import { getAgentAvatar } from '@renderer/config/agent' import { getAgentAvatar } from '@renderer/config/agent'
import { getAgentTypeLabel } from '@renderer/i18n/label' import { getAgentTypeLabel } from '@renderer/i18n/label'
import { AgentType } from '@renderer/types' import { AgentType } from '@renderer/types'
import React from 'react' import React, { ReactNode } from 'react'
import { SettingDivider } from '..' import { SettingDivider } from '..'
export const SettingsTitle: React.FC<React.PropsWithChildren> = ({ children }) => { export interface SettingsTitleProps extends React.ComponentPropsWithRef<'div'> {
return <div className="mb-1 flex items-center gap-2 font-bold">{children}</div> actions?: ReactNode
}
export const SettingsTitle: React.FC<SettingsTitleProps> = ({ children, actions }) => {
return (
<div className={cn(actions ? 'justify-between' : undefined, 'mb-1 flex items-center gap-2')}>
<span className="flex items-center gap-1 font-bold">{children}</span>
{actions !== undefined && actions}
</div>
)
} }
export type AgentLabelProps = { export type AgentLabelProps = {

View File

@ -57,7 +57,7 @@ export const AgentBaseSchema = z.object({
// Basic info // Basic info
name: z.string().optional(), name: z.string().optional(),
description: z.string().optional(), description: z.string().optional(),
accessible_paths: z.array(z.string()), // Array of directory paths the agent can access accessible_paths: z.array(z.string()).nonempty(), // Array of directory paths the agent can access
// Instructions for the agent // Instructions for the agent
instructions: z.string().optional(), // System prompt instructions: z.string().optional(), // System prompt