feat(Settings): Extract the reusable parts from AgentSettings for use in SessionSettings

This commit is contained in:
icarus 2025-09-26 01:22:28 +08:00
parent 3c4bb72a82
commit cb47e8decd
17 changed files with 561 additions and 231 deletions

View File

@ -75,6 +75,7 @@ type Props = TriggerProps | StateProps
/**
* Modal component for creating or editing a Session.
* @deprecated may as a reference when migrating to v2
*
* Either trigger or isOpen and onClose is given.
* @param agentId - The ID of agent which the session is related.

View File

@ -1,7 +1,7 @@
import { Avatar, Button, cn } from '@heroui/react'
import { DeleteIcon, EditIcon } from '@renderer/components/Icons'
import { getAgentAvatar } from '@renderer/config/agent'
import AgentSettingsPopup from '@renderer/pages/settings/AgentSettings'
import AgentSettingsPopup from '@renderer/pages/settings/AgentSettings/AgentSettingsPopup'
import { AgentEntity } from '@renderer/types'
import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuTrigger } from '@renderer/ui/context-menu'
import { FC, memo, useCallback } from 'react'

View File

@ -1,12 +1,13 @@
import { Button, cn, Input, useDisclosure } from '@heroui/react'
import { Button, cn, Input } from '@heroui/react'
import { DeleteIcon, EditIcon } from '@renderer/components/Icons'
import { SessionModal } from '@renderer/components/Popups/agent/SessionModal'
import { useUpdateSession } from '@renderer/hooks/agents/useUpdateSession'
import { useInPlaceEdit } from '@renderer/hooks/useInPlaceEdit'
import { useRuntime } from '@renderer/hooks/useRuntime'
import { SessionSettingsPopup } from '@renderer/pages/settings/AgentSettings'
import { SessionLabel } from '@renderer/pages/settings/AgentSettings/shared'
import { AgentSessionEntity } from '@renderer/types'
import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuTrigger } from '@renderer/ui/context-menu'
import { FC, memo, useCallback } from 'react'
import { FC, memo } from 'react'
import { useTranslation } from 'react-i18next'
// const logger = loggerService.withContext('AgentItem')
@ -23,7 +24,6 @@ interface SessionItemProps {
const SessionItem: FC<SessionItemProps> = ({ session, agentId, isDisabled, isLoading, onDelete, onPress }) => {
const { t } = useTranslation()
const { isOpen, onOpen, onClose } = useDisclosure()
const { chat } = useRuntime()
const updateSession = useUpdateSession(agentId)
const activeSessionId = chat.activeSessionId[agentId]
@ -38,15 +38,6 @@ const SessionItem: FC<SessionItemProps> = ({ session, agentId, isDisabled, isLoa
const isActive = activeSessionId === session.id
const SessionLabel = useCallback(() => {
const displayName = session.name ?? session.id
return (
<>
<span className="px-2 text-sm">{displayName}</span>
</>
)
}, [session.id, session.name])
return (
<>
<ContextMenu modal={false}>
@ -74,7 +65,7 @@ const SessionItem: FC<SessionItemProps> = ({ session, agentId, isDisabled, isLoa
}}
/>
)}
{!isEditing && <SessionLabel />}
{!isEditing && <SessionLabel session={session} />}
</SessionLabelContainer>
</ButtonContainer>
</ContextMenuTrigger>
@ -82,7 +73,10 @@ const SessionItem: FC<SessionItemProps> = ({ session, agentId, isDisabled, isLoa
<ContextMenuItem
key="edit"
onClick={() => {
onOpen()
SessionSettingsPopup.show({
agentId,
sessionId: session.id
})
}}>
<EditIcon size={14} />
{t('common.edit')}
@ -98,7 +92,6 @@ const SessionItem: FC<SessionItemProps> = ({ session, agentId, isDisabled, isLoa
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
<SessionModal agentId={agentId} isOpen={isOpen} onClose={onClose} session={session} />
</>
)
}

View File

@ -0,0 +1,90 @@
import { Button, Tooltip } from '@heroui/react'
import { loggerService } from '@logger'
import { AgentBaseWithId, UpdateAgentBaseForm } from '@renderer/types'
import { Plus } from 'lucide-react'
import React, { useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import { SettingsItem, SettingsTitle } from './shared'
export interface AccessibleDirsSettingProps {
base: AgentBaseWithId | undefined | null
update: (form: UpdateAgentBaseForm) => Promise<void>
}
const logger = loggerService.withContext('AccessibleDirsSetting')
export const AccessibleDirsSetting: React.FC<AccessibleDirsSettingProps> = ({ base, update }) => {
const { t } = useTranslation()
const updateAccessiblePaths = useCallback(
(accessible_paths: UpdateAgentBaseForm['accessible_paths']) => {
if (!base) return
update({ id: base.id, accessible_paths })
},
[base, update]
)
const addAccessiblePath = useCallback(async () => {
if (!base) return
try {
const selected = await window.api.file.selectFolder()
if (!selected) {
return
}
if (base.accessible_paths.includes(selected)) {
window.toast.warning(t('agent.session.accessible_paths.duplicate'))
return
}
updateAccessiblePaths([...base.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'))
}
}, [base, t, updateAccessiblePaths])
const removeAccessiblePath = useCallback(
(path: string) => {
if (!base) return
const newPaths = base.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)
},
[base, t, updateAccessiblePaths]
)
if (!base) return null
return (
<SettingsItem>
<SettingsTitle
actions={
<Tooltip content={t('agent.session.accessible_paths.add')}>
<Button variant="light" 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">
{base.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>
)
}

View File

@ -1,5 +1,13 @@
import { Input, Tooltip } from '@heroui/react'
import { AgentConfiguration, AgentConfigurationSchema, GetAgentResponse, UpdateAgentForm } from '@renderer/types'
import { useUpdateAgent } from '@renderer/hooks/agents/useUpdateAgent'
import { useUpdateSession } from '@renderer/hooks/agents/useUpdateSession'
import {
AgentConfiguration,
AgentConfigurationSchema,
GetAgentResponse,
GetAgentSessionResponse,
UpdateAgentBaseForm
} from '@renderer/types'
import { Info } from 'lucide-react'
import { useCallback, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
@ -8,31 +16,36 @@ import { SettingsContainer, SettingsItem, SettingsTitle } from './shared'
type AgentConfigurationState = AgentConfiguration & Record<string, unknown>
interface AgentAdvancedSettingsProps {
agent: GetAgentResponse | undefined | null
updateAgent: (form: UpdateAgentForm) => Promise<void> | void
}
type AdvancedSettingsProps =
| {
agentBase: GetAgentResponse | undefined | null
update: ReturnType<typeof useUpdateAgent>
}
| {
agentBase: GetAgentSessionResponse | undefined | null
update: ReturnType<typeof useUpdateSession>
}
const defaultConfiguration = AgentConfigurationSchema.parse({}) as AgentConfigurationState
const defaultConfiguration: AgentConfigurationState = AgentConfigurationSchema.parse({})
export const AgentAdvancedSettings: React.FC<AgentAdvancedSettingsProps> = ({ agent, updateAgent }) => {
export const AdvancedSettings: React.FC<AdvancedSettingsProps> = ({ agentBase, update }) => {
const { t } = useTranslation()
const [configuration, setConfiguration] = useState<AgentConfigurationState>(defaultConfiguration)
const [maxTurnsInput, setMaxTurnsInput] = useState<string>(String(defaultConfiguration.max_turns))
useEffect(() => {
if (!agent) {
if (!agentBase) {
setConfiguration(defaultConfiguration)
setMaxTurnsInput(String(defaultConfiguration.max_turns))
return
}
const parsed = AgentConfigurationSchema.parse(agent.configuration ?? {}) as AgentConfigurationState
const parsed: AgentConfigurationState = AgentConfigurationSchema.parse(agentBase.configuration ?? {})
setConfiguration(parsed)
setMaxTurnsInput(String(parsed.max_turns))
}, [agent])
}, [agentBase])
const commitMaxTurns = useCallback(() => {
if (!agent) return
if (!agentBase) return
const parsedValue = Number.parseInt(maxTurnsInput, 10)
if (!Number.isFinite(parsedValue)) {
setMaxTurnsInput(String(configuration.max_turns))
@ -43,13 +56,13 @@ export const AgentAdvancedSettings: React.FC<AgentAdvancedSettingsProps> = ({ ag
setMaxTurnsInput(String(configuration.max_turns))
return
}
const next = { ...configuration, max_turns: sanitized } as AgentConfigurationState
const next: AgentConfigurationState = { ...configuration, max_turns: sanitized }
setConfiguration(next)
setMaxTurnsInput(String(sanitized))
updateAgent({ id: agent.id, configuration: next } satisfies UpdateAgentForm)
}, [agent, configuration, maxTurnsInput, updateAgent])
update({ id: agentBase.id, configuration: next } satisfies UpdateAgentBaseForm)
}, [agentBase, configuration, maxTurnsInput, update])
if (!agent) {
if (!agentBase) {
return null
}
@ -85,4 +98,4 @@ export const AgentAdvancedSettings: React.FC<AgentAdvancedSettingsProps> = ({ ag
)
}
export default AgentAdvancedSettings
export default AdvancedSettings

View File

@ -1,16 +1,15 @@
import { Button, Input, Select, SelectedItems, SelectItem, Textarea, Tooltip } from '@heroui/react'
import { loggerService } from '@logger'
import { ApiModelLabel } from '@renderer/components/ApiModelLabel'
import { useApiModels } from '@renderer/hooks/agents/useModels'
import { useUpdateAgent } from '@renderer/hooks/agents/useUpdateAgent'
import { ApiModel, GetAgentResponse, UpdateAgentForm } from '@renderer/types'
import { Plus } from 'lucide-react'
import { FC, useCallback, useState } from 'react'
import { GetAgentResponse } from '@renderer/types'
import { FC } from 'react'
import { useTranslation } from 'react-i18next'
import { AccessibleDirsSetting } from './AccessibleDirsSetting'
import { DescriptionSetting } from './DescriptionSetting'
import { ModelSetting } from './ModelSetting'
import { NameSetting } from './NameSetting'
import { AgentLabel, SettingsContainer, SettingsItem, SettingsTitle } from './shared'
const logger = loggerService.withContext('AgentEssentialSettings')
// const logger = loggerService.withContext('AgentEssentialSettings')
interface AgentEssentialSettingsProps {
agent: GetAgentResponse | undefined | null
@ -19,76 +18,6 @@ interface AgentEssentialSettingsProps {
const AgentEssentialSettings: FC<AgentEssentialSettingsProps> = ({ agent, update }) => {
const { t } = useTranslation()
const [name, setName] = useState<string | undefined>(agent?.name?.trim())
const [description, setDescription] = useState<string | undefined>(agent?.description?.trim())
const { models } = useApiModels({ providerType: 'anthropic' })
const updateName = (name: UpdateAgentForm['name']) => {
if (!agent) return
update({ id: agent.id, name: name?.trim() })
}
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 updateDesc = useCallback(
(description: UpdateAgentForm['description']) => {
if (!agent) return
update({ id: agent.id, description })
},
[agent, update]
)
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]
)
const renderModels = useCallback((items: SelectedItems<ApiModel>) => {
return items.map((item) => {
const model = item.data ?? undefined
return <ApiModelLabel key={model?.id} model={model} />
})
}, [])
if (!agent) return null
@ -98,76 +27,10 @@ const AgentEssentialSettings: FC<AgentEssentialSettingsProps> = ({ agent, update
<SettingsTitle>{t('agent.type.label')}</SettingsTitle>
<AgentLabel type={agent.type} />
</SettingsItem>
<SettingsItem inline>
<SettingsTitle>{t('common.name')}</SettingsTitle>
<Input
placeholder={t('common.agent_one') + t('common.name')}
value={name}
onValueChange={(value) => setName(value)}
onBlur={() => {
if (name !== agent.name) {
updateName(name)
}
}}
className="max-w-80 flex-1"
/>
</SettingsItem>
<SettingsItem inline className="gap-8">
<SettingsTitle id="model">{t('common.model')}</SettingsTitle>
<Select
selectionMode="single"
aria-labelledby="model"
items={models}
selectedKeys={[agent.model]}
onSelectionChange={(keys) => {
updateModel(keys.currentKey)
}}
className="max-w-80 flex-1"
placeholder={t('common.placeholders.select.model')}
renderValue={renderModels}>
{(model) => (
<SelectItem textValue={model.id}>
<ApiModelLabel model={model} />
</SelectItem>
)}
</Select>
</SettingsItem>
<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>
<SettingsItem>
<SettingsTitle>{t('common.description')}</SettingsTitle>
<Textarea
value={description}
onValueChange={setDescription}
onBlur={() => {
if (description !== agent.description) {
updateDesc(description)
}
}}
/>
</SettingsItem>
<NameSetting base={agent} update={update} />
<ModelSetting base={agent} update={update} />
<AccessibleDirsSetting base={agent} update={update} />
<DescriptionSetting base={agent} update={update} />
</SettingsContainer>
)
}

View File

@ -5,11 +5,11 @@ import { useUpdateAgent } from '@renderer/hooks/agents/useUpdateAgent'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import AgentAdvancedSettings from './AgentAdvancedSettings'
import AdvancedSettings from './AdvancedSettings'
import AgentEssentialSettings from './AgentEssentialSettings'
import AgentPromptSettings from './AgentPromptSettings'
import AgentToolingSettings from './AgentToolingSettings'
import PromptSettings from './PromptSettings'
import { AgentLabel, LeftMenu, Settings, StyledMenu, StyledModal } from './shared'
import ToolingSettings from './ToolingSettings'
interface AgentSettingPopupShowParams {
agentId: string
@ -88,9 +88,9 @@ const AgentSettingPopupContainer: React.FC<AgentSettingPopupParams> = ({ tab, ag
</LeftMenu>
<Settings>
{menu === 'essential' && <AgentEssentialSettings agent={agent} update={updateAgent} />}
{menu === 'prompt' && <AgentPromptSettings agent={agent} update={updateAgent} />}
{menu === 'tooling' && <AgentToolingSettings agentBase={agent} update={updateAgent} />}
{menu === 'advanced' && <AgentAdvancedSettings agent={agent} updateAgent={updateAgent} />}
{menu === 'prompt' && <PromptSettings agentBase={agent} update={updateAgent} />}
{menu === 'tooling' && <ToolingSettings agentBase={agent} update={updateAgent} />}
{menu === 'advanced' && <AdvancedSettings agentBase={agent} update={updateAgent} />}
</Settings>
</div>
)

View File

@ -0,0 +1,40 @@
import { Textarea } from '@heroui/react'
import { AgentBaseWithId, UpdateAgentBaseForm } from '@renderer/types'
import React, { useCallback, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { SettingsItem, SettingsTitle } from './shared'
export interface DescriptionSettingProps {
base: AgentBaseWithId | undefined | null
update: (form: UpdateAgentBaseForm) => Promise<void>
}
export const DescriptionSetting: React.FC<DescriptionSettingProps> = ({ base, update }) => {
const { t } = useTranslation()
const [description, setDescription] = useState<string | undefined>(base?.description?.trim())
const updateDesc = useCallback(
(description: UpdateAgentBaseForm['description']) => {
if (!base) return
update({ id: base.id, description })
},
[base, update]
)
if (!base) return null
return (
<SettingsItem>
<SettingsTitle>{t('common.description')}</SettingsTitle>
<Textarea
value={description}
onValueChange={setDescription}
onBlur={() => {
if (description !== base.description) {
updateDesc(description)
}
}}
/>
</SettingsItem>
)
}

View File

@ -0,0 +1,57 @@
import { Select, SelectedItems, SelectItem } from '@heroui/react'
import { ApiModelLabel } from '@renderer/components/ApiModelLabel'
import { useApiModels } from '@renderer/hooks/agents/useModels'
import { AgentBaseWithId, ApiModel, UpdateAgentBaseForm, UpdateAgentForm } from '@renderer/types'
import { useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import { SettingsItem, SettingsTitle } from './shared'
export interface ModelSettingProps {
base: AgentBaseWithId | undefined | null
update: (form: UpdateAgentBaseForm) => Promise<void>
isDisabled?: boolean
}
export const ModelSetting: React.FC<ModelSettingProps> = ({ base, update, isDisabled }) => {
const { t } = useTranslation()
const { models } = useApiModels({ providerType: 'anthropic' })
const updateModel = (model: UpdateAgentForm['model']) => {
if (!base) return
update({ id: base.id, model })
}
const renderModels = useCallback((items: SelectedItems<ApiModel>) => {
return items.map((item) => {
const model = item.data ?? undefined
return <ApiModelLabel key={model?.id} model={model} />
})
}, [])
if (!base) return null
return (
<SettingsItem inline className="gap-8">
<SettingsTitle id="model">{t('common.model')}</SettingsTitle>
<Select
isDisabled={isDisabled}
selectionMode="single"
aria-labelledby="model"
items={models}
selectedKeys={[base.model]}
onSelectionChange={(keys) => {
updateModel(keys.currentKey)
}}
className="max-w-80 flex-1"
placeholder={t('common.placeholders.select.model')}
renderValue={renderModels}>
{(model) => (
<SelectItem textValue={model.id}>
<ApiModelLabel model={model} />
</SelectItem>
)}
</Select>
</SettingsItem>
)
}

View File

@ -0,0 +1,38 @@
import { Input } from '@heroui/react'
import { AgentBaseWithId, UpdateAgentBaseForm } from '@renderer/types'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { SettingsItem, SettingsTitle } from './shared'
export interface NameSettingsProps {
base: AgentBaseWithId | undefined | null
update: (form: UpdateAgentBaseForm) => Promise<void>
}
export const NameSetting: React.FC<NameSettingsProps> = ({ base, update }) => {
const { t } = useTranslation()
const [name, setName] = useState<string | undefined>(base?.name?.trim())
const updateName = async (name: UpdateAgentBaseForm['name']) => {
if (!base) return
return update({ id: base.id, name: name?.trim() })
}
if (!base) return null
return (
<SettingsItem inline>
<SettingsTitle>{t('common.name')}</SettingsTitle>
<Input
placeholder={t('common.agent_one') + t('common.name')}
value={name}
onValueChange={(value) => setName(value)}
onBlur={() => {
if (name !== base.name) {
updateName(name)
}
}}
className="max-w-80 flex-1"
/>
</SettingsItem>
)
}

View File

@ -2,9 +2,10 @@ import CodeEditor from '@renderer/components/CodeEditor'
import { HSpaceBetweenStack } from '@renderer/components/Layout'
import { RichEditorRef } from '@renderer/components/RichEditor/types'
import { useUpdateAgent } from '@renderer/hooks/agents/useUpdateAgent'
import { useUpdateSession } from '@renderer/hooks/agents/useUpdateSession'
import { usePromptProcessor } from '@renderer/hooks/usePromptProcessor'
import { estimateTextTokens } from '@renderer/services/TokenService'
import { AgentEntity, UpdateAgentForm } from '@renderer/types'
import { AgentEntity, AgentSessionEntity, UpdateAgentBaseForm } from '@renderer/types'
import { Button, Popover } from 'antd'
import { Edit, HelpCircle, Save } from 'lucide-react'
import { FC, useEffect, useRef, useState } from 'react'
@ -14,15 +15,20 @@ import styled from 'styled-components'
import { SettingsContainer, SettingsItem, SettingsTitle } from './shared'
interface AgentPromptSettingsProps {
agent: AgentEntity | undefined | null
update: ReturnType<typeof useUpdateAgent>
}
type AgentPromptSettingsProps =
| {
agentBase: AgentEntity | undefined | null
update: ReturnType<typeof useUpdateAgent>
}
| {
agentBase: AgentSessionEntity | undefined | null
update: ReturnType<typeof useUpdateSession>
}
const AgentPromptSettings: FC<AgentPromptSettingsProps> = ({ agent, update }) => {
const PromptSettings: FC<AgentPromptSettingsProps> = ({ agentBase, update }) => {
const { t } = useTranslation()
const [instructions, setInstructions] = useState<string>(agent?.instructions ?? '')
const [showPreview, setShowPreview] = useState<boolean>(!!agent?.instructions?.length)
const [instructions, setInstructions] = useState<string>(agentBase?.instructions ?? '')
const [showPreview, setShowPreview] = useState<boolean>(!!agentBase?.instructions?.length)
const [tokenCount, setTokenCount] = useState(0)
useEffect(() => {
@ -37,18 +43,17 @@ const AgentPromptSettings: FC<AgentPromptSettingsProps> = ({ agent, update }) =>
const processedPrompt = usePromptProcessor({
prompt: instructions,
modelName: agent?.model
modelName: agentBase?.model
})
const onUpdate = () => {
if (!agent) return
const _agent = { ...agent, type: undefined, instructions } satisfies UpdateAgentForm
update(_agent)
const updatePrompt = () => {
if (!agentBase) return
update({ id: agentBase.id, instructions } satisfies UpdateAgentBaseForm)
}
const promptVarsContent = <pre>{t('agents.add.prompt.variables.tip.content')}</pre>
if (!agent) return null
if (!agentBase) return null
return (
<SettingsContainer>
@ -95,7 +100,7 @@ const AgentPromptSettings: FC<AgentPromptSettingsProps> = ({ agent, update }) =>
setShowPreview(false)
requestAnimationFrame(() => editorRef.current?.setScrollTop?.(currentScrollTop))
} else {
onUpdate()
updatePrompt()
requestAnimationFrame(() => {
setShowPreview(true)
requestAnimationFrame(() => editorRef.current?.setScrollTop?.(currentScrollTop))
@ -154,4 +159,4 @@ const MarkdownContainer = styled.div.attrs({ className: 'markdown' })`
overflow: auto;
`
export default AgentPromptSettings
export default PromptSettings

View File

@ -0,0 +1,31 @@
import { useUpdateAgent } from '@renderer/hooks/agents/useUpdateAgent'
import { GetAgentSessionResponse } from '@renderer/types'
import { FC } from 'react'
import { AccessibleDirsSetting } from './AccessibleDirsSetting'
import { DescriptionSetting } from './DescriptionSetting'
import { ModelSetting } from './ModelSetting'
import { NameSetting } from './NameSetting'
import { SettingsContainer } from './shared'
// const logger = loggerService.withContext('AgentEssentialSettings')
interface SessionEssentialSettingsProps {
session: GetAgentSessionResponse | undefined | null
update: ReturnType<typeof useUpdateAgent>
}
const SessionEssentialSettings: FC<SessionEssentialSettingsProps> = ({ session, update }) => {
if (!session) return null
return (
<SettingsContainer>
<NameSetting base={session} update={update} />
<ModelSetting base={session} update={update} isDisabled />
<AccessibleDirsSetting base={session} update={update} />
<DescriptionSetting base={session} update={update} />
</SettingsContainer>
)
}
export default SessionEssentialSettings

View File

@ -0,0 +1,148 @@
import { Alert, Spinner } from '@heroui/react'
import { TopView } from '@renderer/components/TopView'
import { useSession } from '@renderer/hooks/agents/useSession'
import { useUpdateSession } from '@renderer/hooks/agents/useUpdateSession'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import AdvancedSettings from './AdvancedSettings'
import PromptSettings from './PromptSettings'
import SessionEssentialSettings from './SessionEssentialSettings'
import { LeftMenu, SessionLabel, Settings, StyledMenu, StyledModal } from './shared'
import ToolingSettings from './ToolingSettings'
interface SessionSettingPopupShowParams {
agentId: string
sessionId: string
tab?: AgentSettingPopupTab
}
interface SessionSettingPopupParams extends SessionSettingPopupShowParams {
resolve: () => void
}
type AgentSettingPopupTab = 'essential' | 'prompt' | 'tooling' | 'advanced' | 'session-mcps'
const SessionSettingPopupContainer: React.FC<SessionSettingPopupParams> = ({ tab, agentId, sessionId, resolve }) => {
const [open, setOpen] = useState(true)
const { t } = useTranslation()
const [menu, setMenu] = useState<AgentSettingPopupTab>(tab || 'essential')
const { session, isLoading, error } = useSession(agentId, sessionId)
const updateSession = useUpdateSession(agentId)
const onOk = () => {
setOpen(false)
}
const onCancel = () => {
setOpen(false)
}
const afterClose = () => {
resolve()
}
const items = (
[
{
key: 'essential',
label: t('agent.settings.essential')
},
{
key: 'prompt',
label: t('agent.settings.prompt')
},
{
key: 'tooling',
label: t('agent.settings.tooling.tab', 'Tooling & permissions')
},
{
key: 'advanced',
label: t('agent.settings.advance.title', 'Advanced Settings')
}
] as const satisfies { key: AgentSettingPopupTab; label: string }[]
).filter(Boolean)
const ModalContent = () => {
if (isLoading) {
// TODO: use skeleton for better ux
return <Spinner />
}
if (error) {
return (
<div>
<Alert color="danger" title={t('agent.get.error.failed')} />
</div>
)
}
return (
<div className="flex w-full flex-1">
<LeftMenu>
<StyledMenu
defaultSelectedKeys={[tab || 'essential'] satisfies AgentSettingPopupTab[]}
mode="vertical"
selectedKeys={[menu]}
items={items}
onSelect={({ key }) => setMenu(key as AgentSettingPopupTab)}
/>
</LeftMenu>
<Settings>
{menu === 'essential' && <SessionEssentialSettings session={session} update={updateSession} />}
{menu === 'prompt' && <PromptSettings agentBase={session} update={updateSession} />}
{menu === 'tooling' && <ToolingSettings agentBase={session} update={updateSession} />}
{menu === 'advanced' && <AdvancedSettings agentBase={session} update={updateSession} />}
</Settings>
</div>
)
}
return (
<StyledModal
open={open}
onOk={onOk}
onCancel={onCancel}
afterClose={afterClose}
maskClosable={false}
footer={null}
title={<SessionLabel session={session} className="font-extrabold text-lg" />}
transitionName="animation-move-down"
styles={{
content: {
padding: 0,
overflow: 'hidden',
height: '80vh',
display: 'flex',
flexDirection: 'column'
},
header: { padding: '10px 15px', borderBottom: '0.5px solid var(--color-border)', margin: 0, borderRadius: 0 },
body: {
padding: 0,
display: 'flex',
flex: 1
}
}}
width="min(800px, 70vw)"
centered>
<ModalContent />
</StyledModal>
)
}
export default class SessionSettingsPopup {
static show(props: SessionSettingPopupShowParams) {
return new Promise<void>((resolve) => {
TopView.show(
<SessionSettingPopupContainer
{...props}
resolve={() => {
resolve()
TopView.hide('SessionSettingsPopup')
}}
/>,
'SessionSettingsPopup'
)
})
}
}

View File

@ -6,9 +6,12 @@ import {
AgentConfiguration,
AgentConfigurationSchema,
GetAgentResponse,
GetAgentSessionResponse,
PermissionMode,
Tool,
UpdateAgentForm
UpdateAgentBaseForm,
UpdateAgentForm,
UpdateSessionForm
} from '@renderer/types'
import { Modal } from 'antd'
import { ShieldAlert, ShieldCheck, Wrench } from 'lucide-react'
@ -18,10 +21,15 @@ import { mutate } from 'swr'
import { SettingsContainer, SettingsItem, SettingsTitle } from './shared'
interface AgentToolingSettingsProps {
agent: GetAgentResponse | undefined | null
updateAgent: (form: UpdateAgentForm) => Promise<void> | void
}
type AgentToolingSettingsProps =
| {
agentBase: GetAgentResponse | undefined | null
update: (form: UpdateAgentForm) => Promise<void> | void
}
| {
agentBase: GetAgentSessionResponse | undefined | null
update: (form: UpdateSessionForm) => Promise<void> | void
}
type AgentConfigurationState = AgentConfiguration & Record<string, unknown>
@ -37,7 +45,7 @@ type PermissionModeCard = {
unsupported?: boolean
}
const defaultConfiguration = AgentConfigurationSchema.parse({}) as AgentConfigurationState
const defaultConfiguration: AgentConfigurationState = AgentConfigurationSchema.parse({})
const permissionModeCards: PermissionModeCard[] = [
{
@ -105,7 +113,7 @@ const computeModeDefaults = (mode: PermissionMode, tools: Tool[]): string[] => {
const unique = (values: string[]) => Array.from(new Set(values))
export const AgentToolingSettings: FC<AgentToolingSettingsProps> = ({ agent, updateAgent }) => {
export const ToolingSettings: FC<AgentToolingSettingsProps> = ({ agentBase, update }) => {
const { containerRef, handleScroll } = useScrollPosition('AgentToolingSettings', 100)
const { t } = useTranslation()
const client = useAgentClient()
@ -122,11 +130,11 @@ export const AgentToolingSettings: FC<AgentToolingSettingsProps> = ({ agent, upd
const [selectedMcpIds, setSelectedMcpIds] = useState<string[]>([])
const [isUpdatingMcp, setIsUpdatingMcp] = useState(false)
const availableTools = useMemo(() => agent?.tools ?? [], [agent?.tools])
const availableTools = useMemo(() => agentBase?.tools ?? [], [agentBase?.tools])
const availableServers = useMemo(() => allServers ?? [], [allServers])
useEffect(() => {
if (!agent) {
if (!agentBase) {
setConfiguration(defaultConfiguration)
setSelectedMode(defaultConfiguration.permission_mode)
setApprovedToolIds([])
@ -134,13 +142,13 @@ export const AgentToolingSettings: FC<AgentToolingSettingsProps> = ({ agent, upd
setSelectedMcpIds([])
return
}
const parsed = AgentConfigurationSchema.parse(agent.configuration ?? {}) as AgentConfigurationState
const parsed: AgentConfigurationState = AgentConfigurationSchema.parse(agentBase.configuration ?? {})
setConfiguration(parsed)
setSelectedMode(parsed.permission_mode)
const defaults = computeModeDefaults(parsed.permission_mode, availableTools)
setAutoToolIds(defaults)
const allowed = agent.allowed_tools ?? []
const allowed = agentBase.allowed_tools ?? []
setApprovedToolIds((prev) => {
const sanitized = allowed.filter((id) => availableTools.some((tool) => tool.id === id))
const isSame = sanitized.length === prev.length && sanitized.every((id) => prev.includes(id))
@ -151,8 +159,8 @@ export const AgentToolingSettings: FC<AgentToolingSettingsProps> = ({ agent, upd
const merged = unique([...sanitized, ...defaults])
return merged
})
setSelectedMcpIds(agent.mcps ?? [])
}, [agent, availableTools])
setSelectedMcpIds(agentBase.mcps ?? [])
}, [agentBase, availableTools])
const filteredTools = useMemo(() => {
if (!searchTerm.trim()) {
@ -173,7 +181,7 @@ export const AgentToolingSettings: FC<AgentToolingSettingsProps> = ({ agent, upd
const handleSelectPermissionMode = useCallback(
(nextMode: PermissionMode) => {
if (!agent || nextMode === selectedMode || isUpdatingMode) {
if (!agentBase || nextMode === selectedMode || isUpdatingMode) {
return
}
const defaults = computeModeDefaults(nextMode, availableTools)
@ -184,7 +192,11 @@ export const AgentToolingSettings: FC<AgentToolingSettingsProps> = ({ agent, upd
setIsUpdatingMode(true)
try {
const nextConfiguration = { ...configuration, permission_mode: nextMode } satisfies AgentConfigurationState
await updateAgent({ id: agent.id, configuration: nextConfiguration, allowed_tools: merged })
await update({
id: agentBase.id,
configuration: nextConfiguration,
allowed_tools: merged
} satisfies UpdateAgentBaseForm)
setConfiguration(nextConfiguration)
setSelectedMode(nextMode)
setAutoToolIds(defaults)
@ -236,14 +248,14 @@ export const AgentToolingSettings: FC<AgentToolingSettingsProps> = ({ agent, upd
}
},
[
agent,
agentBase,
selectedMode,
isUpdatingMode,
availableTools,
userAddedIds,
autoToolIds,
configuration,
updateAgent,
update,
modal,
t
]
@ -251,7 +263,7 @@ export const AgentToolingSettings: FC<AgentToolingSettingsProps> = ({ agent, upd
const handleToggleTool = useCallback(
(toolId: string, isApproved: boolean) => {
if (!agent || isUpdatingTools) {
if (!agentBase || isUpdatingTools) {
return
}
startTransition(() => {
@ -267,7 +279,7 @@ export const AgentToolingSettings: FC<AgentToolingSettingsProps> = ({ agent, upd
setIsUpdatingTools(true)
void (async () => {
try {
await updateAgent({ id: agent.id, allowed_tools: sanitized })
await update({ id: agentBase.id, allowed_tools: sanitized } satisfies UpdateAgentBaseForm)
} finally {
setIsUpdatingTools(false)
}
@ -276,7 +288,7 @@ export const AgentToolingSettings: FC<AgentToolingSettingsProps> = ({ agent, upd
})
})
},
[agent, isUpdatingTools, availableTools, autoToolIds, updateAgent]
[agentBase, isUpdatingTools, availableTools, autoToolIds, update]
)
const { agentSummary, autoCount, customCount } = useMemo(() => {
@ -297,7 +309,7 @@ export const AgentToolingSettings: FC<AgentToolingSettingsProps> = ({ agent, upd
const handleToggleMcp = useCallback(
(serverId: string, enabled: boolean) => {
if (!agent || isUpdatingMcp) {
if (!agentBase || isUpdatingMcp) {
return
}
setSelectedMcpIds((prev) => {
@ -309,9 +321,9 @@ export const AgentToolingSettings: FC<AgentToolingSettingsProps> = ({ agent, upd
setIsUpdatingMcp(true)
void (async () => {
try {
await updateAgent({ id: agent.id, mcps: next })
const refreshed = await client.getAgent(agent.id)
const key = client.agentPaths.withId(agent.id)
await update({ id: agentBase.id, mcps: next } satisfies UpdateAgentBaseForm)
const refreshed = await client.getAgent(agentBase.id)
const key = client.agentPaths.withId(agentBase.id)
mutate(key, refreshed, false)
} finally {
setIsUpdatingMcp(false)
@ -320,10 +332,10 @@ export const AgentToolingSettings: FC<AgentToolingSettingsProps> = ({ agent, upd
return next
})
},
[agent, isUpdatingMcp, client, updateAgent]
[agentBase, isUpdatingMcp, client, update]
)
if (!agent) {
if (!agentBase) {
return null
}
@ -571,4 +583,4 @@ export const AgentToolingSettings: FC<AgentToolingSettingsProps> = ({ agent, upd
)
}
export default AgentToolingSettings
export default ToolingSettings

View File

@ -1 +1,2 @@
export { default as AgentSettingsPopup } from './AgentSettingsPopup'
export { default as SessionSettingsPopup } from './SessionSettingsPopup'

View File

@ -1,7 +1,7 @@
import { Avatar, AvatarProps, cn } from '@heroui/react'
import { getAgentAvatar } from '@renderer/config/agent'
import { getAgentTypeLabel } from '@renderer/i18n/label'
import { AgentType } from '@renderer/types'
import { AgentSessionEntity, AgentType } from '@renderer/types'
import { Menu, Modal } from 'antd'
import React, { ReactNode } from 'react'
import styled from 'styled-components'
@ -41,6 +41,20 @@ export const AgentLabel: React.FC<AgentLabelProps> = ({ type, name, classNames,
)
}
export type SessionLabelProps = {
session?: AgentSessionEntity
className?: string
}
export const SessionLabel: React.FC<SessionLabelProps> = ({ session, className }) => {
const displayName = session?.name ?? session?.id
return (
<>
<span className={cn('px-2 text-sm', className)}>{displayName}</span>
</>
)
}
export interface SettingsItemProps extends React.ComponentPropsWithRef<'div'> {
/** Add a divider beneath the item if true, defaults to true. */
divider?: boolean

