Просмотр исходного кода

fix: 折叠工具回执并隐藏协议消息

samien 2 месяцев назад
Родитель
Сommit
34ecef18cc

+ 9 - 1
src/client/components/messages/MessageTimeline.vue

@@ -23,6 +23,7 @@ const props = withDefaults(defineProps<{
   synced?: boolean
   synced?: boolean
   showTools?: boolean
   showTools?: boolean
   interaction?: UiInteraction | null
   interaction?: UiInteraction | null
+  mentionNames?: string[]
 }>(), {
 }>(), {
   title: '',
   title: '',
   subtitle: '',
   subtitle: '',
@@ -38,6 +39,7 @@ const props = withDefaults(defineProps<{
   synced: false,
   synced: false,
   showTools: true,
   showTools: true,
   interaction: null,
   interaction: null,
+  mentionNames: () => [],
 })
 })
 
 
 const emit = defineEmits<{
 const emit = defineEmits<{
@@ -140,7 +142,10 @@ defineExpose({ scrollToMessage, scrollToBottom })
           :key="message.id"
           :key="message.id"
           :data-message-id="message.id"
           :data-message-id="message.id"
           class="message"
           class="message"
-          :class="[`message--${message.role}`, { 'message--failed': message.status === 'failed' }]"
+          :class="[`message--${message.role}`, {
+            'message--failed': message.status === 'failed',
+            'message--tool-only': message.role === 'assistant' && !message.content && !message.reasoning && Boolean(message.tools?.length),
+          }]"
         >
         >
           <div v-if="message.role !== 'user'" class="message__avatar">{{ (message.author || message.profile || (message.role === 'assistant' ? '夭' : '系')).slice(0, 1).toUpperCase() }}</div>
           <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">
           <div class="message__body">
@@ -168,6 +173,8 @@ defineExpose({ scrollToMessage, scrollToBottom })
                 :content="message.content"
                 :content="message.content"
                 :streaming="message.status === 'streaming'"
                 :streaming="message.status === 'streaming'"
                 :legacy-media="message.role === 'assistant'"
                 :legacy-media="message.role === 'assistant'"
+                :plain="message.role === 'user'"
+                :mention-names="mentionNames"
               />
               />
             </div>
             </div>
 
 
@@ -231,6 +238,7 @@ defineExpose({ scrollToMessage, scrollToBottom })
 .dark .message--user .message__body { background: #2d323a; }
 .dark .message--user .message__body { background: #2d323a; }
 .message--user .message__meta { display: none; }
 .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; }
 .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; }
+.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; }
 .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; }
 .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; }
 .message__attachments { display: flex; margin-top: 8px; flex-wrap: wrap; gap: 7px; }.message__attachments button { display: flex; min-width: 90px; max-width: 210px; min-height: 42px; align-items: center; gap: 7px; padding: 5px 8px; overflow: hidden; border: 1px solid var(--line); border-radius: 9px; background: var(--surface); color: var(--text-secondary); cursor: pointer; }.message__attachments button:hover { border-color: var(--line-strong); }.message__attachments img { width: 48px; height: 48px; margin: -5px 0 -5px -8px; object-fit: cover; }.message__attachments button > span { display: grid; place-items: center; width: 25px; height: 25px; border-radius: 7px; background: var(--surface-soft); }.message__attachments strong { min-width: 0; overflow: hidden; font-size: 10px; font-weight: 520; text-overflow: ellipsis; white-space: nowrap; }
 .message__attachments { display: flex; margin-top: 8px; flex-wrap: wrap; gap: 7px; }.message__attachments button { display: flex; min-width: 90px; max-width: 210px; min-height: 42px; align-items: center; gap: 7px; padding: 5px 8px; overflow: hidden; border: 1px solid var(--line); border-radius: 9px; background: var(--surface); color: var(--text-secondary); cursor: pointer; }.message__attachments button:hover { border-color: var(--line-strong); }.message__attachments img { width: 48px; height: 48px; margin: -5px 0 -5px -8px; object-fit: cover; }.message__attachments button > span { display: grid; place-items: center; width: 25px; height: 25px; border-radius: 7px; background: var(--surface-soft); }.message__attachments strong { min-width: 0; overflow: hidden; font-size: 10px; font-weight: 520; text-overflow: ellipsis; white-space: nowrap; }

+ 48 - 2
src/client/components/workspace/viewModels.ts

@@ -72,7 +72,14 @@ function toolToUi(tool: ToolCall): UiToolCall {
   }
   }
 }
 }
 
 
