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

WIP: 建立会话大纲标题锚点

[gstack-context]
Decisions: Markdown H1-H3 使用 message id + heading ordinal 稳定锚点;时间线优先滚到标题并高亮消息
Remaining: 大纲面板、菜单接入、响应式 Inspector、完整回归
Skill: /gitnexus-exploring + /frontend-skill
[/gstack-context]
samien 2 месяцев назад
Родитель
Сommit
3ac0414d9c

+ 11 - 3
src/client/components/messages/MarkdownContent.vue

@@ -14,7 +14,8 @@ const props = withDefaults(defineProps<{
   plain?: boolean
   mentionNames?: string[]
   fileCards?: boolean
-}>(), { streaming: false, legacyMedia: false, plain: false, fileCards: false })
+  outlinePrefix?: string
+}>(), { streaming: false, legacyMedia: false, plain: false, fileCards: false, outlinePrefix: '' })
 
 const emit = defineEmits<{ fileLink: [name: string, url: string] }>()
 
@@ -145,6 +146,13 @@ function decorateMediaPreviews() {
   })
 }
 
+function decorateOutlineHeadings() {
+  if (!props.outlinePrefix || !root.value) return
+  root.value.querySelectorAll<HTMLElement>('h1, h2, h3').forEach((heading, index) => {
+    heading.id = `${props.outlinePrefix}-heading-${index + 1}`
+  })
+}
+
 function onClick(event: MouseEvent) {
   const link = (event.target as HTMLElement).closest('a')
   if (!link) return
@@ -152,8 +160,8 @@ function onClick(event: MouseEvent) {
   if (/^(javascript|data|vbscript):/i.test(href)) event.preventDefault()
 }
 
-onMounted(() => { decorateCopyButtons(); decorateFileLinks(); decorateMediaPreviews() })
-onUpdated(() => { decorateCopyButtons(); decorateFileLinks(); decorateMediaPreviews() })
+onMounted(() => { decorateCopyButtons(); decorateFileLinks(); decorateMediaPreviews(); decorateOutlineHeadings() })
+onUpdated(() => { decorateCopyButtons(); decorateFileLinks(); decorateMediaPreviews(); decorateOutlineHeadings() })
 </script>
 
 <template>

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

@@ -106,6 +106,19 @@ function scrollToMessage(id: string): boolean {
   return true
 }
 
+function scrollToAnchor(messageId: string, anchorId: string): boolean {
+  const root = scroller.value
+  if (!root) return false
+  const message = root.querySelector<HTMLElement>(`[data-message-id="${CSS.escape(messageId)}"]`)
+  if (!message) return false
+  const target = anchorId ? message.querySelector<HTMLElement>(`#${CSS.escape(anchorId)}`) : null
+  ;(target || message).scrollIntoView({ block: target ? 'start' : 'center', behavior: 'smooth' })
+  message.classList.remove('message--revealed')
+  requestAnimationFrame(() => message.classList.add('message--revealed'))
+  window.setTimeout(() => message.classList.remove('message--revealed'), 1800)
+  return true
+}
+
 async function copyMessage(message: UiMessage) {
   await navigator.clipboard.writeText(message.content)
 }
@@ -128,7 +141,7 @@ watch(() => props.messages.at(-1)?.content, async () => {
 
 onMounted(() => nextTick(() => scrollToBottom('auto')))
 
-defineExpose({ scrollToMessage, scrollToBottom })
+defineExpose({ scrollToMessage, scrollToAnchor, scrollToBottom })
 </script>
 
 <template>
@@ -212,6 +225,7 @@ defineExpose({ scrollToMessage, scrollToBottom })
                 :legacy-media="message.role === 'assistant'"
                 :plain="message.role === 'user'"
                 :mention-names="mentionNames"
+                :outline-prefix="message.role === 'assistant' ? `outline-${message.id}` : ''"
                 file-cards
                 @file-link="(name, url) => emit('previewFile', { name, url })"
               />

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

@@ -1,5 +1,8 @@
 import { describe, expect, it } from 'vitest'
+import { mount } from '@vue/test-utils'
+import { nextTick } from 'vue'
 import type { UiMessage } from '@/components/messages/types'
+import MarkdownContent from '@/components/messages/MarkdownContent.vue'
 import { buildSessionOutline } from '@/utils/sessionOutline'
 
 describe('session outline', () => {
@@ -37,4 +40,12 @@ describe('session outline', () => {
     expect(item?.content).toHaveLength(51)
     expect(item?.content.endsWith('…')).toBe(true)
   })
+
+  it('assigns stable DOM anchors to rendered Markdown headings', async () => {
+    const wrapper = mount(MarkdownContent, { props: { content: '# 结论\n## 步骤\n### 风险', outlinePrefix: 'outline-a1' } })
+    await nextTick()
+    expect(wrapper.find('h1').attributes('id')).toBe('outline-a1-heading-1')
+    expect(wrapper.find('h2').attributes('id')).toBe('outline-a1-heading-2')
+    expect(wrapper.find('h3').attributes('id')).toBe('outline-a1-heading-3')
+  })
 })