View File

@ -78,6 +78,20 @@ export const AgentBaseSchema = z.object({
export type AgentBase = z.infer<typeof AgentBaseSchema>
export const isAgentBase = (value: unknown): value is AgentBase => {
return AgentBaseSchema.safeParse(value).success
}
export const AgentBaseWithIdSchema = AgentBaseSchema.extend({
id: z.string()
})
export type AgentBaseWithId = z.infer<typeof AgentBaseWithIdSchema>
export const isAgentBaseWithId = (value: unknown): value is AgentBaseWithId => {
return AgentBaseWithIdSchema.safeParse(value).success
}
// ------------------ Persistence entities ------------------
// Agent entity representing an autonomous agent configuration
@ -90,6 +104,10 @@ export const AgentEntitySchema = AgentBaseSchema.extend({
export type AgentEntity = z.infer<typeof AgentEntitySchema>
export const isAgentEntity = (value: unknown): value is AgentEntity => {
return AgentEntitySchema.safeParse(value).success
}
export interface ListOptions {
limit?: number
offset?: number
@ -108,6 +126,10 @@ export const AgentSessionEntitySchema = AgentBaseSchema.extend({
export type AgentSessionEntity = z.infer<typeof AgentSessionEntitySchema>
export const isAgentSessionEntity = (value: unknown): value is AgentSessionEntity => {
return AgentSessionEntitySchema.safeParse(value).success
}
// AgentSessionMessageEntity representing a message within a session
export const AgentSessionMessageEntitySchema = z.object({
id: z.number(), // Auto-increment primary key
@ -190,6 +212,8 @@ export type UpdateSessionForm = Partial<BaseSessionForm> & { id: string }
export type SessionForm = CreateSessionForm | UpdateSessionForm
export type UpdateAgentBaseForm = Partial<AgentBase> & { id: string }
// ------------------ API data transfer objects ------------------
export interface CreateAgentRequest extends AgentBase {
type: AgentType