diff --git a/FEATURES.md b/FEATURES.md index ba82ecd..21e12ad 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -24,7 +24,7 @@ - [x] **DeepSeek Harness(预览)**:使用 GoodBuddy 固定 Host 和 OpenAI 兼容模型连接;Ask 只允许调用 Host 中真实注册的 `read`、`skill` 以及 Main 管理的 Web Search/Fetch 代理,拒绝插件同名冒充,Execute 放行全部已启用内置及插件工具,并以当前用户权限运行。图像输入跟随所选模型连接的能力声明,文本模型在 Host 或模型调用前拒绝图片,图片模型通过有界内联内容和临时 Attachment Store 接收 JPEG/PNG。 - [x] **DSH npm 插件市场**:市场默认关闭,由用户显式开启后搜索公共 npm 的 `dsh-plugin` 包,使用捆绑 npm 执行精确版本安装和普通 lifecycle scripts,并支持启停、JSON 配置、移除、失败启动自动停用和离线管理已安装插件;关闭市场只隐藏目录与管理界面,不改变已有插件的启停状态,第三方代码不受 Ask 初始化隔离。 - [x] **Ask 与 Execute 工作模式**:Ask 保持只读;Execute 运行已启用且受边界约束的工具。 -- [x] **专家与 Subagent**:支持显式专家、团队分析和最多三个只读专家并行分析。 +- [x] **专家与 Subagent**:支持显式专家、团队分析和最多三个只读专家并行分析;聊天先展示可逐项展开的专家完整输出,再在其下展示总 Agent 的综合结果,并随会话保存。 - [x] **角色绑定模型连接**:每个角色可继承默认模型或选择独立文本模型连接,失效连接安全回退默认模型,综合角色始终继承默认模型。 - [x] **多协议模型配置**:支持 Anthropic Messages、OpenAI Chat Completions、OpenAI Images 和无认证本机模型。 - [x] **上下文用量与自动压缩**:直连模型按每次成功调用更新供应商用量,图片与工具轮次使用同一口径,供应商缺失 usage 时才回退估算;界面明确区分“本次模型调用”和“压缩后对话估算”,压缩线始终根据当前设置与所选模型窗口即时计算,不在每个对话中保存旧配置;压缩标识的前后值使用同一估算口径,运行记录仍保留各次模型调用的供应商 usage。对话与多轮工具 Agent 可在已完成调用越过阈值后自动重复压缩,规划时先为固定提示、工具定义和摘要预留预算;同一回复会分别保留 Agent 工具上下文与对话历史的压缩标识,并在应用重启或较早消息滚出本地历史窗口后继续复用摘要。 diff --git a/src/main/assistant/subagent-service.test.ts b/src/main/assistant/subagent-service.test.ts index 458dcd2..c6e88b3 100644 --- a/src/main/assistant/subagent-service.test.ts +++ b/src/main/assistant/subagent-service.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it, vi } from 'vitest' import type { AssistantExpert } from '../../shared/assistant-contracts' +import type { SubagentEvent } from '../../shared/contracts' import type { AgentExecutionRequest, AgentRuntime @@ -36,10 +37,15 @@ function database() { describe('SubagentService', () => { it('creates a linked child task and puts expert instructions in system context', async () => { let executionRequest: AgentExecutionRequest | undefined + const expertOutput = '结果'.repeat(35_001) const runtime = { run: async function* (request: AgentExecutionRequest) { executionRequest = request - yield { requestId: request.requestId, type: 'text', delta: '结果' } as const + yield { + requestId: request.requestId, + type: 'text', + delta: expertOutput + } as const yield { requestId: request.requestId, type: 'done' } as const }, releaseConversation: vi.fn(async () => undefined), @@ -51,16 +57,17 @@ describe('SubagentService', () => { db as never, new SubagentScheduler({ timeoutMs: 1_000 }) ) - const events: string[] = [] + const events: SubagentEvent[] = [] const result = await service.run({ parentRequest, expert, routingMode: 'smart', signal: new AbortController().signal, - onEvent: (event) => events.push(event.state) + onEvent: (event) => events.push(event) }) - expect(result.output).toBe('结果') + expect(result.output).toBe(expertOutput) + expect(result.output.length).toBeGreaterThan(60_000) expect(db.createTask).toHaveBeenCalledWith( expect.objectContaining({ parentTaskId: parentRequest.requestId, @@ -73,13 +80,27 @@ describe('SubagentService', () => { expect(executionRequest?.trustedInstructions).toContain( expert.systemInstructions ) - expect(events).toEqual(['queued', 'running', 'completed']) + expect(events.map((event) => event.state)).toEqual([ + 'queued', + 'running', + 'completed' + ]) + expect(events.at(-1)).toMatchObject({ + state: 'completed', + output: expertOutput + }) await service.dispose() }) it('fails tool-producing experts and records bounded failure state', async () => { + const events: SubagentEvent[] = [] const runtime = { run: async function* (request: AgentExecutionRequest) { + yield { + requestId: request.requestId, + type: 'text', + delta: '部分结果' + } as const yield { requestId: request.requestId, type: 'tool', @@ -98,13 +119,18 @@ describe('SubagentService', () => { expert, routingMode: 'manual', signal: new AbortController().signal, - onEvent: vi.fn() + onEvent: (event) => events.push(event) })).rejects.toThrow('不允许工具调用') expect(db.updateTaskStatus).toHaveBeenLastCalledWith( expect.any(String), 'failed', expect.stringContaining('不允许工具调用') ) + expect(events.at(-1)).toMatchObject({ + state: 'failed', + output: '部分结果', + error: expect.stringContaining('不允许工具调用') + }) await service.dispose() }) diff --git a/src/main/assistant/subagent-service.ts b/src/main/assistant/subagent-service.ts index 46322ce..ccc6857 100644 --- a/src/main/assistant/subagent-service.ts +++ b/src/main/assistant/subagent-service.ts @@ -217,7 +217,7 @@ export class SubagentService { throw new Error(event.message) } if (event.type === 'text') { - output = `${output}${event.delta}`.slice(0, 60_000) + output = `${output}${event.delta}` } else if (event.type === 'done') { completed = true } @@ -226,7 +226,11 @@ export class SubagentService { throw new Error('专家子任务未报告完成') } this.database.updateTaskStatus(childTaskId, 'completed') - this.emit(input, { childTaskId, state: 'completed' }) + this.emit(input, { + childTaskId, + state: 'completed', + output + }) return { childTaskId, output } } catch (error) { const cancelled = scheduledSignal.aborted || input.signal.aborted @@ -240,6 +244,7 @@ export class SubagentService { this.emit(input, { childTaskId, state: cancelled ? 'cancelled' : 'failed', + output: output || undefined, error: message }) throw new SubagentRunError(message, output, { cause: error }) @@ -272,6 +277,7 @@ export class SubagentService { childTaskId: string state: SubagentEvent['state'] reason?: string + output?: string error?: string } ): void { @@ -286,6 +292,9 @@ export class SubagentService { ...(event.reason ? { reason: event.reason.slice(0, 240) } : {}), + ...(event.output !== undefined + ? { output: event.output } + : {}), ...(event.error ? { error: event.error.slice(0, 1_000) } : {}) diff --git a/src/renderer/src/App.test.tsx b/src/renderer/src/App.test.tsx index 9c7e8ec..ada009e 100644 --- a/src/renderer/src/App.test.tsx +++ b/src/renderer/src/App.test.tsx @@ -6461,24 +6461,53 @@ describe('App', () => { requestId: request.requestId, type: 'subagent', ...events[0]!, - state: 'completed' + state: 'completed', + output: '研究专家的独立结论' }) agentListener?.({ requestId: request.requestId, type: 'subagent', ...events[1]!, state: 'cancelled', - reason: '父任务已停止' + reason: '父任务已停止', + output: '代码专家的部分结论' }) }) expect(within(statusRegion).getByText('已完成')).toBeInTheDocument() expect(within(statusRegion).getByText('已取消')).toBeInTheDocument() expect(within(statusRegion).getByText('父任务已停止')).toBeInTheDocument() + expect( + within(statusRegion).getByText('研究专家的独立结论') + ).toBeInTheDocument() + expect( + within(statusRegion).getByText('代码专家的部分结论') + ).toBeInTheDocument() + await waitFor(() => { + const persistedMessages = vi + .mocked(api.conversations.saveLocal) + .mock.calls.flatMap(([batch]) => + batch.flatMap((conversation) => conversation.messages) + ) + expect( + persistedMessages.some( + (message) => + message.subagents?.[0]?.output === + '研究专家的独立结论' && + message.subagents?.[1]?.output === '代码专家的部分结论' + ) + ).toBe(true) + }) fireEvent.click(screen.getByText('运行记录')) expect(await screen.findAllByText('子专家')).toHaveLength(4) expect(screen.getAllByText(/智能路由/u).length).toBeGreaterThan(0) expect(screen.getAllByText(/手动指定/u).length).toBeGreaterThan(0) + expect( + screen.getByText(/研究专家的独立结论/u) + ).toBeInTheDocument() + expect( + screen.getByText(/代码专家的部分结论/u) + ).toBeInTheDocument() }) it('offers once, session, permanent, and deny for a tool call', async () => { diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index e503a44..895bcec 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -117,6 +117,7 @@ import { conversationContextCompressionMarkerSchema, conversationContextMetricsSchema, conversationMessageBlocksSchema, + conversationSubagentActivitySchema, interactiveWorkModes, normalizeInteractiveWorkMode, projectChannelLabels @@ -1090,6 +1091,14 @@ function isConversation(value: unknown): value is Conversation { compression ).success ))) && + (entry.subagents === undefined || + (Array.isArray(entry.subagents) && + entry.subagents.length <= 3 && + entry.subagents.every( + (subagent) => + conversationSubagentActivitySchema.safeParse(subagent) + .success + ))) && (entry.artifactIds === undefined || (Array.isArray(entry.artifactIds) && entry.artifactIds.length <= 8 && @@ -1139,6 +1148,7 @@ function toConversationMessage(message: Message): ConversationMessage { contextCompression: message.contextCompression, contextCompressions: message.contextCompressions, tools: message.tools, + subagents: message.subagents, sources: message.sources, sourceReferences: message.sourceReferences, knowledgeRetrieval: message.knowledgeRetrieval, @@ -3629,6 +3639,7 @@ function App(): React.JSX.Element { routingMode: event.routingMode, state: event.state, reason: event.reason, + output: event.output, error: event.error } if (index >= 0) { diff --git a/src/renderer/src/ChatTimeline.test.tsx b/src/renderer/src/ChatTimeline.test.tsx index 70fe5d6..aa73bc2 100644 --- a/src/renderer/src/ChatTimeline.test.tsx +++ b/src/renderer/src/ChatTimeline.test.tsx @@ -1,4 +1,10 @@ -import { cleanup, render, screen } from '@testing-library/react' +import { + cleanup, + fireEvent, + render, + screen, + within +} from '@testing-library/react' import { afterEach, describe, expect, it, vi } from 'vitest' import { ChatTimeline, @@ -153,4 +159,78 @@ describe('ChatTimeline', () => { ) ).toBeInTheDocument() }) + + it('shows every parallel expert output in its own expandable card', () => { + const messages: Message[] = [ + { + id: 'assistant-message', + role: 'assistant', + content: '综合结果', + createdAt: 1_775_000_000_000, + state: 'complete', + subagents: [ + { + childTaskId: '00000000-0000-4000-8000-000000000101', + expertId: '00000000-0000-4000-8000-000000000201', + expertName: '研究专家', + routingMode: 'manual', + state: 'completed', + output: '研究专家的独立结论' + }, + { + childTaskId: '00000000-0000-4000-8000-000000000102', + expertId: '00000000-0000-4000-8000-000000000202', + expertName: '代码专家', + routingMode: 'manual', + state: 'completed', + output: '代码专家的独立结论' + }, + { + childTaskId: '00000000-0000-4000-8000-000000000103', + expertId: '00000000-0000-4000-8000-000000000203', + expertName: '安全专家', + routingMode: 'manual', + state: 'completed', + output: '安全专家的独立结论' + } + ] + } + ] + render( + + ) + + const region = screen.getByLabelText('子专家状态') + const summary = screen.getByText('综合结果').parentElement + const messageBody = region.parentElement + expect(summary?.parentElement).toBe(messageBody) + expect( + Array.from(messageBody?.children ?? []).indexOf(region) + ).toBeLessThan( + Array.from(messageBody?.children ?? []).indexOf(summary!) + ) + const cards = within(region).getAllByRole('group') + expect(cards).toHaveLength(3) + for (const [index, output] of [ + '研究专家的独立结论', + '代码专家的独立结论', + '安全专家的独立结论' + ].entries()) { + expect(within(cards[index]!).getByText(output)).toBeInTheDocument() + expect(cards[index]).not.toHaveAttribute('open') + fireEvent.click(within(cards[index]!).getByText(/专家$/u)) + expect(cards[index]).toHaveAttribute('open') + } + }) }) diff --git a/src/renderer/src/ChatTimeline.tsx b/src/renderer/src/ChatTimeline.tsx index 11fe023..3bc2350 100644 --- a/src/renderer/src/ChatTimeline.tsx +++ b/src/renderer/src/ChatTimeline.tsx @@ -21,6 +21,7 @@ import type { ConversationContextCompressionMarker, ConversationMessage, ConversationMessageBlock, + ConversationSubagentActivity, ConversationToolActivity } from '../../shared/assistant-contracts' import { AgentQuestionCard } from './AgentQuestionCard' @@ -30,15 +31,7 @@ import { formatCompactTokens } from './token-format' export type ToolActivity = ConversationToolActivity -export type SubagentActivity = { - childTaskId: string - expertId: string - expertName: string - routingMode: 'manual' | 'smart' - state: 'queued' | 'running' | 'completed' | 'failed' | 'cancelled' - reason?: string - error?: string -} +export type SubagentActivity = ConversationSubagentActivity export type KnowledgeRetrievalStatus = Omit< Extract, @@ -223,6 +216,61 @@ function ToolExecutionList({ ) } +function SubagentStatusList({ + subagents +}: { + subagents: SubagentActivity[] +}): React.JSX.Element { + const { t } = useTranslation('app') + + return ( +
+ {subagents.slice(0, 3).map((subagent) => ( +
+ + +
+ {subagent.output ? ( +
+ {t('chat.subagents.output')} +
+ {subagent.output} +
+
+ ) : ( +

{t('chat.subagents.noOutput')}

+ )} + {(subagent.error || subagent.reason) && + (subagent.state === 'failed' || + subagent.state === 'cancelled') && ( +
+ {t('chat.subagents.error')} +

{subagent.error ?? subagent.reason}

+
+ )} +
+
+ ))} +
+ ) +} + type ChatMessageRowProps = { artifactById: ReadonlyMap canRetry: boolean @@ -406,6 +454,9 @@ function ChatMessageRowView({ })} )} + {message.subagents && message.subagents.length > 0 && ( + + )} {message.blocks && message.blocks.length > 0 ? (
{groupMessageBlocks(message.blocks).map((item) => @@ -638,37 +689,6 @@ function ChatMessageRowView({ message.tools.length > 0 && ( )} - {message.subagents && message.subagents.length > 0 && ( -
- {message.subagents.slice(0, 3).map((subagent) => ( -
-
- ))} -
- )} {message.approval && (
diff --git a/src/renderer/src/i18n/locales/en-US/app.ts b/src/renderer/src/i18n/locales/en-US/app.ts index b615c67..567d010 100644 --- a/src/renderer/src/i18n/locales/en-US/app.ts +++ b/src/renderer/src/i18n/locales/en-US/app.ts @@ -276,6 +276,9 @@ export const app = { smart: 'Smart routing', manual: 'Selected manually', fallbackTask: '{{name}} subagent task', + output: 'Expert output', + error: 'Execution details', + noOutput: 'This expert has no output to display yet.', states: { queued: 'Queued', running: 'Running', diff --git a/src/renderer/src/i18n/locales/zh-CN/app.ts b/src/renderer/src/i18n/locales/zh-CN/app.ts index 99210d2..643f8ae 100644 --- a/src/renderer/src/i18n/locales/zh-CN/app.ts +++ b/src/renderer/src/i18n/locales/zh-CN/app.ts @@ -270,6 +270,9 @@ export const app = { smart: '智能路由', manual: '手动指定', fallbackTask: '{{name}} 子专家任务', + output: '专家输出', + error: '执行说明', + noOutput: '该专家暂时没有可显示的输出。', states: { queued: '等待中', running: '进行中', diff --git a/src/renderer/src/styles.css b/src/renderer/src/styles.css index 3ceb29d..f6ffa6c 100644 --- a/src/renderer/src/styles.css +++ b/src/renderer/src/styles.css @@ -3891,15 +3891,34 @@ button > svg { } .subagent-status-card { - display: grid; - align-items: center; - padding: var(--space-2) var(--space-3); border: 1px solid var(--border-default); border-radius: var(--radius-control); background: var(--surface-subtle); color: var(--text-secondary); + overflow: hidden; +} + +.subagent-status-card > summary { + display: grid; + align-items: center; + padding: var(--space-2) var(--space-3); + cursor: pointer; gap: var(--space-2); grid-template-columns: auto minmax(0, 1fr) auto; + list-style: none; +} + +.subagent-status-card > summary::-webkit-details-marker { + display: none; +} + +.subagent-status-card > summary:hover { + background: var(--accent-subtle); +} + +.subagent-status-card > summary:focus-visible { + outline: 2px solid var(--accent); + outline-offset: -2px; } .subagent-status-card--running { @@ -3912,30 +3931,51 @@ button > svg { background: var(--danger-subtle); } -.subagent-status-card > div { +.subagent-status-card__identity { display: grid; min-width: 0; gap: var(--space-1); } -.subagent-status-card strong, -.subagent-status-card span { +.subagent-status-card summary strong, +.subagent-status-card summary > span:last-child { color: var(--text-primary); font-size: var(--font-body); } -.subagent-status-card small { +.subagent-status-card summary small { color: var(--text-muted); font-size: var(--font-caption); } -.subagent-status-card p { +.subagent-status-card__details { + display: grid; + padding: var(--space-3); + border-top: 1px solid var(--border-subtle); + background: var(--surface-raised); + gap: var(--space-3); +} + +.subagent-status-card__details section { + display: grid; + gap: var(--space-2); +} + +.subagent-status-card__details p { margin: 0; - color: var(--danger); - font-size: var(--font-caption); overflow-wrap: anywhere; } +.subagent-status-card__details > p { + color: var(--text-muted); + font-size: var(--font-caption); +} + +.subagent-status-card__error, +.subagent-status-card__error p { + color: var(--danger); +} + .approval-card { display: grid; align-items: center; diff --git a/src/shared/assistant-contracts.ts b/src/shared/assistant-contracts.ts index 650edca..a51362b 100644 --- a/src/shared/assistant-contracts.ts +++ b/src/shared/assistant-contracts.ts @@ -137,6 +137,29 @@ export type ConversationMessageBlock = z.infer< typeof conversationMessageBlockSchema > +export const conversationSubagentActivitySchema = z + .object({ + childTaskId: assistantIdSchema, + expertId: assistantIdSchema, + expertName: z.string().trim().min(1).max(80), + routingMode: z.enum(['manual', 'smart']), + state: z.enum([ + 'queued', + 'running', + 'completed', + 'failed', + 'cancelled' + ]), + reason: z.string().trim().min(1).max(240).optional(), + output: z.string().optional(), + error: z.string().trim().min(1).max(1_000).optional() + }) + .strict() + +export type ConversationSubagentActivity = z.infer< + typeof conversationSubagentActivitySchema +> + export const conversationContextCompressionMarkerSchema = z .object({ state: z.enum(['compressing', 'completed', 'failed']), @@ -168,6 +191,10 @@ export const conversationMessageSchema = z .max(2) .optional(), tools: z.array(conversationToolActivitySchema).max(100).optional(), + subagents: z + .array(conversationSubagentActivitySchema) + .max(3) + .optional(), sources: z.array(z.string().max(8_192)).max(100).optional(), sourceReferences: z .array( diff --git a/src/shared/contracts.ts b/src/shared/contracts.ts index 2fa732c..d675735 100644 --- a/src/shared/contracts.ts +++ b/src/shared/contracts.ts @@ -957,6 +957,7 @@ export const subagentEventSchema = z 'cancelled' ]), reason: z.string().trim().min(1).max(240).optional(), + output: z.string().optional(), error: z.string().trim().min(1).max(1_000).optional() }) .strict()