-export function chatMessageToUi(message: ChatMessage): UiMessage {
+function toolResult(message: ChatMessage): unknown {
+  const raw = record(message.raw)
+  if (raw.result !== undefined) return raw.result
+  if (raw.output !== undefined && typeof raw.output !== 'string') return raw.output
+  try { return JSON.parse(message.content) } catch { return message.content || undefined }
+}
+
+export function chatMessageToUi(message: ChatMessage, agentNameFor?: (profile?: string) => string | undefined): UiMessage {
   const attachments: UiMessageAttachment[] | undefined = message.attachments?.map(attachment => ({
   const attachments: UiMessageAttachment[] | undefined = message.attachments?.map(attachment => ({
     id: attachment.id,
     id: attachment.id,
     name: attachment.name,
     name: attachment.name,
@@ -83,7 +90,7 @@ export function chatMessageToUi(message: ChatMessage): UiMessage {
   return {
   return {
     id: message.id,
     id: message.id,
     role: message.role,
     role: message.role,
-    author: message.role === 'assistant' ? message.profile : undefined,
+    author: message.role === 'assistant' ? agentNameFor?.(message.profile) || message.profile : undefined,
     content: message.content,
     content: message.content,
     reasoning: message.reasoning,
     reasoning: message.reasoning,
     createdAt: message.timestamp < 10_000_000_000 ? message.timestamp * 1000 : message.timestamp,
     createdAt: message.timestamp < 10_000_000_000 ? message.timestamp * 1000 : message.timestamp,
@@ -95,6 +102,45 @@ export function chatMessageToUi(message: ChatMessage): UiMessage {
   }
   }
 }
 }
 
 
+/** Render only user and agent prose; tool result rows belong under their call. */
+export function chatMessagesToUi(messages: ChatMessage[], agentNameFor?: (profile?: string) => string | undefined): UiMessage[] {
+  const result: UiMessage[] = []
+  const toolOwners = new Map<string, UiMessage>()
+  let lastAssistant: UiMessage | undefined
+
+  for (const message of messages) {
+    if (message.role === 'tool') {
+      const owner = (message.toolCallId && toolOwners.get(message.toolCallId)) || lastAssistant
+      if (!owner) continue
+      const id = message.toolCallId || message.id
+      const tools = [...(owner.tools ?? [])]
+      const index = tools.findIndex(tool => tool.id === id)
+      const patch: UiToolCall = {
+        id,
+        name: message.toolName || tools[index]?.name || '工具',
+        status: message.error ? 'error' : 'success',
+        input: tools[index]?.input,
+        output: message.error || toolResult(message),
+      }
+      if (index >= 0) tools[index] = { ...tools[index], ...patch }
+      else tools.push(patch)
+      owner.tools = tools
+      if (message.toolCallId) toolOwners.set(message.toolCallId, owner)
+      continue
+    }
+    // Gateway system rows are transport/protocol detail rather than chat
+    // content. Keep the canvas focused on the user and participating agents.
+    if (message.role === 'system') continue
+    const ui = chatMessageToUi(message, agentNameFor)
+    result.push(ui)
+    if (message.role === 'assistant') {
+      lastAssistant = ui
+      for (const tool of ui.tools ?? []) toolOwners.set(tool.id, ui)
+    }
+  }
+  return result
+}
+
 export function chatInteraction(approval?: ApprovalRequest, clarification?: ClarificationRequest): UiInteraction | null {
 export function chatInteraction(approval?: ApprovalRequest, clarification?: ClarificationRequest): UiInteraction | null {
   if (approval) return {
   if (approval) return {
     id: approval.id,
     id: approval.id,

+ 8 - 3
src/client/utils/normalize.ts

@@ -128,13 +128,14 @@ export function normalizeSession(value: unknown, fallbackProfile?: string): Sess
 
 
 function normalizeToolCall(value: unknown, index: number): ToolCall {
 function normalizeToolCall(value: unknown, index: number): ToolCall {
   const source = record(value)
   const source = record(value)
+  const functionCall = record(source.function)
   const error = string(source.error)
   const error = string(source.error)
   const result = pick(source, 'result', 'output') as JsonValue | undefined
   const result = pick(source, 'result', 'output') as JsonValue | undefined
   return {
   return {
     id: string(pick(source, 'id', 'tool_call_id', 'toolCallId', 'tool_id'), `tool-${index}`),
     id: string(pick(source, 'id', 'tool_call_id', 'toolCallId', 'tool_id'), `tool-${index}`),
-    name: string(pick(source, 'name', 'tool_name', 'toolName'), '工具'),
+    name: string(pick(source, 'name', 'tool_name', 'toolName') ?? functionCall.name, '工具'),
     status: error ? 'failed' : result !== undefined ? 'completed' : 'running',
     status: error ? 'failed' : result !== undefined ? 'completed' : 'running',
-    arguments: pick(source, 'arguments', 'args', 'context') as JsonValue | undefined,
+    arguments: (pick(source, 'arguments', 'args', 'context') ?? functionCall.arguments) as JsonValue | undefined,
     result,
     result,
     preview: string(pick(source, 'preview', 'summary', 'args_text')) || undefined,
     preview: string(pick(source, 'preview', 'summary', 'args_text')) || undefined,
     error: error || undefined,
     error: error || undefined,
@@ -192,7 +193,11 @@ export function normalizeChatMessage(value: unknown, sessionId: string, fallback
     stage: status === 'error' || source.error ? 'failed' : 'settled',
     stage: status === 'error' || source.error ? 'failed' : 'settled',
     error: string(source.error) || undefined,
     error: string(source.error) || undefined,
     attachments: attachments.length ? attachments : undefined,
     attachments: attachments.length ? attachments : undefined,
-    toolCalls: values(pick(source, 'tool_calls', 'toolCalls', 'tools')).map(normalizeToolCall),
+    toolCalls: values(typeof pick(source, 'tool_calls', 'toolCalls', 'tools') === 'string'
+      ? (() => { try { return JSON.parse(String(pick(source, 'tool_calls', 'toolCalls', 'tools'))) } catch { return [] } })()
+      : 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,
     raw: value as JsonValue,
     raw: value as JsonValue,
   }
   }
 }
 }

+ 2 - 2
src/client/views/ChatView.vue

@@ -12,7 +12,7 @@ import MessageTimeline from '@/components/messages/MessageTimeline.vue'
 import type { UiMessage } from '@/components/messages/types'
 import type { UiMessage } from '@/components/messages/types'
 import ResourceSidebar from '@/components/app/ResourceSidebar.vue'
 import ResourceSidebar from '@/components/app/ResourceSidebar.vue'
 import WorkspaceView from '@/components/workspace/WorkspaceView.vue'
 import WorkspaceView from '@/components/workspace/WorkspaceView.vue'
-import { chatInteraction, chatMessageToUi, sessionSidebarItem } from '@/components/workspace/viewModels'
+import { chatInteraction, chatMessagesToUi, sessionSidebarItem } from '@/components/workspace/viewModels'
 import { consumeLibraryItemForComposer } from '@/components/workspace/pendingComposer'
 import { consumeLibraryItemForComposer } from '@/components/workspace/pendingComposer'
 import { useAuthStore } from '@/stores/auth'
 import { useAuthStore } from '@/stores/auth'
 import { useChatStore } from '@/stores/chat'
 import { useChatStore } from '@/stores/chat'
@@ -45,7 +45,7 @@ const sidebarItems = computed(() => sessions.value.map(session => sessionSidebar
   chat.unreadCounts[session.id] ?? 0,
   chat.unreadCounts[session.id] ?? 0,
   agentNames.value.get(session.profile || auth.activeProfile?.name || ''),
   agentNames.value.get(session.profile || auth.activeProfile?.name || ''),
 )))
 )))
-const messages = computed(() => chat.messages.map(chatMessageToUi))
+const messages = computed(() => chatMessagesToUi(chat.messages, profile => profile ? agentNames.value.get(profile) : undefined))
 const activeSession = computed(() => chat.activeSession)
 const activeSession = computed(() => chat.activeSession)
 const connected = computed(() => ['connected', 'ready'].includes(chat.connectionState))
 const connected = computed(() => ['connected', 'ready'].includes(chat.connectionState))
 const interaction = computed(() => chatInteraction(chat.pendingApproval, chat.pendingClarification))
 const interaction = computed(() => chatInteraction(chat.pendingApproval, chat.pendingClarification))

+ 2 - 0
src/shared/types.ts

@@ -135,6 +135,8 @@ export interface ChatMessage {
   error?: string
   error?: string
   attachments?: ChatAttachment[]
   attachments?: ChatAttachment[]
   toolCalls?: ToolCall[]
   toolCalls?: ToolCall[]
+  toolCallId?: string
+  toolName?: string
   raw?: JsonValue
   raw?: JsonValue
 }
 }
 
 

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

@@ -0,0 +1,27 @@
+import { describe, expect, it } from 'vitest'
+import { chatMessagesToUi } from '@/components/workspace/viewModels'
+import { normalizeChatMessage } from '@/utils/normalize'
+
+describe('ordinary chat tool timeline', () => {
+  it('folds persisted function calls and their result rows into one expandable tool', () => {
+    const user = normalizeChatMessage({ id: 'user-row', role: 'user', content: 'MEDIA:/tmp/keep-raw.png', timestamp: 0 }, 'session-1', 'default')
+    const call = normalizeChatMessage({
+      id: 'assistant-call', role: 'assistant', content: '', tool_calls: JSON.stringify([{
+        id: 'call-1', type: 'function', function: { name: 'terminal', arguments: '{"command":"pwd"}' },
+      }]), timestamp: 1,
+    }, 'session-1', 'default')
+    const result = normalizeChatMessage({
+      id: 'tool-result', role: 'tool', tool_call_id: 'call-1', tool_name: 'terminal',
+      content: '{"output":"/tmp/work","exit_code":0,"error":null}', timestamp: 2,
+    }, 'session-1', 'default')
+    const system = normalizeChatMessage({ id: 'system-row', role: 'system', content: 'transport detail', timestamp: 3 }, 'session-1', 'default')
+
+    const timeline = chatMessagesToUi([user, call, result, system])
+    expect(timeline).toHaveLength(2)
+    expect(timeline[0]).toMatchObject({ role: 'user', content: 'MEDIA:/tmp/keep-raw.png' })
+    expect(timeline[1].role).toBe('assistant')
+    expect(timeline[1].tools).toEqual([expect.objectContaining({
+      id: 'call-1', name: 'terminal', status: 'success', input: '{"command":"pwd"}', output: { output: '/tmp/work', exit_code: 0, error: null },
+    })])
+  })
+})

+ 9 - 2
tests/e2e/workspace.spec.ts

@@ -69,13 +69,20 @@ test('restores a routed conversation even when unread-count refresh fails', asyn
 test('renders historical assistant MEDIA as Markdown in chat and group chat', async ({ page }) => {
 test('renders historical assistant MEDIA as Markdown in chat and group chat', async ({ page }) => {
   await page.goto('/chat/session-demo')
   await page.goto('/chat/session-demo')
   await expect(page.locator('.markdown img[alt="AppIcon-1024.png"]')).toBeVisible()
   await expect(page.locator('.markdown img[alt="AppIcon-1024.png"]')).toBeVisible()
-  await expect(page.locator('.message--user .markdown')).toContainText('MEDIA:/brand/AppIcon-1024.png')
-  await expect(page.locator('.message--user .markdown img')).toHaveCount(0)
 
 
   await page.getByRole('button', { name: '群聊' }).click()
   await page.getByRole('button', { name: '群聊' }).click()
   await expect(page.locator('.markdown img[alt="AppIcon-1024.png"]')).toBeVisible()
   await expect(page.locator('.markdown img[alt="AppIcon-1024.png"]')).toBeVisible()
 })
 })
 
 
+test('folds tool result rows into their expandable tool call', async ({ page }) => {
+  await page.goto('/chat/session-demo')
+  const tool = page.getByRole('button', { name: /file_search/ })
+  await expect(tool).toBeVisible()
+  await tool.click()
+  await expect(page.locator('.tool-trace pre')).toContainText('/tmp/demo-report.pdf')
+  await expect(page.locator('.message--tool')).toHaveCount(0)
+})
+
 test('keeps the canonical logo and yaoyao-webui composer geometry', async ({ page }) => {
 test('keeps the canonical logo and yaoyao-webui composer geometry', async ({ page }) => {
   const logo = page.locator('.rail__brand img')
   const logo = page.locator('.rail__brand img')
   await expect(logo).toHaveAttribute('src', '/brand/AppIcon-1024.png')
   await expect(logo).toHaveAttribute('src', '/brand/AppIcon-1024.png')

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

@@ -41,8 +41,9 @@ const sessions = [
 ]
 ]
 const messages = [
 const messages = [
   { id: 'message-user', role: 'user', content: '请检查今天生成的产物。MEDIA:/brand/AppIcon-1024.png', timestamp: now() - 180 },
   { id: 'message-user', role: 'user', content: '请检查今天生成的产物。MEDIA:/brand/AppIcon-1024.png', timestamp: now() - 180 },
-  { id: 'message-assistant', role: 'assistant', content: '已整理完成。下面是 **验收摘要**:\n\n- 普通聊天已连接\n- 群聊 v2 已就绪\n- [打开报告](https://example.com/report)', reasoning_content: '先核对会话与文件索引。', timestamp: now() - 170 },
+  { id: 'message-tool-call', role: 'assistant', content: '', tool_calls: [{ id: 'tool-1', type: 'function', function: { name: 'file_search', arguments: '{"query":"产物"}' } }], timestamp: now() - 176 },
   { id: 'message-tool', role: 'tool', tool_name: 'file_search', tool_call_id: 'tool-1', summary: '扫描产物目录', result: { path: '/tmp/demo-report.pdf', count: 1 }, timestamp: now() - 175 },
   { id: 'message-tool', role: 'tool', tool_name: 'file_search', tool_call_id: 'tool-1', summary: '扫描产物目录', result: { path: '/tmp/demo-report.pdf', count: 1 }, timestamp: now() - 175 },
+  { 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-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-legacy-media', role: 'assistant', content: '历史兼容:MEDIA:/brand/AppIcon-1024.png', timestamp: now() - 150 },
 ]
 ]