diff --git a/apps/desktop/e2e/settings.spec.ts b/apps/desktop/e2e/settings.spec.ts index e9b09c6671..0411971704 100644 --- a/apps/desktop/e2e/settings.spec.ts +++ b/apps/desktop/e2e/settings.spec.ts @@ -28,6 +28,49 @@ test('changing the theme in settings applies to the UI', async ({ window: page } ).toBe(true); }); +test('subagent presets can be reviewed and edited in desktop settings', async ({ window: page }) => { + await page.evaluate(async () => { + const connections = await window.maka.connections.list(); + const connection = connections[0]; + if (!connection) throw new Error('E2E subagent settings requires a seeded connection'); + await window.maka.settings.update({ + subagents: { + presets: [{ + id: 'e2e-fast-reader', + name: 'E2E 快速阅读', + description: '快速阅读大型代码仓库。', + profile: 'local_read', + connectionSlug: connection.slug, + model: connection.enabledModelIds?.[0] ?? connection.defaultModel, + enabled: true, + }], + }, + }); + }); + + await page.getByRole('button', { name: '展开侧边栏' }).click(); + await page.getByRole('button', { name: '设置' }).click(); + await settingsNavigation(page).getByRole('button', { name: '子 Agent', exact: true }).click(); + + const settings = page.getByRole('main', { name: '设置内容' }); + await expect(settings.getByRole('heading', { name: '子 Agent', exact: true })).toBeVisible(); + await expect(settings.getByText('E2E 快速阅读', { exact: true })).toBeVisible(); + await expect(settings.getByText('可用', { exact: true })).toBeVisible(); + + await settings.getByRole('button', { name: '编辑', exact: true }).click(); + const dialog = page.getByRole('dialog', { name: '编辑子 Agent' }); + const description = dialog.getByRole('textbox', { name: '适用场景' }); + await description.fill('快速阅读代码,并总结关键调用链。'); + await dialog.getByRole('button', { name: '保存', exact: true }).click(); + + await expect(dialog).toBeHidden(); + await expect(settings.getByText('快速阅读代码,并总结关键调用链。', { exact: true })).toBeVisible(); + await expect.poll(async () => page.evaluate(async () => { + const current = await window.maka.settings.get(); + return current.subagents.presets[0]?.description; + })).toBe('快速阅读代码,并总结关键调用链。'); +}); + test('remote access prioritizes a configured channel that needs attention', async ({ window: page }) => { const runtimeError = 'runtime-diagnostic-'.repeat(10); await page.evaluate(async (lastError) => { diff --git a/apps/desktop/src/main/__tests__/subagent-preset-presentation.test.ts b/apps/desktop/src/main/__tests__/subagent-preset-presentation.test.ts new file mode 100644 index 0000000000..5e59c0b986 --- /dev/null +++ b/apps/desktop/src/main/__tests__/subagent-preset-presentation.test.ts @@ -0,0 +1,64 @@ +import { strict as assert } from 'node:assert'; +import { describe, it } from 'node:test'; +import type { LlmConnection, SubagentPreset } from '@maka/core'; +import { + subagentPresetAvailability, + suggestSubagentPresetId, +} from '../../renderer/settings/subagent-preset-presentation.js'; + +function connection(input: Partial = {}): LlmConnection { + return { + slug: 'deepseek', + name: 'DeepSeek', + providerType: 'deepseek', + defaultModel: 'deepseek-chat', + enabled: true, + enabledModelIds: ['deepseek-chat'], + createdAt: 0, + updatedAt: 0, + ...input, + }; +} + +function preset(input: Partial = {}): SubagentPreset { + return { + id: 'fast-reader', + name: 'Fast reader', + description: 'Read large repositories quickly', + profile: 'local_read', + connectionSlug: 'deepseek', + model: 'deepseek-chat', + enabled: true, + ...input, + }; +} + +describe('subagentPresetAvailability', () => { + it('distinguishes disabled and broken model routes from usable presets', () => { + assert.deepEqual(subagentPresetAvailability(preset(), [connection()]), { + kind: 'available', + tone: 'success', + }); + assert.equal(subagentPresetAvailability(preset({ enabled: false }), []).kind, 'disabled'); + assert.equal(subagentPresetAvailability(preset(), []).kind, 'missing_connection'); + assert.equal( + subagentPresetAvailability(preset(), [connection({ enabled: false })]).kind, + 'connection_disabled', + ); + assert.equal( + subagentPresetAvailability(preset({ model: 'deepseek-reasoner' }), [connection()]).kind, + 'model_disabled', + ); + }); +}); + +describe('suggestSubagentPresetId', () => { + it('creates stable safe ids and resolves collisions', () => { + assert.equal(suggestSubagentPresetId('Fast Code Reader', new Set()), 'fast-code-reader'); + assert.equal(suggestSubagentPresetId('快速阅读', new Set()), 'subagent'); + assert.equal( + suggestSubagentPresetId('Fast Code Reader', new Set(['fast-code-reader', 'fast-code-reader-2'])), + 'fast-code-reader-3', + ); + }); +}); diff --git a/apps/desktop/src/renderer/locales/settings-navigation-copy.ts b/apps/desktop/src/renderer/locales/settings-navigation-copy.ts index dd331850aa..58e07455e3 100644 --- a/apps/desktop/src/renderer/locales/settings-navigation-copy.ts +++ b/apps/desktop/src/renderer/locales/settings-navigation-copy.ts @@ -18,6 +18,7 @@ const SETTINGS_NAVIGATION_COPY_BY_LOCALE = { general: { label: '通用', description: '隐身、启动、对话默认与网络代理等系统偏好。' }, appearance: { label: '外观', description: '主题、配色与界面语言。' }, models: { label: '模型', description: '模型连接、API key 与 OAuth 订阅管理。' }, + subagents: { label: '子 Agent', description: '配置主 Agent 可以自动选择的子 Agent、能力边界与模型。' }, usage: { label: '使用统计', description: 'token、模型、工具使用走势与配额追踪。' }, memory: { label: '记忆', description: 'Maka 记住的内容,以及本地 MEMORY.md 文件。' }, 'daily-review': { label: '每日回顾', description: '每天分析本机对话,生成摘要、遗漏提醒和建议。' }, @@ -41,6 +42,7 @@ const SETTINGS_NAVIGATION_COPY_BY_LOCALE = { general: { label: 'General', description: 'Privacy, startup, conversation defaults, and network proxy preferences.' }, appearance: { label: 'Appearance', description: 'Theme, color palette, and interface language.' }, models: { label: 'Models', description: 'Model connections, API keys, and OAuth subscriptions.' }, + subagents: { label: 'Subagents', description: 'Configure the subagents, capability boundaries, and models the main agent may select.' }, usage: { label: 'Usage', description: 'Token, model, tool usage trends, and quota tracking.' }, memory: { label: 'Memory', description: 'What Maka remembers, and the local MEMORY.md file.' }, 'daily-review': { label: 'Daily Review', description: 'Analyze local conversations for summaries, reminders, and suggestions.' }, diff --git a/apps/desktop/src/renderer/locales/settings-subagents-copy.ts b/apps/desktop/src/renderer/locales/settings-subagents-copy.ts new file mode 100644 index 0000000000..71f7aad786 --- /dev/null +++ b/apps/desktop/src/renderer/locales/settings-subagents-copy.ts @@ -0,0 +1,252 @@ +import type { + SubagentProfile, + ThinkingLevel, + UiCatalog, + UiLocale, +} from '@maka/core'; + +type ProfileCopy = { + label: string; + description: string; +}; + +export type SubagentSettingsCopy = { + section: { + title: string; + description: string; + count(enabled: number, total: number): string; + add: string; + limitReached: string; + emptyTitle: string; + emptyDescription: string; + }; + row: { + edit: string; + remove: string; + enabled: string; + fallbackDescription: string; + route(profile: string, connection: string, model: string, thinking?: string): string; + }; + status: { + available: string; + disabled: string; + missingConnection: string; + connectionDisabled: string; + modelDisabled: string; + }; + editor: { + createTitle: string; + createSubtitle: string; + editTitle: string; + editSubtitle: string; + name: string; + namePlaceholder: string; + id: string; + idDescription: string; + idPlaceholder: string; + description: string; + descriptionHelp: string; + descriptionPlaceholder: string; + profile: string; + connection: string; + model: string; + thinking: string; + defaultThinking: string; + enabled: string; + enabledDescription: string; + implementationWarning: string; + noConnection: string; + noModel: string; + requiredName: string; + requiredDescription: string; + invalidId: string; + duplicateId: string; + invalidRoute: string; + cancel: string; + create: string; + save: string; + saving: string; + }; + remove: { + title(name: string): string; + description: string; + confirm: string; + cancel: string; + }; + toast: { + saveFailed: string; + }; + profiles: Record; + thinking: Record; +}; + +const SETTINGS_SUBAGENTS_COPY_BY_LOCALE = { + zh: { + section: { + title: '已批准的子 Agent', + description: '主 Agent 会根据适用场景,从已启用且可用的配置中选择。每个配置固定自己的能力边界、连接和模型。', + count: (enabled, total) => `已启用 ${enabled} / 共 ${total}`, + add: '添加子 Agent', + limitReached: '已达到 64 个配置的上限', + emptyTitle: '还没有子 Agent 配置', + emptyDescription: '添加一个配置后,主 Agent 就能把合适的任务交给独立模型处理。', + }, + row: { + edit: '编辑', + remove: '删除', + enabled: '启用', + fallbackDescription: '尚未填写适用场景', + route: (profile, connection, model, thinking) => + `${profile} · ${connection} / ${model}${thinking ? ` · 思考 ${thinking}` : ''}`, + }, + status: { + available: '可用', + disabled: '已停用', + missingConnection: '连接不存在', + connectionDisabled: '连接已停用', + modelDisabled: '模型未启用', + }, + editor: { + createTitle: '添加子 Agent', + createSubtitle: '创建一个可由主 Agent 自动选择的模型配置。', + editTitle: '编辑子 Agent', + editSubtitle: '修改适用场景、能力边界和模型路由。', + name: '显示名称', + namePlaceholder: '快速代码阅读', + id: 'subagent_id', + idDescription: '创建后保持不变,主 Agent 和历史会话会用它识别此配置。', + idPlaceholder: 'fast-reader', + description: '适用场景', + descriptionHelp: '写清楚何时应该使用它;主 Agent 主要根据这段描述挑选配置。', + descriptionPlaceholder: '适合快速、低成本地阅读大型仓库', + profile: '能力 Profile', + connection: '模型连接', + model: '模型', + thinking: '思考级别', + defaultThinking: '跟随模型默认', + enabled: '立即启用', + enabledDescription: '启用后,主 Agent 可以选择这个配置。', + implementationWarning: '实现代码 Profile 可以写文件和执行命令,并会在隔离 worktree 中运行。', + noConnection: '请先在“模型”页启用一个模型连接。', + noModel: '所选连接没有已启用的模型。', + requiredName: '请输入显示名称。', + requiredDescription: '请说明这个子 Agent 的适用场景。', + invalidId: '只能使用字母、数字、点、下划线、冒号和连字符,最多 128 个字符。', + duplicateId: '这个 subagent_id 已经存在。', + invalidRoute: '请选择已启用的连接和模型。', + cancel: '取消', + create: '创建', + save: '保存', + saving: '保存中…', + }, + remove: { + title: (name) => `删除“${name}”?`, + description: '主 Agent 将不再看到这个配置。已创建的子会话不会被删除。', + confirm: '删除', + cancel: '取消', + }, + toast: { + saveFailed: '保存子 Agent 配置失败', + }, + profiles: { + local_read: { label: '代码阅读', description: '只读访问当前工作区,适合搜索、理解和总结代码。' }, + web_research: { label: '网络研究', description: '只使用联网搜索,适合查找外部资料和最新信息。' }, + implementation: { label: '实现代码', description: '可以读写文件并执行命令,在隔离 worktree 中完成改动。' }, + }, + thinking: { + off: '关闭', + minimal: '最少', + low: '低', + medium: '中', + high: '高', + xhigh: '超高', + max: '最大', + }, + }, + en: { + section: { + title: 'Approved subagents', + description: 'The main agent selects from enabled, available presets based on when each should be used. Every preset fixes its capability boundary, connection, and model.', + count: (enabled, total) => `${enabled} enabled · ${total} total`, + add: 'Add subagent', + limitReached: 'The 64-preset limit has been reached', + emptyTitle: 'No subagent presets yet', + emptyDescription: 'Add a preset so the main agent can delegate suitable work to a separate model.', + }, + row: { + edit: 'Edit', + remove: 'Remove', + enabled: 'Enabled', + fallbackDescription: 'No usage guidance yet', + route: (profile, connection, model, thinking) => + `${profile} · ${connection} / ${model}${thinking ? ` · Thinking ${thinking}` : ''}`, + }, + status: { + available: 'Available', + disabled: 'Disabled', + missingConnection: 'Connection missing', + connectionDisabled: 'Connection disabled', + modelDisabled: 'Model not enabled', + }, + editor: { + createTitle: 'Add subagent', + createSubtitle: 'Create a model preset that the main agent can select automatically.', + editTitle: 'Edit subagent', + editSubtitle: 'Change its usage guidance, capability boundary, and model route.', + name: 'Display name', + namePlaceholder: 'Fast code reader', + id: 'subagent_id', + idDescription: 'Stable after creation. The main agent and session history use it to identify this preset.', + idPlaceholder: 'fast-reader', + description: 'When to use', + descriptionHelp: 'Describe when this preset is the right choice. The main agent relies primarily on this guidance.', + descriptionPlaceholder: 'Fast, low-cost exploration of large repositories', + profile: 'Capability profile', + connection: 'Model connection', + model: 'Model', + thinking: 'Thinking level', + defaultThinking: 'Use model default', + enabled: 'Enable immediately', + enabledDescription: 'When enabled, the main agent may select this preset.', + implementationWarning: 'The Implementation profile can write files and run commands inside an isolated worktree.', + noConnection: 'Enable a model connection on the Models page first.', + noModel: 'The selected connection has no enabled models.', + requiredName: 'Enter a display name.', + requiredDescription: 'Describe when this subagent should be used.', + invalidId: 'Use only letters, numbers, dots, underscores, colons, and hyphens, up to 128 characters.', + duplicateId: 'That subagent_id already exists.', + invalidRoute: 'Select an enabled connection and model.', + cancel: 'Cancel', + create: 'Create', + save: 'Save', + saving: 'Saving…', + }, + remove: { + title: (name) => `Remove “${name}”?`, + description: 'The main agent will no longer see this preset. Existing child sessions are not deleted.', + confirm: 'Remove', + cancel: 'Cancel', + }, + toast: { + saveFailed: 'Failed to save subagent presets', + }, + profiles: { + local_read: { label: 'Code reading', description: 'Read-only access to the current workspace for search, understanding, and summaries.' }, + web_research: { label: 'Web research', description: 'Web search only, for external sources and current information.' }, + implementation: { label: 'Implementation', description: 'Read and write files and run commands in an isolated worktree.' }, + }, + thinking: { + off: 'Off', + minimal: 'Minimal', + low: 'Low', + medium: 'Medium', + high: 'High', + xhigh: 'Extra high', + max: 'Maximum', + }, + }, +} satisfies UiCatalog; + +export function getSubagentSettingsCopy(locale: UiLocale): SubagentSettingsCopy { + return SETTINGS_SUBAGENTS_COPY_BY_LOCALE[locale]; +} diff --git a/apps/desktop/src/renderer/locales/shell-copy.ts b/apps/desktop/src/renderer/locales/shell-copy.ts index 3d7849ed5d..fee61b4a90 100644 --- a/apps/desktop/src/renderer/locales/shell-copy.ts +++ b/apps/desktop/src/renderer/locales/shell-copy.ts @@ -633,6 +633,7 @@ const ZH_SETTINGS_SECTIONS: Record = { general: '通用', appearance: '外观', models: '模型', + subagents: '子 Agent', usage: '使用统计', memory: '记忆', 'daily-review': '每日回顾', @@ -649,6 +650,7 @@ const EN_SETTINGS_SECTIONS: Record = { general: 'General', appearance: 'Appearance', models: 'Models', + subagents: 'Subagents', usage: 'Usage', memory: 'Memory', 'daily-review': 'Daily Review', diff --git a/apps/desktop/src/renderer/settings/settings-nav.ts b/apps/desktop/src/renderer/settings/settings-nav.ts index 7a212a68fd..1de56739a6 100644 --- a/apps/desktop/src/renderer/settings/settings-nav.ts +++ b/apps/desktop/src/renderer/settings/settings-nav.ts @@ -13,6 +13,7 @@ import { Search, Settings as SettingsIcon, ShieldCheck, + Workflow, type LucideProps, } from '@maka/ui/icons'; import type { SettingsSection, UiLocale } from '@maka/core'; @@ -60,6 +61,7 @@ export const SETTINGS_NAV: SettingsNavItem[] = [ { id: 'general', Icon: SettingsIcon, enabled: true, group: 'preferences' }, { id: 'appearance', Icon: Palette, enabled: true, group: 'preferences' }, { id: 'models', Icon: Cpu, enabled: true, group: 'capabilities' }, + { id: 'subagents', Icon: Workflow, enabled: true, group: 'capabilities' }, { id: 'memory', Icon: Brain, enabled: true, group: 'capabilities' }, { id: 'voice', Icon: Mic, enabled: true, group: 'capabilities' }, { id: 'bot-chat', Icon: Bot, enabled: true, group: 'capabilities' }, diff --git a/apps/desktop/src/renderer/settings/settings-surface.tsx b/apps/desktop/src/renderer/settings/settings-surface.tsx index 2c9c73513f..bdbb34ade6 100644 --- a/apps/desktop/src/renderer/settings/settings-surface.tsx +++ b/apps/desktop/src/renderer/settings/settings-surface.tsx @@ -29,7 +29,7 @@ import type { import { createDefaultSettings } from '@maka/core/settings'; import { useMountedRef, useToast, useUiLocale } from '@maka/ui'; import { ProvidersPanel } from './ProvidersPanel'; -import { SubagentPresetsPanel } from './subagent-presets-panel'; +import { SubagentSettingsPage } from './subagent-settings-page'; import { safeLocalStorageSet } from '../browser-storage'; import { AboutSettingsPage } from './about-settings-page'; import { AppearanceSettingsPage } from './appearance-settings-page'; @@ -405,13 +405,16 @@ function SettingsPageBody(props: { initialCreateProviderType={props.initialCreateProviderType} onInitialCreateProviderConsumed={props.onInitialCreateProviderConsumed} /> - ); + case 'subagents': + return ( + + ); case 'usage': return ( candidate.slug === preset.connectionSlug); + if (!connection) return { kind: 'missing_connection', tone: 'destructive' }; + if (!connection.enabled) return { kind: 'connection_disabled', tone: 'warning' }; + if (!connectionEnabledModelIds(connection).includes(preset.model)) { + return { kind: 'model_disabled', tone: 'warning' }; + } + return { kind: 'available', tone: 'success' }; +} + +export function suggestSubagentPresetId( + name: string, + existingIds: ReadonlySet, +): string { + const normalized = name + .normalize('NFKD') + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, '') + .slice(0, 96); + const base = normalized || 'subagent'; + if (!existingIds.has(base)) return base; + for (let suffix = 2; suffix < 10_000; suffix += 1) { + const candidate = `${base}-${suffix}`; + if (!existingIds.has(candidate)) return candidate; + } + return `${base}-${Date.now()}`; +} diff --git a/apps/desktop/src/renderer/settings/subagent-presets-panel.tsx b/apps/desktop/src/renderer/settings/subagent-presets-panel.tsx deleted file mode 100644 index 00a498734a..0000000000 --- a/apps/desktop/src/renderer/settings/subagent-presets-panel.tsx +++ /dev/null @@ -1,209 +0,0 @@ -import { useMemo, useState } from 'react'; -import { Item } from '@astryxdesign/core'; -import { SettingsSection } from './settings-section'; -import { - connectionEnabledModelIds, - isSafeSubagentPresetId, - type AppSettings, - type LlmConnection, - type SubagentPreset, - type SubagentProfile, - type UpdateAppSettingsResult, -} from '@maka/core'; -import { Button, FormLayout, Selector, Switch, TextInput, useToast, useUiLocale } from '@maka/ui'; -import { settingsActionErrorMessage } from './settings-error-copy'; - -const PROFILE_OPTIONS: Array<{ value: SubagentProfile; label: string }> = [ - { value: 'local_read', label: 'Local Read' }, - { value: 'web_research', label: 'Web Research' }, - { value: 'implementation', label: 'Implementation' }, -]; - -export function SubagentPresetsPanel(props: { - settings: AppSettings; - connections: readonly LlmConnection[]; - onUpdate( - patch: Parameters[0], - ): Promise; -}) { - const locale = useUiLocale(); - const zh = locale === 'zh'; - const toast = useToast(); - const usableConnections = useMemo( - () => props.connections.filter((connection) => connection.enabled), - [props.connections], - ); - const [name, setName] = useState(''); - const [description, setDescription] = useState(''); - const [id, setId] = useState(''); - const [profile, setProfile] = useState('local_read'); - const [connectionSlug, setConnectionSlug] = useState(usableConnections[0]?.slug ?? ''); - const selectedConnection = - usableConnections.find((connection) => connection.slug === connectionSlug) ?? - usableConnections[0]; - const modelIds = selectedConnection ? connectionEnabledModelIds(selectedConnection) : []; - const [model, setModel] = useState(modelIds[0] ?? ''); - const selectedModel = modelIds.includes(model) ? model : (modelIds[0] ?? ''); - const [saving, setSaving] = useState(false); - - async function persist(presets: SubagentPreset[]): Promise { - setSaving(true); - try { - await props.onUpdate({ subagents: { presets } }); - return true; - } catch (error) { - toast.error( - zh ? '保存子 Agent 配置失败' : 'Failed to save subagent presets', - settingsActionErrorMessage(error, locale), - ); - return false; - } finally { - setSaving(false); - } - } - - async function addPreset() { - const presetId = id.trim(); - if ( - !isSafeSubagentPresetId(presetId) || - !name.trim() || - !selectedConnection || - !selectedModel || - props.settings.subagents.presets.some((preset) => preset.id === presetId) - ) { - toast.error( - zh - ? '请填写唯一且有效的 ID、名称、连接和模型' - : 'Enter a unique valid id, name, connection, and model', - ); - return; - } - const saved = await persist([ - ...props.settings.subagents.presets, - { - id: presetId, - name: name.trim(), - description: description.trim(), - profile, - connectionSlug: selectedConnection.slug, - model: selectedModel, - enabled: true, - }, - ]); - if (!saved) return; - setName(''); - setDescription(''); - setId(''); - } - - // Deep-review fix: was a Card-wrapped `.settingsRows` (the retired card - // dialect) with a leading Item doing a section header's job. - return ( - - {props.settings.subagents.presets.map((preset) => ( - - - void persist( - props.settings.subagents.presets.map((candidate) => - candidate.id === preset.id ? { ...candidate, enabled } : candidate, - ), - ) - } - /> -