fix: show expert outputs before synthesis
Parallel expert runs previously exposed only child status, while the synthesized answer appeared above the expert cards. Each expert now retains and exposes its complete output, the cards can be expanded, and the final synthesis is rendered beneath them and persisted with the conversation. Release note: 多专家并行分析现在可展开查看每位专家的完整输出,并在其下方显示总 Agent 的综合结果。
This commit is contained in:
+1
-1
@@ -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] **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] **DSH npm 插件市场**:市场默认关闭,由用户显式开启后搜索公共 npm 的 `dsh-plugin` 包,使用捆绑 npm 执行精确版本安装和普通 lifecycle scripts,并支持启停、JSON 配置、移除、失败启动自动停用和离线管理已安装插件;关闭市场只隐藏目录与管理界面,不改变已有插件的启停状态,第三方代码不受 Ask 初始化隔离。
|
||||||
- [x] **Ask 与 Execute 工作模式**:Ask 保持只读;Execute 运行已启用且受边界约束的工具。
|
- [x] **Ask 与 Execute 工作模式**:Ask 保持只读;Execute 运行已启用且受边界约束的工具。
|
||||||
- [x] **专家与 Subagent**:支持显式专家、团队分析和最多三个只读专家并行分析。
|
- [x] **专家与 Subagent**:支持显式专家、团队分析和最多三个只读专家并行分析;聊天先展示可逐项展开的专家完整输出,再在其下展示总 Agent 的综合结果,并随会话保存。
|
||||||
- [x] **角色绑定模型连接**:每个角色可继承默认模型或选择独立文本模型连接,失效连接安全回退默认模型,综合角色始终继承默认模型。
|
- [x] **角色绑定模型连接**:每个角色可继承默认模型或选择独立文本模型连接,失效连接安全回退默认模型,综合角色始终继承默认模型。
|
||||||
- [x] **多协议模型配置**:支持 Anthropic Messages、OpenAI Chat Completions、OpenAI Images 和无认证本机模型。
|
- [x] **多协议模型配置**:支持 Anthropic Messages、OpenAI Chat Completions、OpenAI Images 和无认证本机模型。
|
||||||
- [x] **上下文用量与自动压缩**:直连模型按每次成功调用更新供应商用量,图片与工具轮次使用同一口径,供应商缺失 usage 时才回退估算;界面明确区分“本次模型调用”和“压缩后对话估算”,压缩线始终根据当前设置与所选模型窗口即时计算,不在每个对话中保存旧配置;压缩标识的前后值使用同一估算口径,运行记录仍保留各次模型调用的供应商 usage。对话与多轮工具 Agent 可在已完成调用越过阈值后自动重复压缩,规划时先为固定提示、工具定义和摘要预留预算;同一回复会分别保留 Agent 工具上下文与对话历史的压缩标识,并在应用重启或较早消息滚出本地历史窗口后继续复用摘要。
|
- [x] **上下文用量与自动压缩**:直连模型按每次成功调用更新供应商用量,图片与工具轮次使用同一口径,供应商缺失 usage 时才回退估算;界面明确区分“本次模型调用”和“压缩后对话估算”,压缩线始终根据当前设置与所选模型窗口即时计算,不在每个对话中保存旧配置;压缩标识的前后值使用同一估算口径,运行记录仍保留各次模型调用的供应商 usage。对话与多轮工具 Agent 可在已完成调用越过阈值后自动重复压缩,规划时先为固定提示、工具定义和摘要预留预算;同一回复会分别保留 Agent 工具上下文与对话历史的压缩标识,并在应用重启或较早消息滚出本地历史窗口后继续复用摘要。
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { describe, expect, it, vi } from 'vitest'
|
import { describe, expect, it, vi } from 'vitest'
|
||||||
import type { AssistantExpert } from '../../shared/assistant-contracts'
|
import type { AssistantExpert } from '../../shared/assistant-contracts'
|
||||||
|
import type { SubagentEvent } from '../../shared/contracts'
|
||||||
import type {
|
import type {
|
||||||
AgentExecutionRequest,
|
AgentExecutionRequest,
|
||||||
AgentRuntime
|
AgentRuntime
|
||||||
@@ -36,10 +37,15 @@ function database() {
|
|||||||
describe('SubagentService', () => {
|
describe('SubagentService', () => {
|
||||||
it('creates a linked child task and puts expert instructions in system context', async () => {
|
it('creates a linked child task and puts expert instructions in system context', async () => {
|
||||||
let executionRequest: AgentExecutionRequest | undefined
|
let executionRequest: AgentExecutionRequest | undefined
|
||||||
|
const expertOutput = '结果'.repeat(35_001)
|
||||||
const runtime = {
|
const runtime = {
|
||||||
run: async function* (request: AgentExecutionRequest) {
|
run: async function* (request: AgentExecutionRequest) {
|
||||||
executionRequest = request
|
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
|
yield { requestId: request.requestId, type: 'done' } as const
|
||||||
},
|
},
|
||||||
releaseConversation: vi.fn(async () => undefined),
|
releaseConversation: vi.fn(async () => undefined),
|
||||||
@@ -51,16 +57,17 @@ describe('SubagentService', () => {
|
|||||||
db as never,
|
db as never,
|
||||||
new SubagentScheduler({ timeoutMs: 1_000 })
|
new SubagentScheduler({ timeoutMs: 1_000 })
|
||||||
)
|
)
|
||||||
const events: string[] = []
|
const events: SubagentEvent[] = []
|
||||||
const result = await service.run({
|
const result = await service.run({
|
||||||
parentRequest,
|
parentRequest,
|
||||||
expert,
|
expert,
|
||||||
routingMode: 'smart',
|
routingMode: 'smart',
|
||||||
signal: new AbortController().signal,
|
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(db.createTask).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
parentTaskId: parentRequest.requestId,
|
parentTaskId: parentRequest.requestId,
|
||||||
@@ -73,13 +80,27 @@ describe('SubagentService', () => {
|
|||||||
expect(executionRequest?.trustedInstructions).toContain(
|
expect(executionRequest?.trustedInstructions).toContain(
|
||||||
expert.systemInstructions
|
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()
|
await service.dispose()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('fails tool-producing experts and records bounded failure state', async () => {
|
it('fails tool-producing experts and records bounded failure state', async () => {
|
||||||
|
const events: SubagentEvent[] = []
|
||||||
const runtime = {
|
const runtime = {
|
||||||
run: async function* (request: AgentExecutionRequest) {
|
run: async function* (request: AgentExecutionRequest) {
|
||||||
|
yield {
|
||||||
|
requestId: request.requestId,
|
||||||
|
type: 'text',
|
||||||
|
delta: '部分结果'
|
||||||
|
} as const
|
||||||
yield {
|
yield {
|
||||||
requestId: request.requestId,
|
requestId: request.requestId,
|
||||||
type: 'tool',
|
type: 'tool',
|
||||||
@@ -98,13 +119,18 @@ describe('SubagentService', () => {
|
|||||||
expert,
|
expert,
|
||||||
routingMode: 'manual',
|
routingMode: 'manual',
|
||||||
signal: new AbortController().signal,
|
signal: new AbortController().signal,
|
||||||
onEvent: vi.fn()
|
onEvent: (event) => events.push(event)
|
||||||
})).rejects.toThrow('不允许工具调用')
|
})).rejects.toThrow('不允许工具调用')
|
||||||
expect(db.updateTaskStatus).toHaveBeenLastCalledWith(
|
expect(db.updateTaskStatus).toHaveBeenLastCalledWith(
|
||||||
expect.any(String),
|
expect.any(String),
|
||||||
'failed',
|
'failed',
|
||||||
expect.stringContaining('不允许工具调用')
|
expect.stringContaining('不允许工具调用')
|
||||||
)
|
)
|
||||||
|
expect(events.at(-1)).toMatchObject({
|
||||||
|
state: 'failed',
|
||||||
|
output: '部分结果',
|
||||||
|
error: expect.stringContaining('不允许工具调用')
|
||||||
|
})
|
||||||
await service.dispose()
|
await service.dispose()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -217,7 +217,7 @@ export class SubagentService {
|
|||||||
throw new Error(event.message)
|
throw new Error(event.message)
|
||||||
}
|
}
|
||||||
if (event.type === 'text') {
|
if (event.type === 'text') {
|
||||||
output = `${output}${event.delta}`.slice(0, 60_000)
|
output = `${output}${event.delta}`
|
||||||
} else if (event.type === 'done') {
|
} else if (event.type === 'done') {
|
||||||
completed = true
|
completed = true
|
||||||
}
|
}
|
||||||
@@ -226,7 +226,11 @@ export class SubagentService {
|
|||||||
throw new Error('专家子任务未报告完成')
|
throw new Error('专家子任务未报告完成')
|
||||||
}
|
}
|
||||||
this.database.updateTaskStatus(childTaskId, 'completed')
|
this.database.updateTaskStatus(childTaskId, 'completed')
|
||||||
this.emit(input, { childTaskId, state: 'completed' })
|
this.emit(input, {
|
||||||
|
childTaskId,
|
||||||
|
state: 'completed',
|
||||||
|
output
|
||||||
|
})
|
||||||
return { childTaskId, output }
|
return { childTaskId, output }
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const cancelled = scheduledSignal.aborted || input.signal.aborted
|
const cancelled = scheduledSignal.aborted || input.signal.aborted
|
||||||
@@ -240,6 +244,7 @@ export class SubagentService {
|
|||||||
this.emit(input, {
|
this.emit(input, {
|
||||||
childTaskId,
|
childTaskId,
|
||||||
state: cancelled ? 'cancelled' : 'failed',
|
state: cancelled ? 'cancelled' : 'failed',
|
||||||
|
output: output || undefined,
|
||||||
error: message
|
error: message
|
||||||
})
|
})
|
||||||
throw new SubagentRunError(message, output, { cause: error })
|
throw new SubagentRunError(message, output, { cause: error })
|
||||||
@@ -272,6 +277,7 @@ export class SubagentService {
|
|||||||
childTaskId: string
|
childTaskId: string
|
||||||
state: SubagentEvent['state']
|
state: SubagentEvent['state']
|
||||||
reason?: string
|
reason?: string
|
||||||
|
output?: string
|
||||||
error?: string
|
error?: string
|
||||||
}
|
}
|
||||||
): void {
|
): void {
|
||||||
@@ -286,6 +292,9 @@ export class SubagentService {
|
|||||||
...(event.reason
|
...(event.reason
|
||||||
? { reason: event.reason.slice(0, 240) }
|
? { reason: event.reason.slice(0, 240) }
|
||||||
: {}),
|
: {}),
|
||||||
|
...(event.output !== undefined
|
||||||
|
? { output: event.output }
|
||||||
|
: {}),
|
||||||
...(event.error
|
...(event.error
|
||||||
? { error: event.error.slice(0, 1_000) }
|
? { error: event.error.slice(0, 1_000) }
|
||||||
: {})
|
: {})
|
||||||
|
|||||||
@@ -6461,24 +6461,53 @@ describe('App', () => {
|
|||||||
requestId: request.requestId,
|
requestId: request.requestId,
|
||||||
type: 'subagent',
|
type: 'subagent',
|
||||||
...events[0]!,
|
...events[0]!,
|
||||||
state: 'completed'
|
state: 'completed',
|
||||||
|
output: '研究专家的独立结论'
|
||||||
})
|
})
|
||||||
agentListener?.({
|
agentListener?.({
|
||||||
requestId: request.requestId,
|
requestId: request.requestId,
|
||||||
type: 'subagent',
|
type: 'subagent',
|
||||||
...events[1]!,
|
...events[1]!,
|
||||||
state: 'cancelled',
|
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()
|
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('运行记录'))
|
fireEvent.click(screen.getByText('运行记录'))
|
||||||
expect(await screen.findAllByText('子专家')).toHaveLength(4)
|
expect(await screen.findAllByText('子专家')).toHaveLength(4)
|
||||||
expect(screen.getAllByText(/智能路由/u).length).toBeGreaterThan(0)
|
expect(screen.getAllByText(/智能路由/u).length).toBeGreaterThan(0)
|
||||||
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 () => {
|
it('offers once, session, permanent, and deny for a tool call', async () => {
|
||||||
|
|||||||
@@ -117,6 +117,7 @@ import {
|
|||||||
conversationContextCompressionMarkerSchema,
|
conversationContextCompressionMarkerSchema,
|
||||||
conversationContextMetricsSchema,
|
conversationContextMetricsSchema,
|
||||||
conversationMessageBlocksSchema,
|
conversationMessageBlocksSchema,
|
||||||
|
conversationSubagentActivitySchema,
|
||||||
interactiveWorkModes,
|
interactiveWorkModes,
|
||||||
normalizeInteractiveWorkMode,
|
normalizeInteractiveWorkMode,
|
||||||
projectChannelLabels
|
projectChannelLabels
|
||||||
@@ -1090,6 +1091,14 @@ function isConversation(value: unknown): value is Conversation {
|
|||||||
compression
|
compression
|
||||||
).success
|
).success
|
||||||
))) &&
|
))) &&
|
||||||
|
(entry.subagents === undefined ||
|
||||||
|
(Array.isArray(entry.subagents) &&
|
||||||
|
entry.subagents.length <= 3 &&
|
||||||
|
entry.subagents.every(
|
||||||
|
(subagent) =>
|
||||||
|
conversationSubagentActivitySchema.safeParse(subagent)
|
||||||
|
.success
|
||||||
|
))) &&
|
||||||
(entry.artifactIds === undefined ||
|
(entry.artifactIds === undefined ||
|
||||||
(Array.isArray(entry.artifactIds) &&
|
(Array.isArray(entry.artifactIds) &&
|
||||||
entry.artifactIds.length <= 8 &&
|
entry.artifactIds.length <= 8 &&
|
||||||
@@ -1139,6 +1148,7 @@ function toConversationMessage(message: Message): ConversationMessage {
|
|||||||
contextCompression: message.contextCompression,
|
contextCompression: message.contextCompression,
|
||||||
contextCompressions: message.contextCompressions,
|
contextCompressions: message.contextCompressions,
|
||||||
tools: message.tools,
|
tools: message.tools,
|
||||||
|
subagents: message.subagents,
|
||||||
sources: message.sources,
|
sources: message.sources,
|
||||||
sourceReferences: message.sourceReferences,
|
sourceReferences: message.sourceReferences,
|
||||||
knowledgeRetrieval: message.knowledgeRetrieval,
|
knowledgeRetrieval: message.knowledgeRetrieval,
|
||||||
@@ -3629,6 +3639,7 @@ function App(): React.JSX.Element {
|
|||||||
routingMode: event.routingMode,
|
routingMode: event.routingMode,
|
||||||
state: event.state,
|
state: event.state,
|
||||||
reason: event.reason,
|
reason: event.reason,
|
||||||
|
output: event.output,
|
||||||
error: event.error
|
error: event.error
|
||||||
}
|
}
|
||||||
if (index >= 0) {
|
if (index >= 0) {
|
||||||
|
|||||||
@@ -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 { afterEach, describe, expect, it, vi } from 'vitest'
|
||||||
import {
|
import {
|
||||||
ChatTimeline,
|
ChatTimeline,
|
||||||
@@ -153,4 +159,78 @@ describe('ChatTimeline', () => {
|
|||||||
)
|
)
|
||||||
).toBeInTheDocument()
|
).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(
|
||||||
|
<ChatTimeline
|
||||||
|
artifactById={new Map()}
|
||||||
|
conversationId="conversation-1"
|
||||||
|
hiddenMessageCount={0}
|
||||||
|
isUnusedConversation={false}
|
||||||
|
locale="zh-CN"
|
||||||
|
messageStartIndex={0}
|
||||||
|
messages={messages}
|
||||||
|
{...callbacks}
|
||||||
|
retryContent=""
|
||||||
|
totalMessageCount={messages.length}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
|
||||||
|
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')
|
||||||
|
}
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import type {
|
|||||||
ConversationContextCompressionMarker,
|
ConversationContextCompressionMarker,
|
||||||
ConversationMessage,
|
ConversationMessage,
|
||||||
ConversationMessageBlock,
|
ConversationMessageBlock,
|
||||||
|
ConversationSubagentActivity,
|
||||||
ConversationToolActivity
|
ConversationToolActivity
|
||||||
} from '../../shared/assistant-contracts'
|
} from '../../shared/assistant-contracts'
|
||||||
import { AgentQuestionCard } from './AgentQuestionCard'
|
import { AgentQuestionCard } from './AgentQuestionCard'
|
||||||
@@ -30,15 +31,7 @@ import { formatCompactTokens } from './token-format'
|
|||||||
|
|
||||||
export type ToolActivity = ConversationToolActivity
|
export type ToolActivity = ConversationToolActivity
|
||||||
|
|
||||||
export type SubagentActivity = {
|
export type SubagentActivity = ConversationSubagentActivity
|
||||||
childTaskId: string
|
|
||||||
expertId: string
|
|
||||||
expertName: string
|
|
||||||
routingMode: 'manual' | 'smart'
|
|
||||||
state: 'queued' | 'running' | 'completed' | 'failed' | 'cancelled'
|
|
||||||
reason?: string
|
|
||||||
error?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export type KnowledgeRetrievalStatus = Omit<
|
export type KnowledgeRetrievalStatus = Omit<
|
||||||
Extract<AgentEvent, { type: 'knowledge-retrieval' }>,
|
Extract<AgentEvent, { type: 'knowledge-retrieval' }>,
|
||||||
@@ -223,6 +216,61 @@ function ToolExecutionList({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function SubagentStatusList({
|
||||||
|
subagents
|
||||||
|
}: {
|
||||||
|
subagents: SubagentActivity[]
|
||||||
|
}): React.JSX.Element {
|
||||||
|
const { t } = useTranslation('app')
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section
|
||||||
|
aria-label={t('chat.subagents.region')}
|
||||||
|
className="subagent-status-list"
|
||||||
|
>
|
||||||
|
{subagents.slice(0, 3).map((subagent) => (
|
||||||
|
<details
|
||||||
|
className={`subagent-status-card subagent-status-card--${subagent.state}`}
|
||||||
|
key={subagent.childTaskId}
|
||||||
|
>
|
||||||
|
<summary>
|
||||||
|
<Bot aria-hidden="true" size={15} />
|
||||||
|
<span className="subagent-status-card__identity">
|
||||||
|
<strong>{subagent.expertName}</strong>
|
||||||
|
<small>
|
||||||
|
{subagent.routingMode === 'smart'
|
||||||
|
? t('chat.subagents.smart')
|
||||||
|
: t('chat.subagents.manual')}
|
||||||
|
</small>
|
||||||
|
</span>
|
||||||
|
<span>{t(`chat.subagents.states.${subagent.state}`)}</span>
|
||||||
|
</summary>
|
||||||
|
<div className="subagent-status-card__details">
|
||||||
|
{subagent.output ? (
|
||||||
|
<section>
|
||||||
|
<strong>{t('chat.subagents.output')}</strong>
|
||||||
|
<div className="markdown-content">
|
||||||
|
<MarkdownRenderer>{subagent.output}</MarkdownRenderer>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
) : (
|
||||||
|
<p>{t('chat.subagents.noOutput')}</p>
|
||||||
|
)}
|
||||||
|
{(subagent.error || subagent.reason) &&
|
||||||
|
(subagent.state === 'failed' ||
|
||||||
|
subagent.state === 'cancelled') && (
|
||||||
|
<section className="subagent-status-card__error">
|
||||||
|
<strong>{t('chat.subagents.error')}</strong>
|
||||||
|
<p>{subagent.error ?? subagent.reason}</p>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
))}
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
type ChatMessageRowProps = {
|
type ChatMessageRowProps = {
|
||||||
artifactById: ReadonlyMap<string, AssistantArtifact>
|
artifactById: ReadonlyMap<string, AssistantArtifact>
|
||||||
canRetry: boolean
|
canRetry: boolean
|
||||||
@@ -406,6 +454,9 @@ function ChatMessageRowView({
|
|||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{message.subagents && message.subagents.length > 0 && (
|
||||||
|
<SubagentStatusList subagents={message.subagents} />
|
||||||
|
)}
|
||||||
{message.blocks && message.blocks.length > 0 ? (
|
{message.blocks && message.blocks.length > 0 ? (
|
||||||
<div className="message-blocks">
|
<div className="message-blocks">
|
||||||
{groupMessageBlocks(message.blocks).map((item) =>
|
{groupMessageBlocks(message.blocks).map((item) =>
|
||||||
@@ -638,37 +689,6 @@ function ChatMessageRowView({
|
|||||||
message.tools.length > 0 && (
|
message.tools.length > 0 && (
|
||||||
<ToolExecutionList tools={message.tools} />
|
<ToolExecutionList tools={message.tools} />
|
||||||
)}
|
)}
|
||||||
{message.subagents && message.subagents.length > 0 && (
|
|
||||||
<section
|
|
||||||
aria-label={t('chat.subagents.region')}
|
|
||||||
className="subagent-status-list"
|
|
||||||
>
|
|
||||||
{message.subagents.slice(0, 3).map((subagent) => (
|
|
||||||
<article
|
|
||||||
className={`subagent-status-card subagent-status-card--${subagent.state}`}
|
|
||||||
key={subagent.childTaskId}
|
|
||||||
>
|
|
||||||
<Bot aria-hidden="true" size={15} />
|
|
||||||
<div>
|
|
||||||
<strong>{subagent.expertName}</strong>
|
|
||||||
<small>
|
|
||||||
{subagent.routingMode === 'smart'
|
|
||||||
? t('chat.subagents.smart')
|
|
||||||
: t('chat.subagents.manual')}
|
|
||||||
</small>
|
|
||||||
{(subagent.error || subagent.reason) &&
|
|
||||||
(subagent.state === 'failed' ||
|
|
||||||
subagent.state === 'cancelled') && (
|
|
||||||
<p>{subagent.error ?? subagent.reason}</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<span>
|
|
||||||
{t(`chat.subagents.states.${subagent.state}`)}
|
|
||||||
</span>
|
|
||||||
</article>
|
|
||||||
))}
|
|
||||||
</section>
|
|
||||||
)}
|
|
||||||
{message.approval && (
|
{message.approval && (
|
||||||
<div className="approval-card">
|
<div className="approval-card">
|
||||||
<ShieldCheck size={18} />
|
<ShieldCheck size={18} />
|
||||||
|
|||||||
@@ -276,6 +276,9 @@ export const app = {
|
|||||||
smart: 'Smart routing',
|
smart: 'Smart routing',
|
||||||
manual: 'Selected manually',
|
manual: 'Selected manually',
|
||||||
fallbackTask: '{{name}} subagent task',
|
fallbackTask: '{{name}} subagent task',
|
||||||
|
output: 'Expert output',
|
||||||
|
error: 'Execution details',
|
||||||
|
noOutput: 'This expert has no output to display yet.',
|
||||||
states: {
|
states: {
|
||||||
queued: 'Queued',
|
queued: 'Queued',
|
||||||
running: 'Running',
|
running: 'Running',
|
||||||
|
|||||||
@@ -270,6 +270,9 @@ export const app = {
|
|||||||
smart: '智能路由',
|
smart: '智能路由',
|
||||||
manual: '手动指定',
|
manual: '手动指定',
|
||||||
fallbackTask: '{{name}} 子专家任务',
|
fallbackTask: '{{name}} 子专家任务',
|
||||||
|
output: '专家输出',
|
||||||
|
error: '执行说明',
|
||||||
|
noOutput: '该专家暂时没有可显示的输出。',
|
||||||
states: {
|
states: {
|
||||||
queued: '等待中',
|
queued: '等待中',
|
||||||
running: '进行中',
|
running: '进行中',
|
||||||
|
|||||||
+50
-10
@@ -3891,15 +3891,34 @@ button > svg {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.subagent-status-card {
|
.subagent-status-card {
|
||||||
display: grid;
|
|
||||||
align-items: center;
|
|
||||||
padding: var(--space-2) var(--space-3);
|
|
||||||
border: 1px solid var(--border-default);
|
border: 1px solid var(--border-default);
|
||||||
border-radius: var(--radius-control);
|
border-radius: var(--radius-control);
|
||||||
background: var(--surface-subtle);
|
background: var(--surface-subtle);
|
||||||
color: var(--text-secondary);
|
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);
|
gap: var(--space-2);
|
||||||
grid-template-columns: auto minmax(0, 1fr) auto;
|
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 {
|
.subagent-status-card--running {
|
||||||
@@ -3912,30 +3931,51 @@ button > svg {
|
|||||||
background: var(--danger-subtle);
|
background: var(--danger-subtle);
|
||||||
}
|
}
|
||||||
|
|
||||||
.subagent-status-card > div {
|
.subagent-status-card__identity {
|
||||||
display: grid;
|
display: grid;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
gap: var(--space-1);
|
gap: var(--space-1);
|
||||||
}
|
}
|
||||||
|
|
||||||
.subagent-status-card strong,
|
.subagent-status-card summary strong,
|
||||||
.subagent-status-card span {
|
.subagent-status-card summary > span:last-child {
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
font-size: var(--font-body);
|
font-size: var(--font-body);
|
||||||
}
|
}
|
||||||
|
|
||||||
.subagent-status-card small {
|
.subagent-status-card summary small {
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
font-size: var(--font-caption);
|
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;
|
margin: 0;
|
||||||
color: var(--danger);
|
|
||||||
font-size: var(--font-caption);
|
|
||||||
overflow-wrap: anywhere;
|
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 {
|
.approval-card {
|
||||||
display: grid;
|
display: grid;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
@@ -137,6 +137,29 @@ export type ConversationMessageBlock = z.infer<
|
|||||||
typeof conversationMessageBlockSchema
|
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
|
export const conversationContextCompressionMarkerSchema = z
|
||||||
.object({
|
.object({
|
||||||
state: z.enum(['compressing', 'completed', 'failed']),
|
state: z.enum(['compressing', 'completed', 'failed']),
|
||||||
@@ -168,6 +191,10 @@ export const conversationMessageSchema = z
|
|||||||
.max(2)
|
.max(2)
|
||||||
.optional(),
|
.optional(),
|
||||||
tools: z.array(conversationToolActivitySchema).max(100).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(),
|
sources: z.array(z.string().max(8_192)).max(100).optional(),
|
||||||
sourceReferences: z
|
sourceReferences: z
|
||||||
.array(
|
.array(
|
||||||
|
|||||||
@@ -957,6 +957,7 @@ export const subagentEventSchema = z
|
|||||||
'cancelled'
|
'cancelled'
|
||||||
]),
|
]),
|
||||||
reason: z.string().trim().min(1).max(240).optional(),
|
reason: z.string().trim().min(1).max(240).optional(),
|
||||||
|
output: z.string().optional(),
|
||||||
error: z.string().trim().min(1).max(1_000).optional()
|
error: z.string().trim().min(1).max(1_000).optional()
|
||||||
})
|
})
|
||||||
.strict()
|
.strict()
|
||||||
|
|||||||
Reference in New Issue
Block a user