Răsfoiți Sursa

WIP: 接入会话大纲面板与菜单入口

[gstack-context]
Decisions: 右上角小菜单打开响应式 Inspector;侧栏菜单也可切换目标会话后打开;移动端点击条目后自动关闭并恢复焦点
Remaining: 假 9119 大纲数据、端到端定位验证、全量回归与重载
Skill: /gitnexus-exploring + /frontend-skill
[/gstack-context]
samien 2 luni în urmă
părinte
comite
56ecf28eb8

+ 3 - 1
src/client/components/app/WorkspaceShell.vue

@@ -25,6 +25,7 @@ const props = withDefaults(defineProps<{
   sidebarTitle?: string
   sidebarSubtitle?: string
   inspectorOpen?: boolean
+  inspectorCloseLabel?: string
 }>(), {
   userName: '',
   profileName: '',
@@ -34,6 +35,7 @@ const props = withDefaults(defineProps<{
   sidebarTitle: '',
   sidebarSubtitle: '',
   inspectorOpen: false,
+  inspectorCloseLabel: '关闭预览',
 })
 
 const emit = defineEmits<{
@@ -384,7 +386,7 @@ onBeforeUnmount(() => {
     <Transition name="inspector-slide">
       <aside v-if="inspectorOpen" class="workspace-inspector">
         <div class="workspace-inspector__close">
-          <button class="icon-button" type="button" aria-label="关闭预览" @click="emit('closeInspector')">
+          <button class="icon-button" type="button" :aria-label="inspectorCloseLabel" :title="inspectorCloseLabel" @click="emit('closeInspector')">
             <AppIcon name="close" />
           </button>
         </div>

+ 67 - 0
src/client/components/messages/SessionOutline.vue

@@ -0,0 +1,67 @@
+<script setup lang="ts">
+import { computed } from 'vue'
+import type { UiMessage } from './types'
+import { buildSessionOutline, type SessionOutlineItem } from '@/utils/sessionOutline'
+
+const props = defineProps<{ messages: UiMessage[]; hasOlder?: boolean }>()
+const emit = defineEmits<{ navigate: [target: { messageId: string; anchorId: string }] }>()
+
+const items = computed(() => buildSessionOutline(props.messages))
+
+function formatTime(value?: UiMessage['createdAt']) {
+  if (!value) return ''
+  const date = value instanceof Date ? value : new Date(value)
+  if (Number.isNaN(date.getTime())) return ''
+  return new Intl.DateTimeFormat('zh-CN', { hour: '2-digit', minute: '2-digit' }).format(date)
+}
+
+function navigate(item: SessionOutlineItem) {
+  emit('navigate', { messageId: item.messageId, anchorId: item.anchorId })
+}
+</script>
+
+<template>
+  <nav class="session-outline" aria-label="会话大纲">
+    <header>
+      <div><strong>会话大纲</strong><span>{{ items.length }} 项</span></div>
+      <p>按问题与回答标题快速定位</p>
+    </header>
+    <div class="session-outline__content">
+      <template v-if="items.length">
+        <button
+          v-for="item in items"
+          :key="item.id"
+          type="button"
+          class="outline-item"
+          :class="[`outline-item--${item.type}`, `outline-item--level-${item.level}`]"
+          :aria-label="`跳转到 ${item.content}`"
+          @click="navigate(item)"
+        >
+          <template v-if="item.type === 'user'">
+            <b>Q</b><span>{{ item.content }}</span><time v-if="formatTime(item.createdAt)">{{ formatTime(item.createdAt) }}</time>
+          </template>
+          <template v-else>
+            <i aria-hidden="true" /><span>{{ item.content }}</span>
+          </template>
+        </button>
+      </template>
+      <div v-else class="session-outline__empty">
+        <strong>还没有大纲</strong>
+        <p>发送问题,或让回答使用 Markdown 标题后会显示在这里。</p>
+      </div>
+    </div>
+    <footer v-if="hasOlder">当前大纲基于已加载的消息</footer>
+  </nav>
+</template>
+
+<style scoped>
+.session-outline { display: flex; height: 100%; min-height: 0; flex-direction: column; background: var(--surface); }
+.session-outline > header { flex: 0 0 auto; min-height: 60px; padding: 13px 48px 10px 15px; border-bottom: 1px solid var(--line); }.session-outline > header > div { display: flex; align-items: baseline; gap: 8px; }.session-outline > header strong { font-size: 13px; font-weight: 620; }.session-outline > header span { color: var(--text-muted); font-size: 9px; }.session-outline > header p { margin: 4px 0 0; color: var(--text-muted); font-size: 9px; }
+.session-outline__content { min-height: 0; flex: 1; padding: 10px; overflow-y: auto; }
+.outline-item { display: flex; width: 100%; min-width: 0; align-items: flex-start; gap: 7px; margin: 0 0 3px; padding: 6px 8px; border: 0; border-radius: 7px; background: transparent; color: var(--text-secondary); cursor: pointer; font: inherit; text-align: left; transition: background-color 100ms ease, color 100ms ease; }.outline-item:hover, .outline-item:focus-visible { outline: 0; background: var(--surface-soft); color: var(--text-primary); }.outline-item span { min-width: 0; overflow: hidden; font-size: 11px; line-height: 1.45; overflow-wrap: anywhere; }.outline-item time { flex: 0 0 auto; margin-left: auto; color: var(--text-muted); font-size: 8px; font-variant-numeric: tabular-nums; }
+.outline-item--user { margin: 6px 0; padding: 8px 9px; border-radius: 9px; background: var(--surface-soft); color: var(--text-primary); }.outline-item--user:first-child { margin-top: 0; }.outline-item--user b { display: grid; width: 19px; height: 19px; flex: 0 0 19px; place-items: center; border-radius: 6px; background: var(--surface-raised); color: var(--text-secondary); font-size: 9px; font-weight: 650; }.outline-item--user span { font-size: 11.5px; font-weight: 520; }
+.outline-item--heading i { width: 4px; height: 4px; flex: 0 0 4px; margin-top: 6px; border-radius: 50%; background: currentColor; opacity: .45; }.outline-item--level-1 { padding-left: 10px; color: var(--text-primary); }.outline-item--level-1 span { font-weight: 560; }.outline-item--level-2 { padding-left: 22px; }.outline-item--level-3 { padding-left: 34px; color: var(--text-muted); }.outline-item--level-3 span { font-size: 10.5px; }
+.session-outline__empty { display: flex; min-height: 220px; align-items: center; justify-content: center; padding: 24px; flex-direction: column; color: var(--text-muted); text-align: center; }.session-outline__empty strong { color: var(--text-secondary); font-size: 12px; }.session-outline__empty p { max-width: 220px; margin: 6px 0 0; font-size: 10px; line-height: 1.55; }
+.session-outline > footer { flex: 0 0 auto; padding: 8px 12px; border-top: 1px solid var(--line); color: var(--text-muted); font-size: 8px; text-align: center; }
+@media (prefers-reduced-motion: reduce) { .outline-item { transition: none; } }
+</style>

+ 3 - 1
src/client/components/workspace/WorkspaceView.vue

@@ -8,7 +8,8 @@ withDefaults(defineProps<{
   sidebarTitle: string
   sidebarSubtitle?: string
   inspectorOpen?: boolean
-}>(), { sidebarSubtitle: '', inspectorOpen: false })
+  inspectorCloseLabel?: string
+}>(), { sidebarSubtitle: '', inspectorOpen: false, inspectorCloseLabel: '关闭预览' })
 
 const emit = defineEmits<{ closeInspector: [] }>()
 const auth = useAuthStore()
@@ -33,6 +34,7 @@ function selectProfile(label: string) { auth.selectProfile(profileNameMap.value.
     :sidebar-title="sidebarTitle"
     :sidebar-subtitle="sidebarSubtitle"
     :inspector-open="inspectorOpen"
+    :inspector-close-label="inspectorCloseLabel"
     @logout="logout"
     @toggle-theme="theme.toggle"
     @select-profile="selectProfile"

+ 39 - 5
src/client/views/ChatView.vue

@@ -14,6 +14,7 @@ import type { PreviewMedia } from '@/components/library/ImagePreviewLightbox.vue
 import type { UiLibraryItem } from '@/components/library/types'
 import { mediaItemsFromMessages, mediaUrlIdentity, previewItemFromUrl } from '@/components/library/mediaSequence'
 import MessageTimeline from '@/components/messages/MessageTimeline.vue'
+import SessionOutline from '@/components/messages/SessionOutline.vue'
 import type { UiMessage } from '@/components/messages/types'
 import ResourceSidebar from '@/components/app/ResourceSidebar.vue'
 import WorkspaceView from '@/components/workspace/WorkspaceView.vue'
@@ -32,6 +33,7 @@ const quoted = ref<UiMessage | null>(null)
 const preview = ref<UiLibraryItem | null>(null)
 const filePreview = ref<UiLibraryItem | null>(null)
 const mediaPreviewIndex = ref<number | null>(null)
+const outlineOpen = ref(false)
 const modelDialog = ref(false)
 const showTools = ref(true)
 const queueMode = ref(false)
@@ -41,6 +43,7 @@ const renameValue = ref('')
 const actionMenuPosition = ref({ x: 8, y: 8 })
 const composer = ref<InstanceType<typeof ComposerShell> | null>(null)
 const timeline = ref<InstanceType<typeof MessageTimeline> | null>(null)
+const headerSessionMenuButton = ref<HTMLButtonElement | null>(null)
 const restoringRouteProfile = ref('')
 
 const sessions = computed(() => [...chat.sessions]
@@ -129,6 +132,8 @@ async function refreshSessions() {
 }
 
 async function chooseSession(id: string) {
+  outlineOpen.value = false
+  preview.value = null
   const session = chat.sessions.find(item => item.id === id)
   const profile = session?.profile || auth.activeProfile?.name || 'default'
   await router.push(sessionLocation(id, profile))
@@ -140,6 +145,8 @@ async function chooseSession(id: string) {
 }
 
 async function createSession() {
+  outlineOpen.value = false
+  preview.value = null
   const id = chat.createSession(auth.activeProfile?.name)
   await router.push(sessionLocation(id, auth.activeProfile?.name || 'default'))
 }
@@ -155,7 +162,7 @@ function openAttachment(attachment: NonNullable<UiMessage['attachments']>[number
   const item = previewItemFromUrl(attachment.name, attachment.url || '', attachment.id, attachment.kind)
   item.size = attachment.size
   if (item.kind === 'image' || item.kind === 'video') openMedia(item)
-  else preview.value = item
+  else { outlineOpen.value = false; preview.value = item }
 }
 
 function openMedia(item: UiLibraryItem) {
@@ -167,7 +174,7 @@ function openMedia(item: UiLibraryItem) {
 function openLocalFile({ name, url }: { name: string; url: string }) {
   const item = previewItemFromUrl(name, url)
   if (item.kind === 'image' || item.kind === 'video') openMedia(item)
-  else filePreview.value = item
+  else { outlineOpen.value = false; filePreview.value = item }
 }
 
 async function addPreviewToComposer(item: UiLibraryItem) {
@@ -241,6 +248,27 @@ async function toggleSessionPinned() {
   closeSessionActions()
 }
 
+async function openSessionOutline() {
+  const session = actionSession.value
+  if (!session) return
+  closeSessionActions()
+  if (session.id !== chat.activeSessionId || session.profile !== chat.activeProfileName) await chooseSession(session.id)
+  preview.value = null
+  outlineOpen.value = true
+}
+
+function closeInspector() {
+  const restoreMenuFocus = outlineOpen.value
+  preview.value = null
+  outlineOpen.value = false
+  if (restoreMenuFocus) void nextTick(() => headerSessionMenuButton.value?.focus())
+}
+
+function navigateOutline(target: { messageId: string; anchorId: string }) {
+  timeline.value?.scrollToAnchor(target.messageId, target.anchorId)
+  if (window.matchMedia('(max-width: 900px)').matches) closeInspector()
+}
+
 async function deleteSession() {
   const session = chat.sessions.find(item => item.id === actionSessionId.value)
   if (!session) return
@@ -284,6 +312,8 @@ onBeforeUnmount(() => {
 
 watch(() => auth.activeProfile?.name, async (profile, previous) => {
   if (!profile || profile === previous) return
+  outlineOpen.value = false
+  preview.value = null
   if (restoringRouteProfile.value === profile) {
     restoringRouteProfile.value = ''
     return
@@ -307,7 +337,7 @@ watch(() => chat.activeSessionId, async id => {
 </script>
 
 <template>
-  <WorkspaceView sidebar-title="对话" :sidebar-subtitle="`${sessions.length} 个会话`" :inspector-open="!!preview" @close-inspector="preview = null">
+  <WorkspaceView sidebar-title="对话" :sidebar-subtitle="`${sessions.length} 个会话`" :inspector-open="!!preview || outlineOpen" :inspector-close-label="outlineOpen ? '关闭会话大纲' : '关闭预览'" @close-inspector="closeInspector">
     <template #sidebar-action>
       <button class="sidebar-primary-action" type="button" title="新建聊天" aria-label="新建聊天" @click="createSession">
         <YaoYaoSidebarIcon name="add" />
@@ -360,7 +390,7 @@ watch(() => chat.activeSessionId, async id => {
         @clarify="interaction && chat.respondToClarification(interaction.id, $event)"
       >
         <template #header-actions>
-          <button v-if="activeSession" class="icon-button header-session-menu" type="button" title="会话操作" aria-label="会话操作" @click="openSessionActions(activeSession.id, $event)"><AppIcon name="dots" :size="18" /></button>
+          <button v-if="activeSession" ref="headerSessionMenuButton" class="icon-button header-session-menu" type="button" title="会话操作" aria-label="会话操作" @click="openSessionActions(activeSession.id, $event)"><AppIcon name="dots" :size="18" /></button>
         </template>
       </MessageTimeline>
       <p v-if="chat.error" class="chat-error" role="alert"><AppIcon name="alert" :size="13" />{{ chat.error }}</p>
@@ -395,7 +425,10 @@ watch(() => chat.activeSessionId, async id => {
       />
     </div>
 
-    <template #inspector><PreviewInspector v-if="preview" :item="preview" @close="preview = null" @add-to-composer="addPreviewToComposer" /></template>
+    <template #inspector>
+      <SessionOutline v-if="outlineOpen" :messages="messages" :has-older="chat.hasMoreBefore" @navigate="navigateOutline" />
+      <PreviewInspector v-else-if="preview" :item="preview" @close="preview = null" @add-to-composer="addPreviewToComposer" />
+    </template>
   </WorkspaceView>
 
   <PreviewModal v-if="filePreview" :item="filePreview" :items="conversationMediaItems" @close="filePreview = null" @add-to-composer="addPreviewToComposer" @source="filePreview = null" />
@@ -411,6 +444,7 @@ watch(() => chat.activeSessionId, async id => {
           <div><button class="quiet-button" type="button" @click="renaming = false">取消</button><button class="solid-button" type="button" @click="saveRename">保存</button></div>
         </template>
         <template v-else>
+          <button class="action-row" role="menuitem" type="button" @click="openSessionOutline"><AppIcon name="menu" :size="14" />会话大纲</button>
           <button class="action-row" role="menuitem" type="button" @click="toggleSessionPinned"><AppIcon :name="actionSession?.pinned ? 'pin-off' : 'pin'" :size="14" />{{ actionSession?.pinned ? '取消置顶' : '置顶会话' }}</button>
           <button class="action-row" role="menuitem" type="button" @click="renaming = true"><AppIcon name="edit" :size="14" />重命名</button>
           <button class="action-row danger" role="menuitem" type="button" @click="deleteSession"><AppIcon name="trash" :size="14" />删除会话</button>