Jelajahi Sumber

WIP: 将同回合思考与工具合并折叠

[gstack-context]
Decisions: 回合摘要一级折叠;展开即显示 reasoning 和工具输入输出;兼容 reasoning_content 历史字段;内部消息不再单独渲染
Remaining: 全量测试、真实会话视觉检查、重载 8800
Skill: /frontend-skill
[/gstack-context]
samien 2 bulan lalu
induk
melakukan
e1aaf4ec9d

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

@@ -1,11 +1,13 @@
 <script setup lang="ts">
-import { nextTick, onMounted, ref, watch } from 'vue'
+import { computed, nextTick, onMounted, ref, watch } from 'vue'
 import AppIcon from '@/components/common/AppIcon.vue'
 import EmptyState from '@/components/common/EmptyState.vue'
 import InteractionCard from './InteractionCard.vue'
 import MarkdownContent from './MarkdownContent.vue'
 import ToolTrace from './ToolTrace.vue'
+import TurnTrace from './TurnTrace.vue'
 import type { UiInteraction, UiLocalFileLink, UiMessage } from './types'
+import { buildMessageTimelineRows } from '@/utils/turnTrace'
 
 const props = withDefaults(defineProps<{
   messages: UiMessage[]
@@ -57,6 +59,7 @@ const emit = defineEmits<{
 const scroller = ref<HTMLElement | null>(null)
 const pinnedToBottom = ref(true)
 const showJump = ref(false)
+const timelineRows = computed(() => buildMessageTimelineRows(props.messages))
 
 function formatTime(value?: string | number | Date) {
   if (!value) return ''
@@ -172,8 +175,11 @@ defineExpose({ scrollToMessage, scrollToAnchor, scrollToBottom })
           {{ loadingOlder ? '正在加载…' : '加载更早消息' }}
         </button>
 
+        <template v-for="row in timelineRows" :key="row.id">
+        <TurnTrace v-if="row.kind === 'trace'" :group="row" />
         <article
-          v-for="message in messages"
+          v-else
+          v-for="message in [row.message]"
           :key="message.id"
           :data-message-id="message.id"
           class="message"
@@ -254,6 +260,7 @@ defineExpose({ scrollToMessage, scrollToAnchor, scrollToBottom })
             </template>
           </div>
         </article>
+        </template>
 
         <InteractionCard
           v-if="interaction"

+ 14 - 8
src/client/components/messages/ToolTrace.vue

@@ -1,15 +1,18 @@
 <script setup lang="ts">
-import { computed, ref } from 'vue'
+import { computed, ref, watch } from 'vue'
 import type { UiToolCall } from './types'
 
-const props = defineProps<{ tool: UiToolCall }>()
-const open = ref(props.tool.status === 'error')
+const props = withDefaults(defineProps<{ tool: UiToolCall; expanded?: boolean }>(), { expanded: false })
+const open = ref(props.expanded || props.tool.status === 'error')
 const statusLabel = computed(() => ({ running: '运行中', success: '完成', error: '失败', pending: '等待' })[props.tool.status])
-const detail = computed(() => {
-  const value = props.tool.output ?? props.tool.input
+function detail(value: unknown): string {
+  if (value === undefined || value === null || value === '') return ''
   if (typeof value === 'string') return value
   try { return JSON.stringify(value, null, 2) } catch { return String(value ?? '') }
-})
+}
+const inputDetail = computed(() => detail(props.tool.input))
+const outputDetail = computed(() => detail(props.tool.output))
+watch(() => props.expanded, value => { if (value) open.value = true })
 </script>
 
 <template>
@@ -19,7 +22,10 @@ const detail = computed(() => {
       <strong>{{ tool.name }}</strong>
       <small v-if="tool.status === 'error'">{{ statusLabel }}</small>
     </button>
-    <pre v-if="open && detail">{{ detail }}</pre>
+    <div v-if="open && (inputDetail || outputDetail)" class="tool-trace__details">
+      <section v-if="inputDetail"><small>输入</small><pre>{{ inputDetail }}</pre></section>
+      <section v-if="outputDetail"><small>输出</small><pre>{{ outputDetail }}</pre></section>
+    </div>
   </div>
 </template>
 
@@ -30,6 +36,6 @@ const detail = computed(() => {
 .tool-trace > button strong { font: inherit; font-weight: 400; letter-spacing: .01em; }
 .tool-trace > button small { color: var(--danger); font: inherit; }
 .tool-trace__caret { display: inline-block; width: 9px; color: var(--text-muted); font-size: 13px; line-height: 1; transition: transform 120ms ease; }.tool-trace__caret.open { transform: rotate(90deg); }
-pre { max-width: min(680px, 100%); max-height: 260px; margin: 4px 0 2px 16px; padding: 8px 10px; overflow: auto; border-left: 1px dashed var(--line-strong); background: transparent; color: var(--text-secondary); font: 10px/1.55 var(--font-code); white-space: pre-wrap; overflow-wrap: anywhere; }
+.tool-trace__details { margin: 4px 0 4px 16px; border-left: 1px dashed var(--line-strong); }.tool-trace__details section + section { margin-top: 7px; }.tool-trace__details small { display: block; padding: 0 9px 2px; color: var(--text-muted); font: 8px/1.4 var(--font-ui); letter-spacing: .05em; }.tool-trace__details pre { max-width: min(680px, 100%); max-height: 260px; margin: 0; padding: 4px 9px 7px; overflow: auto; background: transparent; color: var(--text-secondary); font: 10px/1.55 var(--font-code); white-space: pre-wrap; overflow-wrap: anywhere; }
 .tool-trace--error { color: var(--danger); }
 </style>

+ 45 - 0
src/client/components/messages/TurnTrace.vue

@@ -0,0 +1,45 @@
+<script setup lang="ts">
+import { computed } from 'vue'
+import AppIcon from '@/components/common/AppIcon.vue'
+import MarkdownContent from './MarkdownContent.vue'
+import ToolTrace from './ToolTrace.vue'
+import type { TurnTraceGroup } from '@/utils/turnTrace'
+
+const props = defineProps<{ group: TurnTraceGroup }>()
+const reasoningCount = computed(() => props.group.entries.filter(entry => entry.type === 'reasoning').length)
+const toolCount = computed(() => props.group.entries.filter(entry => entry.type === 'tool').length)
+const summary = computed(() => [
+  reasoningCount.value ? `${reasoningCount.value} 段思考` : '',
+  toolCount.value ? `${toolCount.value} 个工具` : '',
+].filter(Boolean).join(' · '))
+const statusLabel = computed(() => props.group.status === 'running' ? '进行中' : props.group.status === 'error' ? '有错误' : '')
+</script>
+
+<template>
+  <details class="turn-trace" :class="`turn-trace--${group.status}`">
+    <summary>
+      <span class="turn-trace__caret">›</span>
+      <AppIcon name="brain" :size="13" />
+      <strong>思考与工具</strong>
+      <small>{{ summary }}</small>
+      <em v-if="statusLabel">{{ statusLabel }}</em>
+    </summary>
+    <div class="turn-trace__content">
+      <template v-for="entry in group.entries" :key="entry.id">
+        <section v-if="entry.type === 'reasoning'" class="turn-trace__reasoning">
+          <header><AppIcon name="brain" :size="12" />思考过程 · {{ entry.content.length }} 字</header>
+          <MarkdownContent :content="entry.content" />
+        </section>
+        <ToolTrace v-else :tool="entry.tool" expanded />
+      </template>
+    </div>
+  </details>
+</template>
+
+<style scoped>
+.turn-trace { width: min(680px, 100%); margin: 2px 0 9px; color: var(--text-muted); }.turn-trace > summary { display: flex; width: fit-content; min-height: 28px; align-items: center; gap: 6px; padding: 2px 7px 2px 2px; border-radius: 7px; cursor: pointer; list-style: none; transition: background-color 100ms ease, color 100ms ease; }.turn-trace > summary::-webkit-details-marker { display: none; }.turn-trace > summary:hover, .turn-trace > summary:focus-visible { outline: 0; background: var(--surface-soft); color: var(--text-secondary); }.turn-trace > summary strong { color: var(--text-secondary); font-size: 10px; font-weight: 540; }.turn-trace > summary small { font-size: 9px; }.turn-trace > summary em { color: var(--warning); font-size: 8px; font-style: normal; }.turn-trace--error > summary em { color: var(--danger); }
+.turn-trace__caret { display: inline-block; width: 9px; font-size: 14px; line-height: 1; transition: transform 120ms ease; }.turn-trace[open] .turn-trace__caret { transform: rotate(90deg); }
+.turn-trace__content { margin: 1px 0 6px 9px; padding: 2px 0 4px 13px; border-left: 1px dashed var(--line-strong); }.turn-trace__reasoning { max-width: 620px; padding: 7px 0 5px; color: var(--text-secondary); }.turn-trace__reasoning header { display: flex; align-items: center; gap: 5px; margin-bottom: 6px; color: var(--text-muted); font-size: 9px; }.turn-trace__reasoning :deep(.markdown) { font-size: 11px; line-height: 1.58; }
+.turn-trace :deep(.tool-trace) { margin-block: 5px; }
+@media (prefers-reduced-motion: reduce) { .turn-trace > summary, .turn-trace__caret { transition: none; } }
+</style>

+ 1 - 1
src/client/utils/normalize.ts

@@ -192,7 +192,7 @@ export function normalizeChatMessage(value: unknown, sessionId: string, fallback
     profile: string(source.profile, fallbackProfile) || undefined,
     role,
     content,
-    reasoning: string(pick(source, 'reasoning', 'thinking')) || undefined,
+    reasoning: string(pick(source, 'reasoning', 'thinking', 'reasoning_content', 'reasoningContent')) || undefined,
     timestamp: number(pick(source, 'timestamp', 'created_at', 'createdAt', 'updated_at', 'updatedAt'), Date.now() / 1000),
     sequence: number(pick(source, 'seq', 'sequence')) || undefined,
     stage: status === 'error' || source.error ? 'failed' : 'settled',

+ 2 - 1
tests/client/toolTimeline.test.ts

@@ -7,7 +7,7 @@ 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: 'assistant-call', role: 'assistant', content: '', reasoning_content: '先检查目录', tool_calls: JSON.stringify([{
         id: 'call-1', type: 'function', function: { name: 'terminal', arguments: '{"command":"pwd"}' },
       }]), timestamp: 1,
     }, 'session-1', 'default')
@@ -21,6 +21,7 @@ describe('ordinary chat tool timeline', () => {
     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].reasoning).toBe('先检查目录')
     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 },
     })])

+ 12 - 6
tests/e2e/workspace.spec.ts

@@ -133,10 +133,16 @@ test('renders historical assistant MEDIA as Markdown in chat and group chat', as
 
 test('folds tool result rows into their expandable tool call', async ({ page }) => {
   await page.goto('/chat/session-demo')
-  const tool = page.locator('[data-message-id="message-tool-call"]').getByRole('button', { name: /file_search/ })
-  await expect(tool).toBeVisible()
-  await tool.click()
-  await expect(page.locator('.tool-trace pre')).toContainText('/tmp/demo-report.pdf')
+  const trace = page.locator('.turn-trace').first()
+  await expect(trace).not.toHaveAttribute('open', '')
+  await expect(trace.locator('summary')).toContainText('思考与工具')
+  await expect(trace.locator('summary')).toContainText('2 段思考 · 2 个工具')
+  await trace.locator('summary').click()
+  await expect(trace.locator('.turn-trace__reasoning')).toHaveCount(2)
+  await expect(trace.locator('.tool-trace')).toHaveCount(2)
+  await expect(trace.locator('.tool-trace__details')).toHaveCount(2)
+  await expect(trace.locator('.tool-trace__details').filter({ hasText: '/tmp/demo-report.pdf' })).toBeVisible()
+  await expect(page.locator('[data-message-id="message-tool-call"]')).toHaveCount(0)
   await expect(page.locator('.message--tool')).toHaveCount(0)
 })
 
@@ -188,8 +194,8 @@ test('keeps the canonical logo and yaoyao-webui composer geometry', async ({ pag
   await assistantMessage.hover()
   await expect(assistantMessage.locator('.message__actions')).toHaveCSS('opacity', '1')
   await expect(page.locator('.message--tool-only .message__actions')).toHaveCount(0)
-  await expect(page.locator('[data-message-id="message-thinking-tool"] .message__actions')).toHaveCount(0)
-  await expect(page.locator('[data-message-id="message-thinking-tool"]')).toHaveClass(/message--assistant-internal/)
+  await expect(page.locator('[data-message-id="message-thinking-tool"]')).toHaveCount(0)
+  await expect(page.locator('.turn-trace')).toHaveCount(1)
   await expect(page.locator('.message--user .message__meta')).toHaveCSS('display', 'none')
   await expect(page.getByRole('button', { name: '会话操作' })).toBeVisible()
   await page.getByRole('button', { name: '会话操作' }).click()