Przeglądaj źródła

WIP: 实现会话大纲提取器

[gstack-context]
Decisions: 大纲由用户问题和本轮所有助手 H1-H3 组成;排除思考、代码围栏、工具与系统事件
Remaining: 标题锚点、消息定位、大纲面板与菜单接入、完整回归
Skill: /gitnexus-exploring + /frontend-skill
[/gstack-context]
samien 2 miesięcy temu
rodzic
commit
845358d23f

+ 72 - 0
src/client/utils/sessionOutline.ts

@@ -0,0 +1,72 @@
+import type { UiMessage } from '@/components/messages/types'
+
+export type SessionOutlineItem = {
+  id: string
+  type: 'user' | 'heading'
+  content: string
+  messageId: string
+  level: 0 | 1 | 2 | 3
+  anchorId: string
+  createdAt?: UiMessage['createdAt']
+}
+
+function cleanMessageText(value: string): string {
+  return value.replace(/<think>[\s\S]*?<\/think>/gi, '').trim()
+}
+
+function userQuestion(message: UiMessage): string {
+  const firstLine = cleanMessageText(message.content).split('\n').map(line => line.trim()).find(Boolean) || '用户消息'
+  return firstLine.length > 50 ? `${firstLine.slice(0, 50)}…` : firstLine
+}
+
+function assistantHeadings(message: UiMessage): SessionOutlineItem[] {
+  const result: SessionOutlineItem[] = []
+  let headingIndex = 0
+  let fence = ''
+  for (const line of cleanMessageText(message.content).split('\n')) {
+    const trimmed = line.trim()
+    const fenceMatch = trimmed.match(/^(```+|~~~+)/)
+    if (fenceMatch) {
+      fence = fence ? '' : fenceMatch[1]![0]!
+      continue
+    }
+    if (fence) continue
+    const match = trimmed.match(/^(#{1,3})\s+(.+?)\s*#*$/)
+    if (!match) continue
+    headingIndex += 1
+    const level = match[1]!.length as 1 | 2 | 3
+    result.push({
+      id: `outline-${message.id}-heading-${headingIndex}`,
+      type: 'heading',
+      content: match[2]!.trim(),
+      messageId: message.id,
+      level,
+      anchorId: `outline-${message.id}-heading-${headingIndex}`,
+      createdAt: message.createdAt,
+    })
+  }
+  return result
+}
+
+/** Builds the loaded-session outline from user turns and assistant Markdown headings. */
+export function buildSessionOutline(messages: UiMessage[]): SessionOutlineItem[] {
+  const visible = messages.filter(message => (message.role === 'user' || message.role === 'assistant') && !message.timelineKind)
+  const items: SessionOutlineItem[] = []
+  for (let index = 0; index < visible.length; index += 1) {
+    const message = visible[index]!
+    if (message.role !== 'user') continue
+    items.push({
+      id: `outline-user-${message.id}`,
+      type: 'user',
+      content: userQuestion(message),
+      messageId: message.id,
+      level: 0,
+      anchorId: `message-${message.id}`,
+      createdAt: message.createdAt,
+    })
+    for (let replyIndex = index + 1; replyIndex < visible.length && visible[replyIndex]?.role !== 'user'; replyIndex += 1) {
+      items.push(...assistantHeadings(visible[replyIndex]!))
+    }
+  }
+  return items
+}

+ 40 - 0
tests/client/sessionOutline.test.ts

@@ -0,0 +1,40 @@
+import { describe, expect, it } from 'vitest'
+import type { UiMessage } from '@/components/messages/types'
+import { buildSessionOutline } from '@/utils/sessionOutline'
+
+describe('session outline', () => {
+  it('keeps each user turn and all assistant H1-H3 headings before the next turn', () => {
+    const messages: UiMessage[] = [
+      { id: 'u1', role: 'user', content: '请给我一份完整方案' },
+      { id: 'a1', role: 'assistant', content: '# 结论\n正文\n## 实施步骤' },
+      { id: 'a2', role: 'assistant', content: '### 风险\n说明' },
+      { id: 'u2', role: 'user', content: '再补充测试' },
+      { id: 'a3', role: 'assistant', content: '## 测试计划' },
+    ]
+    expect(buildSessionOutline(messages).map(item => [item.type, item.level, item.content])).toEqual([
+      ['user', 0, '请给我一份完整方案'],
+      ['heading', 1, '结论'],
+      ['heading', 2, '实施步骤'],
+      ['heading', 3, '风险'],
+      ['user', 0, '再补充测试'],
+      ['heading', 2, '测试计划'],
+    ])
+  })
+
+  it('ignores reasoning, fenced-code headings, tools, and timeline system events', () => {
+    const messages: UiMessage[] = [
+      { id: 'system', role: 'user', content: '模型切换', timelineKind: 'system' },
+      { id: 'u1', role: 'user', content: '<think>内部</think>真实问题' },
+      { id: 'a1', role: 'assistant', content: '<think># 隐藏标题</think>\n```md\n# 代码标题\n```\n## 可见标题' },
+      { id: 'tool', role: 'tool', content: '# 工具标题' },
+    ]
+    expect(buildSessionOutline(messages).map(item => item.content)).toEqual(['真实问题', '可见标题'])
+  })
+
+  it('truncates long user questions to fifty characters', () => {
+    const content = '这是一条很长的用户问题'.repeat(8)
+    const [item] = buildSessionOutline([{ id: 'u1', role: 'user', content }])
+    expect(item?.content).toHaveLength(51)
+    expect(item?.content.endsWith('…')).toBe(true)
+  })
+})