Browse Source

fix: 将子任务通知渲染为时间线事件

samien 2 months ago
parent
commit
d39cb11769

+ 18 - 0
src/client/components/messages/MessageTimeline.vue

@@ -62,6 +62,15 @@ function formatTime(value?: string | number | Date) {
   return new Intl.DateTimeFormat('zh-CN', { hour: '2-digit', minute: '2-digit' }).format(date)
 }
 
+function delegationSummary(metadata?: Record<string, unknown>): string {
+  const total = Number(metadata?.task_count ?? 0)
+  const completed = Number(metadata?.completed_count ?? 0)
+  const failed = Number(metadata?.failed_count ?? 0)
+  const seconds = Number(metadata?.duration_seconds ?? 0)
+  const duration = seconds > 0 ? ` · ${seconds < 60 ? `${Math.round(seconds)} 秒` : `${Math.floor(seconds / 60)} 分 ${Math.round(seconds % 60)} 秒`}` : ''
+  return `${total || completed + failed || 1} 个子任务 · ${completed} 已完成${failed ? ` · ${failed} 失败` : ''}${duration}`
+}
+
 function onScroll() {
   const el = scroller.value
   if (!el) return
@@ -149,6 +158,13 @@ defineExpose({ scrollToMessage, scrollToBottom })
         >
           <div v-if="message.role !== 'user'" class="message__avatar">{{ (message.author || message.profile || (message.role === 'assistant' ? '夭' : '系')).slice(0, 1).toUpperCase() }}</div>
           <div class="message__body">
+            <template v-if="message.timelineKind === 'delegation-complete'">
+              <details class="delegation-event">
+                <summary><AppIcon name="groups" :size="14" /><span><strong>子任务已完成</strong><small>{{ delegationSummary(message.timelineMetadata) }}</small></span></summary>
+                <MarkdownContent v-if="message.content" :content="message.content" />
+              </details>
+            </template>
+            <template v-else>
             <div class="message__meta">
               <strong>{{ message.role === 'user' ? '你' : message.author || message.profile || (message.role === 'assistant' ? 'Agent' : '系统') }}</strong>
               <time>{{ formatTime(message.createdAt) }}</time>
@@ -197,6 +213,7 @@ defineExpose({ scrollToMessage, scrollToBottom })
               <button type="button" title="引用" aria-label="引用消息" @click="emit('quote', message)"><AppIcon name="quote" :size="13" /></button>
               <button v-if="message.role === 'assistant'" type="button" title="从这里分支" aria-label="从这里分支" @click="emit('branch', message)"><AppIcon name="branch" :size="13" /></button>
             </div>
+            </template>
           </div>
         </article>
 
@@ -238,6 +255,7 @@ defineExpose({ scrollToMessage, scrollToBottom })
 .dark .message--user .message__body { background: #2d323a; }
 .message--user .message__meta { display: none; }
 .message--system { justify-content: center; }.message--system .message__avatar, .message--system .message__meta { display: none; }.message--system .message__body { max-width: 82%; padding: 6px 10px; border: 1px solid var(--line); border-radius: 9px; color: var(--text-muted); text-align: center; }
+.delegation-event { min-width: 230px; max-width: 460px; text-align: left; }.delegation-event summary { display: flex; align-items: center; gap: 8px; cursor: pointer; list-style: none; }.delegation-event summary::-webkit-details-marker { display: none; }.delegation-event summary > span { display: flex; min-width: 0; flex-direction: column; gap: 2px; }.delegation-event strong { color: var(--text-secondary); font-size: 11px; font-weight: 650; }.delegation-event small { color: var(--text-muted); font-size: 9px; }.delegation-event :deep(.markdown) { max-height: 260px; margin-top: 9px; overflow: auto; color: var(--text-secondary); font-size: 10px; }
 .message--tool-only { margin-block: 9px; }.message--tool-only .message__avatar, .message--tool-only .message__meta, .message--tool-only .message__actions { display: none; }.message--tool-only .message__body { max-width: 100%; }
 .message__reasoning { max-width: 420px; margin: 3px 0 10px; padding: 0 0 8px; border: 0; border-bottom: 1px dashed var(--line-strong); color: var(--text-secondary); font-size: 11px; }.message__reasoning summary { display: flex; align-items: center; gap: 5px; list-style: none; color: var(--text-muted); cursor: pointer; font-size: 9px; }.message__reasoning summary::-webkit-details-marker { display: none; }.message__reasoning summary::before { content: '›'; color: var(--text-muted); font-size: 14px; line-height: 1; transition: transform 120ms ease; }.message__reasoning[open] summary::before { transform: rotate(90deg); }.message__reasoning :deep(.markdown) { margin-top: 8px; }
 .fork-divider { display: flex; align-items: center; gap: 12px; margin: 8px 0 28px; color: var(--text-muted); font-size: 10px; }.fork-divider::before, .fork-divider::after { height: 1px; flex: 1; background: var(--line); content: ''; }.fork-divider > span { display: inline-flex; align-items: center; gap: 6px; padding: 5px 10px; border: 1px solid var(--line); border-radius: 999px; background: var(--surface-raised); white-space: nowrap; }.fork-divider strong { max-width: 180px; overflow: hidden; color: var(--text-secondary); text-overflow: ellipsis; }

+ 2 - 0
src/client/components/messages/types.ts

@@ -27,6 +27,8 @@ export type UiMessage = {
   attachments?: UiMessageAttachment[]
   tools?: UiToolCall[]
   profile?: string
+  timelineKind?: 'delegation-complete' | 'system'
+  timelineMetadata?: Record<string, unknown>
 }
 
 export type UiInteraction = {

+ 9 - 0
src/client/components/workspace/viewModels.ts

@@ -79,6 +79,10 @@ function toolResult(message: ChatMessage): unknown {
   try { return JSON.parse(message.content) } catch { return message.content || undefined }
 }
 
+function timelineMetadata(value: unknown): Record<string, unknown> | undefined {
+  return value && typeof value === 'object' && !Array.isArray(value) ? value as Record<string, unknown> : undefined
+}
+
 export function chatMessageToUi(message: ChatMessage, agentNameFor?: (profile?: string) => string | undefined): UiMessage {
   const attachments: UiMessageAttachment[] | undefined = message.attachments?.map(attachment => ({
     id: attachment.id,
@@ -132,6 +136,11 @@ export function chatMessagesToUi(messages: ChatMessage[], agentNameFor?: (profil
     // content. Keep the canvas focused on the user and participating agents.
     if (message.role === 'system') continue
     const ui = chatMessageToUi(message, agentNameFor)
+    if (message.displayKind) {
+      ui.role = 'system'
+      ui.timelineKind = message.displayKind === 'async_delegation_complete' ? 'delegation-complete' : 'system'
+      ui.timelineMetadata = timelineMetadata(message.displayMetadata)
+    }
     result.push(ui)
     if (message.role === 'assistant') {
       lastAssistant = ui

+ 7 - 0
src/client/utils/normalize.ts

@@ -143,6 +143,11 @@ function normalizeToolCall(value: unknown, index: number): ToolCall {
   }
 }
 
+function parseJsonValue(value: unknown): JsonValue | undefined {
+  if (typeof value !== 'string') return value as JsonValue | undefined
+  try { return JSON.parse(value) as JsonValue } catch { return undefined }
+}
+
 export function normalizeAttachment(value: unknown, index = 0): ChatAttachment {
   const source = record(value)
   const mimeType = string(pick(source, 'mime_type', 'mimeType', 'media_type', 'mediaType'), 'application/octet-stream')
@@ -198,6 +203,8 @@ export function normalizeChatMessage(value: unknown, sessionId: string, fallback
       : pick(source, 'tool_calls', 'toolCalls', 'tools')).map(normalizeToolCall),
     toolCallId: string(pick(source, 'tool_call_id', 'toolCallId')) || undefined,
     toolName: string(pick(source, 'tool_name', 'toolName')) || undefined,
+    displayKind: string(pick(source, 'display_kind', 'displayKind')) || undefined,
+    displayMetadata: parseJsonValue(pick(source, 'display_metadata', 'displayMetadata')),
     raw: value as JsonValue,
   }
 }

+ 2 - 0
src/shared/types.ts

@@ -137,6 +137,8 @@ export interface ChatMessage {
   toolCalls?: ToolCall[]
   toolCallId?: string
   toolName?: string
+  displayKind?: string
+  displayMetadata?: JsonValue
   raw?: JsonValue
 }
 

+ 14 - 0
tests/client/toolTimeline.test.ts

@@ -24,4 +24,18 @@ describe('ordinary chat tool timeline', () => {
       id: 'call-1', name: 'terminal', status: 'success', input: '{"command":"pwd"}', output: { output: '/tmp/work', exit_code: 0, error: null },
     })])
   })
+
+  it('renders asynchronous delegation completion as a timeline event, not a user message', () => {
+    const notice = normalizeChatMessage({
+      id: 'delegation', role: 'user', content: '[ASYNC DELEGATION BATCH COMPLETE]',
+      display_kind: 'async_delegation_complete',
+      display_metadata: JSON.stringify({ task_count: 2, completed_count: 2, failed_count: 0, duration_seconds: 71 }),
+      timestamp: 1,
+    }, 'session-1', 'default')
+    const [timeline] = chatMessagesToUi([notice])
+    expect(timeline).toMatchObject({
+      role: 'system', timelineKind: 'delegation-complete',
+      timelineMetadata: { task_count: 2, completed_count: 2 },
+    })
+  })
 })

+ 7 - 0
tests/e2e/workspace.spec.ts

@@ -83,6 +83,13 @@ test('folds tool result rows into their expandable tool call', async ({ page })
   await expect(page.locator('.message--tool')).toHaveCount(0)
 })
 
+test('renders subtask completion as a collapsed timeline event', async ({ page }) => {
+  await page.goto('/chat/session-demo')
+  await expect(page.getByText('子任务已完成', { exact: true })).toBeVisible()
+  await expect(page.locator('.delegation-event')).toContainText('2 个子任务 · 2 已完成')
+  await expect(page.locator('.delegation-event')).not.toHaveAttribute('open', '')
+})
+
 test('keeps the canonical logo and yaoyao-webui composer geometry', async ({ page }) => {
   const logo = page.locator('.rail__brand img')
   await expect(logo).toHaveAttribute('src', '/brand/AppIcon-1024.png')

+ 1 - 0
tests/fixtures/fake-hermes.mjs

@@ -46,6 +46,7 @@ const messages = [
   { id: 'message-assistant', role: 'assistant', content: '已整理完成。下面是 **验收摘要**:\n\n- 普通聊天已连接\n- 群聊 v2 已就绪\n- [打开报告](https://example.com/report)', reasoning_content: '先核对会话与文件索引。', timestamp: now() - 170 },
   { id: 'message-image', role: 'assistant', content: '预览图:![夭夭 Logo](/brand/AppIcon-1024.png)', timestamp: now() - 160 },
   { id: 'message-legacy-media', role: 'assistant', content: '历史兼容:MEDIA:/brand/AppIcon-1024.png', timestamp: now() - 150 },
+  { id: 'message-delegation', role: 'user', content: '[ASYNC DELEGATION BATCH COMPLETE]\n后台子任务已经完成。', display_kind: 'async_delegation_complete', display_metadata: { task_count: 2, completed_count: 2, failed_count: 0, duration_seconds: 71 }, timestamp: now() - 140 },
 ]
 const groupAgent = { id: agentId, roomId, profile: 'yaoyao', displayName: '夭夭', description: '主 Agent', enabled: true, replyWithoutMention: true, status: 'idle', createdAt: now() - 2000, updatedAt: now() }
 const groupMessage = { seq: 1, id: '44444444-4444-4444-8444-444444444444', roomId, senderKind: 'human', senderId: 'demo-user', senderName: '验收用户', content: '大家好,检查一下群聊输入框。', status: 'completed', createdAt: now() - 120, updatedAt: now() - 120 }