Jelajahi Sumber

拆分可写聊天与只读历史记录

LSamien 1 bulan lalu
induk
melakukan
6ad3081f47

+ 1 - 1
README.md

@@ -1,6 +1,6 @@
 # 夭夭 Web
 
-当前发布版本:**v0.3.19**,配套 iOS **1.3(177)**。详见 [发布说明](docs/releases/v0.3.19.md)。
+当前发布版本:**v0.3.20**,配套 iOS **1.3(177)**。详见 [发布说明](docs/releases/v0.3.20.md)。
 
 夭夭 Web 为网页和 iOS 提供统一的 Agent、群聊、文件库及语音配置服务。Hermes 通过标准 9119 HTTP/WebSocket 接口执行任务;无需安装 Yaoyao 插件。
 

+ 12 - 0
docs/releases/v0.3.20.md

@@ -0,0 +1,12 @@
+# v0.3.20
+
+聊天模式与 Hermes 历史记录拆分为独立入口。
+
+- `/chat` 只显示 `source=web`、由当前 8800 Web/iOS 创建的 Hermes Profile 会话,可新建并继续聊天。
+- `/history` 只显示其他来源的 Hermes 会话,保留消息、文件和大纲浏览,不显示输入框,也不能继续或修改会话。
+- 侧栏分别提供“聊天”和“历史记录”入口;历史页不会改变上次使用的聊天/Bot 模式。
+- 服务端重新开放聊天 HTTP+SSE 通道,但在恢复会话前检查来源;历史会话不能借助直接链接恢复为可写会话。
+- Bot 模式继续使用 8800 自有 Agent/群聊,不增加历史记录入口。
+- Profile 名称和头像读取重新可用,不再回退为 `default` 和绿色默认头像。
+
+配套 iOS 继续为 1.3(177),无需插件升级。

+ 2 - 2
package-lock.json

@@ -1,12 +1,12 @@
 {
   "name": "hermes-yaoyao",
-  "version": "0.3.19",
+  "version": "0.3.20",
   "lockfileVersion": 3,
   "requires": true,
   "packages": {
     "": {
       "name": "hermes-yaoyao",
-      "version": "0.3.19",
+      "version": "0.3.20",
       "license": "BSL-1.1",
       "dependencies": {
         "@koa/bodyparser": "^5.0.0",

+ 1 - 1
package.json

@@ -1,6 +1,6 @@
 {
   "name": "hermes-yaoyao",
-  "version": "0.3.19",
+  "version": "0.3.20",
   "private": true,
   "license": "BSL-1.1",
   "description": "夭夭 Web:直连 Hermes 9119 的聊天、团队、文件库与产物工作台",

+ 3 - 3
release.json

@@ -1,6 +1,6 @@
 {
   "schemaVersion": 1,
-  "releaseVersion": "0.3.19",
-  "webVersion": "0.3.19",
-  "gitTag": "v0.3.19"
+  "releaseVersion": "0.3.20",
+  "webVersion": "0.3.20",
+  "gitTag": "v0.3.20"
 }

+ 6 - 2
src/client/App.vue

@@ -5,11 +5,13 @@ import LoadingScreen from '@/components/app/LoadingScreen.vue'
 import LoginView from '@/views/LoginView.vue'
 import PasswordChangeView from '@/views/PasswordChangeView.vue'
 import { useAuthStore } from '@/stores/auth'
+import { useChatStore } from '@/stores/chat'
 import { useKanbanStore } from '@/stores/kanban'
 import { useThemeStore } from '@/stores/theme'
 import AgentIdentityFixture from '@/components/app/AgentIdentityFixture.vue'
 
 const auth = useAuthStore()
+const chat = useChatStore()
 const kanban = useKanbanStore()
 const theme = useThemeStore()
 const route = useRoute()
@@ -17,13 +19,15 @@ const agentIdentityFixture = import.meta.env.DEV
   && new URLSearchParams(window.location.search).get('fixture') === 'agent-identity'
 
 const pageTitle = computed(() => {
-  const name = route.path.startsWith('/conversations')
+  const name = route.path.startsWith('/history')
+    ? '历史记录'
+    : route.path.startsWith('/conversations')
     ? '聊天'
     : route.path.startsWith('/kanban')
       ? kanban.selectedBoard?.name || kanban.selectedBoardSlug || '看板'
     : route.path.startsWith('/files')
       ? '文件库'
-      : '历史记录'
+      : chat.activeSession?.title || '对话'
   return `${name} · 夭夭`
 })
 

+ 2 - 1
src/client/api/sessions.ts

@@ -20,7 +20,7 @@ export interface MessagePage {
   limit: number
 }
 
-export async function getSessions(profile?: string, cursor?: string, limit = 100): Promise<SessionPage> {
+export async function getSessions(profile?: string, cursor?: string, limit = 100, view: 'chat' | 'history' = 'chat'): Promise<SessionPage> {
   const offset = Math.max(0, Number.parseInt(cursor ?? '0', 10) || 0)
   const payload = unwrapData(await apiRequest<unknown>(apiUrl('/api/app/sessions', {
     profile,
@@ -29,6 +29,7 @@ export async function getSessions(profile?: string, cursor?: string, limit = 100
     order: 'recent',
     archived: 'exclude',
     exclude: EXCLUDED_SOURCES,
+    view,
   })))
   const source = record(payload)
   const rawItems = values(source.items ?? source.sessions ?? payload)

+ 8 - 6
src/client/components/app/WorkspaceShell.vue

@@ -12,10 +12,10 @@ import { rememberInterfacePath } from '@/utils/interfaceMode'
 import YaoYaoSidebarIcon from '@/components/common/YaoYaoSidebarIcon.vue'
 
 type NavItem = {
-  key: 'chat' | 'groups' | 'kanban' | 'files'
+  key: 'chat' | 'history' | 'groups' | 'kanban' | 'files'
   label: string
   path: string
-  icon: 'chat' | 'groups' | 'board' | 'files'
+  icon: 'chat' | 'history' | 'groups' | 'board' | 'files'
 }
 
 type SettingsPage =
@@ -119,7 +119,8 @@ function profileTitle(profile?: Profile): string {
 }
 
 const navItems: NavItem[] = [
-  { key: 'chat', label: '历史记录', path: '/chat', icon: 'chat' },
+  { key: 'chat', label: '聊天', path: '/chat', icon: 'chat' },
+  { key: 'history', label: '历史记录', path: '/history', icon: 'history' },
   { key: 'groups', label: '聊天', path: '/conversations', icon: 'groups' },
   { key: 'kanban', label: '看板', path: '/kanban', icon: 'board' },
   { key: 'files', label: '文件库', path: '/files', icon: 'files' },
@@ -137,7 +138,8 @@ const activeNav = computed(() => {
 })
 
 const contextHeading = computed(() => ({
-  chat: '历史记录',
+  chat: '聊天',
+  history: '历史记录',
   groups: '聊天列表',
   kanban: '看板列表',
   files: '历史记录',
@@ -290,8 +292,8 @@ function toggleSidebar() {
   try { localStorage.setItem(SIDEBAR_COLLAPSED_KEY, sidebarCollapsed.value ? '1' : '0') } catch { /* optional persistence */ }
 }
 
-function navIcon(key: NavItem['key']): 'chat' | 'folder' | 'people' | 'board' {
-  return key === 'groups' ? 'people' : key === 'kanban' ? 'board' : key === 'files' ? 'folder' : 'chat'
+function navIcon(key: NavItem['key']): 'chat' | 'history' | 'folder' | 'people' | 'board' {
+  return key === 'groups' ? 'people' : key === 'kanban' ? 'board' : key === 'files' ? 'folder' : key === 'history' ? 'history' : 'chat'
 }
 
 async function openSidebarSearch(host: HTMLElement | null) {

+ 2 - 0
src/client/components/common/AppIcon.vue

@@ -1,6 +1,7 @@
 <script setup lang="ts">
 type IconName =
   | 'chat'
+  | 'history'
   | 'groups'
   | 'files'
   | 'artifacts'
@@ -68,6 +69,7 @@ withDefaults(defineProps<{ name: IconName; size?: number }>(), { size: 18 })
       <path d="M8 18h-3a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2v11a2 2 0 0 1-2 2h-5l-6 4z" />
       <path d="M8 9h8M8 13h5" />
     </template>
+    <template v-else-if="name === 'history'"><circle cx="12" cy="12" r="9" /><path d="M12 7v5l3 2M3 12H1m2 0 2-2m-2 2 2 2" /></template>
     <template v-else-if="name === 'groups'">
       <path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2" />
       <circle cx="9" cy="7" r="4" />

+ 3 - 1
src/client/components/common/YaoYaoSidebarIcon.vue

@@ -8,15 +8,17 @@ import {
   GridOutline,
   PeopleOutline,
   SearchOutline,
+  TimeOutline,
 } from '@vicons/ionicons5'
 
-type SidebarIconName = 'add' | 'board' | 'chat' | 'folder' | 'people' | 'search'
+type SidebarIconName = 'add' | 'board' | 'chat' | 'history' | 'folder' | 'people' | 'search'
 
 const props = withDefaults(defineProps<{ name: SidebarIconName; size?: number }>(), { size: 20 })
 const component = computed(() => ({
   add: AddOutline,
   board: GridOutline,
   chat: ChatbubbleOutline,
+  history: TimeOutline,
   folder: FolderOutline,
   people: PeopleOutline,
   search: SearchOutline,

+ 1 - 0
src/client/router/index.ts

@@ -6,6 +6,7 @@ const router = createRouter({
   routes: [
     { path: '/', redirect: () => savedInterfacePath() },
     { path: '/chat/:sessionId?', name: 'chat', component: () => import('@/views/ChatView.vue') },
+    { path: '/history/:sessionId?', name: 'history', component: () => import('@/views/HistoryView.vue') },
     { path: '/conversations/:id?', name: 'conversations', component: () => import('@/views/ConversationsView.vue') },
     { path: '/kanban/:boardSlug?', name: 'kanban', component: () => import('@/views/KanbanView.vue') },
     { path: '/files', name: 'files', component: () => import('@/views/FilesView.vue') },

+ 7 - 4
src/client/stores/chat.ts

@@ -374,13 +374,15 @@ export const useChatStore = defineStore('chat', () => {
     }
   })
 
-  async function loadSessions(profile?: string): Promise<void> {
+  let sessionView: 'chat' | 'history' = 'chat'
+  async function loadSessions(profile?: string, view: 'chat' | 'history' = sessionView): Promise<void> {
+    sessionView = view
     const requestedProfile = profile || undefined
     const loadGeneration = ++sessionLoadGeneration
     isLoading.value = true
     error.value = undefined
     try {
-      const page = await getSessions(requestedProfile, undefined, 100)
+      const page = await getSessions(requestedProfile, undefined, 100, sessionView)
       if (loadGeneration !== sessionLoadGeneration
         || (requestedProfile && auth.activeProfile?.name !== requestedProfile)) return
       // 9119 owns recency within each bucket. Pinning is an explicit user
@@ -403,7 +405,7 @@ export const useChatStore = defineStore('chat', () => {
     const loadGeneration = sessionLoadGeneration
     isLoadingMoreSessions.value = true
     try {
-      const page = await getSessions(requestedProfile, cursor, 100)
+      const page = await getSessions(requestedProfile, cursor, 100, sessionView)
       if (loadGeneration !== sessionLoadGeneration
         || (requestedProfile && auth.activeProfile?.name !== requestedProfile)) return
       sessions.value = appendSessionPage(sessions.value, page.items)
@@ -510,6 +512,7 @@ export const useChatStore = defineStore('chat', () => {
     activeProfileName.value = profile
     return id
   }
+  function clearSelection(): void { activeSessionId.value = undefined }
 
   async function performEnsureRuntime(initialState: ChatRouteState): Promise<ChatRouteState> {
     if (initialState.runtimeSessionId) return initialState
@@ -952,7 +955,7 @@ export const useChatStore = defineStore('chat', () => {
     sessions, activeSessionId, activeProfileName, activeSession, routes, activeRouteState, messages,
     connectionState, historySynced, hasMoreBefore, isLoading, isLoadingMoreSessions, hasMoreSessions, isSending, isStreaming, isQueued,
     error, models, selectedModel, reasoningEffort, fastMode, contextUsage, pendingApproval, pendingClarification, unreadCounts,
-    loadSessions, loadMoreSessions, selectSession, loadOlder, createSession, connect, disconnect, send, interrupt,
+    loadSessions, loadMoreSessions, selectSession, loadOlder, createSession, clearSelection, connect, disconnect, send, interrupt,
     respondToApproval, respondToClarification, branchSession, renameSession, setSessionPinned, removeSession,
     loadModels, setModel, setFastMode, refreshActiveSessionModel, refreshContextUsage, loadUnread, markRead, switchProfile,
   }

+ 88 - 18
src/client/views/ChatView.vue

@@ -58,7 +58,7 @@ function handleModelCatalogChanged(event: Event) {
 }
 
 const sessions = computed(() => [...chat.sessions]
-  .filter(session => !['cron', 'ios_group'].includes(session.source)))
+  .filter(session => session.source === 'web'))
 const agentNames = computed(() => new Map(auth.profiles.map(profile => [
   profile.name,
   profile.agentName || profile.displayName || profile.name,
@@ -143,10 +143,18 @@ async function refreshSessions() {
   }
   // The session route is the authoritative navigation target. A secondary
   // unread-count request must never prevent its history from being restored.
-  await chat.loadSessions(profile)
+  await chat.loadSessions(profile, 'chat')
+  if (requestedId) {
+    const target = await getSession(requestedId, profile).catch(() => undefined)
+    if (target && target.source !== 'web') {
+      await router.replace({ path: `/history/${encodeURIComponent(requestedId)}`, query: { profile } })
+      return
+    }
+  }
   if (requestedId && chat.activeSessionId !== requestedId) await chat.selectSession(requestedId, profile)
   if (requestedId && profile && routeProfile() !== profile) await router.replace(sessionLocation(requestedId, profile))
   void chat.loadUnread(profile).catch(() => undefined)
+  if (!requestedId && chat.activeSession?.source !== 'web') chat.clearSelection()
 }
 
 async function chooseSession(id: string) {
@@ -156,7 +164,7 @@ async function chooseSession(id: string) {
   const profile = session?.profile || auth.activeProfile?.name || 'default'
   await router.push(sessionLocation(id, profile))
   await ensureRouteProfile(profile)
-  if (chat.activeProfileName !== profile) await chat.loadSessions(profile)
+    if (chat.activeProfileName !== profile) await chat.loadSessions(profile, 'chat')
   await chat.selectSession(id, profile)
   await chat.markRead(id, profile).catch(() => undefined)
   quoted.value = null
@@ -340,7 +348,14 @@ onMounted(async () => {
   document.addEventListener('keydown', handleSessionActionKey)
   window.addEventListener(MODEL_CATALOG_CHANGED_EVENT, handleModelCatalogChanged)
   await refreshSessions()
+  await Promise.allSettled([chat.loadModels(auth.activeProfile?.name), chat.connect()])
+  void chat.refreshActiveSessionModel().catch(() => undefined)
+  modelSyncTimer = window.setInterval(() => {
+    if (document.visibilityState === 'visible') void chat.refreshActiveSessionModel().catch(() => undefined)
+  }, 3_000)
   await revealSourceMessage()
+  const pendingFile = await consumeLibraryItemForComposer()
+  if (pendingFile) composer.value?.attachFiles([pendingFile])
 })
 
 onBeforeUnmount(() => {
@@ -362,23 +377,30 @@ watch(() => auth.activeProfile?.name, async (profile, previous) => {
   chat.switchProfile(profile)
   const requestedId = typeof route.params.sessionId === 'string' ? route.params.sessionId : ''
   if (requestedId && routeProfile() === profile) {
-    await Promise.allSettled([chat.loadSessions(profile), chat.loadUnread(profile)])
+    await Promise.allSettled([chat.loadSessions(profile, 'chat'), chat.loadModels(profile), chat.loadUnread(profile), chat.connect()])
     await chat.selectSession(requestedId, profile)
     return
   }
   await router.replace({ path: '/chat', query: { profile } })
-  await Promise.allSettled([chat.loadSessions(profile), chat.loadUnread(profile)])
+  await Promise.allSettled([chat.loadSessions(profile, 'chat'), chat.loadModels(profile), chat.loadUnread(profile), chat.connect()])
 })
 
 watch(() => chat.activeSessionId, async id => {
   const profile = chat.activeProfileName || auth.activeProfile?.name || ''
+  if (id) void chat.refreshActiveSessionModel().catch(() => undefined)
   if (!id || (route.params.sessionId === id && routeProfile() === profile)) return
   await router.replace(sessionLocation(id, profile))
 })
 </script>
 
 <template>
-  <WorkspaceView sidebar-title="历史记录" :sidebar-subtitle="`${sessions.length} 个会话`" :inspector-open="!!preview || outlineOpen" :inspector-close-label="outlineOpen ? '关闭会话大纲' : '关闭预览'" @close-inspector="closeInspector">
+  <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" />
+        <span>新建聊天</span>
+      </button>
+    </template>
     <template #sidebar>
       <ResourceSidebar
         :items="sidebarItems"
@@ -389,8 +411,8 @@ watch(() => chat.activeSessionId, async id => {
         :has-more="chat.hasMoreSessions"
         :loading-more="chat.isLoadingMoreSessions"
         search-placeholder="搜索会话"
-        empty-title="还没有历史会话"
-        empty-description="Hermes 会话会在这里以只读历史显示。"
+        empty-title="还没有会话"
+        empty-description="新建会话后,从输入框开始聊天。"
         @load-more="chat.loadMoreSessions(auth.activeProfile?.name)"
         @select="chooseSession"
         @more="openSessionActions"
@@ -402,46 +424,94 @@ watch(() => chat.activeSessionId, async id => {
       <MessageTimeline
         ref="timeline"
         :messages="messages"
-        :title="activeSession?.title || '历史记录'"
-        :subtitle="activeSession ? `${activeSession.profile || auth.activeProfile?.name || ''} · ${activeSession.model || '默认模型'}` : '选择左侧会话查看历史'"
+        :title="activeSession?.title || '新会话'"
+        :subtitle="activeSession ? `${activeSession.profile || auth.activeProfile?.name || ''} · ${activeSession.model || chat.selectedModel?.name || '默认模型'}` : '选择会话或直接开始输入'"
         :loading="chat.activeRouteState?.isLoadingHistory || chat.isLoading"
         :loading-older="chat.activeRouteState?.isLoadingHistory"
         :has-older="chat.hasMoreBefore"
-        :connected="false"
+        :connected="connected"
         :synced="chat.historySynced"
         :show-tools="showThinking"
-        :thinking="false"
+        :thinking="chat.isSending || chat.isStreaming"
         :show-assistant-identity="false"
         :agent-avatars="agentAvatars"
-        :allow-branch="false"
-        read-only
+        :interaction="interaction"
         empty-logo
         transparent-header
+        :fork-source-title="forkSourceTitle"
         @load-older="chat.loadOlder"
+        @quote="quoted = $event"
+        @branch="branch"
         @preview="openAttachment"
         @preview-file="openLocalFile"
+        @approve="interaction && chat.respondToApproval(interaction.id, $event)"
+        @clarify="interaction && chat.respondToClarification(interaction.id, $event)"
       >
         <template #header-actions>
           <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>
+      <ComposerShell
+        ref="composer"
+        mode="chat"
+        :draft-key="`${auth.user?.id || 'local'}:${auth.activeProfile?.name || 'default'}:${chat.activeSessionId || 'new'}`"
+        :disabled="chat.connectionState === 'failed'"
+        :streaming="chat.isStreaming"
+        :sending="chat.isSending"
+        :model-label="chat.selectedModel?.name || '选择模型'"
+        :fast-mode="chat.fastMode"
+        :fast-mode-disabled="!chat.activeSessionId"
+        :reasoning-effort="reasoningLabel"
+        :reasoning-value="chat.reasoningEffort || ''"
+        :reasoning-options="reasoningComposerOptions"
+        :context-used="contextUsed"
+        :context-limit="chat.contextUsage?.contextLimit || 262144"
+        :context-estimated="contextIsEstimated"
+        :queue-mode="queueMode || chat.isQueued"
+        :tool-trace-visible="showThinking"
+        :reference="reference"
+        :slash-commands="[
+          { id: 'fork', label: 'fork', detail: '从当前会话创建分支' },
+          { id: 'compact', label: 'compact', detail: '压缩当前会话上下文' },
+          { id: 'model', label: 'model', detail: '切换模型' },
+        ]"
+        @send="send"
+        @stop="chat.interrupt"
+        @model-click="modelDialog = true"
+        @fast-mode-toggle="toggleFastMode"
+        @reasoning-change="selectReasoning"
+        @settings-click="toggleShowThinking"
+        @queue-toggle="queueMode = !queueMode"
+        @clear-reference="quoted = null"
+      />
     </div>
 
     <template #inspector>
       <SessionOutline v-if="outlineOpen" :messages="messages" :has-older="chat.hasMoreBefore" @navigate="navigateOutline" />
-      <PreviewInspector v-else-if="preview" :item="preview" :can-add-to-composer="false" @close="preview = null" />
+      <PreviewInspector v-else-if="preview" :item="preview" @close="preview = null" @add-to-composer="addPreviewToComposer" />
     </template>
   </WorkspaceView>
 
   <FloatingResourceSearch section="chat" label="搜索会话" :items="sidebarItems" @select="chooseSession" />
-  <PreviewModal v-if="filePreview" :item="filePreview" :items="conversationMediaItems" :can-add-to-composer="false" @close="filePreview = null" @source="filePreview = null" />
-  <ImagePreviewLightbox v-model="mediaPreviewIndex" :images="lightboxMedia" :can-add="false" />
+  <PreviewModal v-if="filePreview" :item="filePreview" :items="conversationMediaItems" @close="filePreview = null" @add-to-composer="addPreviewToComposer" @source="filePreview = null" />
+  <ImagePreviewLightbox v-model="mediaPreviewIndex" :images="lightboxMedia" @add="addMediaToComposer" />
+
+  <ModelChoiceDialog :open="modelDialog" :options="chat.models" :selected-id="selectedModelId" :busy="modelSwitching" @close="modelDialog = false" @select="selectModel" />
 
   <Teleport to="body">
     <Transition name="session-menu">
       <section v-if="actionSessionId" class="session-actions" :style="actionMenuStyle" role="menu" aria-label="会话操作" @contextmenu.prevent>
-        <button class="action-row" role="menuitem" type="button" @click="openSessionOutline"><AppIcon name="menu" :size="14" />会话大纲</button>
+        <template v-if="renaming">
+          <label>会话名称<input v-model="renameValue" maxlength="120" autofocus @keydown.enter="saveRename" /></label>
+          <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>
+        </template>
       </section>
     </Transition>
   </Teleport>

+ 468 - 0
src/client/views/HistoryView.vue

@@ -0,0 +1,468 @@
+<script setup lang="ts">
+import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
+import { useRoute, useRouter } from 'vue-router'
+import AppIcon from '@/components/common/AppIcon.vue'
+import YaoYaoSidebarIcon from '@/components/common/YaoYaoSidebarIcon.vue'
+import type { ChoiceOption } from '@/components/common/types'
+import ComposerShell from '@/components/composer/ComposerShell.vue'
+import ModelChoiceDialog from '@/components/composer/ModelChoiceDialog.vue'
+import type { ComposerOption, ComposerReference, ComposerSubmit } from '@/components/composer/types'
+import PreviewInspector from '@/components/library/PreviewInspector.vue'
+import PreviewModal from '@/components/library/PreviewModal.vue'
+import ImagePreviewLightbox from '@/components/library/ImagePreviewLightbox.vue'
+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 FloatingResourceSearch from '@/components/app/FloatingResourceSearch.vue'
+import WorkspaceView from '@/components/workspace/WorkspaceView.vue'
+import { chatInteraction, chatMessagesToUi, sessionSidebarItem } from '@/components/workspace/viewModels'
+import { consumeLibraryItemForComposer, loadComposerFile } from '@/components/workspace/pendingComposer'
+import { readAgentShowThinking, writeAgentShowThinking } from '@/utils/sessionPreferences'
+import { modelChoiceId, modelForChoiceId } from '@/utils/sessionModel'
+import { MODEL_CATALOG_CHANGED_EVENT, modelCatalogChangedProfile } from '@/utils/modelCatalogEvents'
+import { estimateConversationTokens } from '@/utils/contextUsage'
+import { useAuthStore } from '@/stores/auth'
+import { useChatStore } from '@/stores/chat'
+import { getSession } from '@/api/sessions'
+
+const auth = useAuthStore()
+const chat = useChatStore()
+const route = useRoute()
+const router = useRouter()
+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 modelSwitching = ref(false)
+const showThinking = ref(true)
+const queueMode = ref(false)
+const actionSessionId = ref('')
+const renaming = ref(false)
+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('')
+let modelSyncTimer: number | undefined
+
+function handleModelCatalogChanged(event: Event) {
+  const profile = modelCatalogChangedProfile(event)
+  if (profile && profile === auth.activeProfile?.name) void chat.loadModels(profile).catch(() => undefined)
+}
+
+const sessions = computed(() => [...chat.sessions]
+  .filter(session => !['cron', 'ios_group', 'yaoyao_workspace', 'web'].includes(session.source)))
+const agentNames = computed(() => new Map(auth.profiles.map(profile => [
+  profile.name,
+  profile.agentName || profile.displayName || profile.name,
+])))
+const agentAvatars = computed(() => Object.fromEntries(auth.profiles.flatMap(profile =>
+  profile.agentAvatar ? [[profile.name, profile.agentAvatar] as const] : []
+)))
+const sidebarItems = computed(() => sessions.value.map(session => sessionSidebarItem(
+  session,
+  chat.unreadCounts[session.id] ?? 0,
+  agentNames.value.get(session.profile || auth.activeProfile?.name || ''),
+)))
+const messages = computed(() => chatMessagesToUi(chat.messages, profile => profile ? agentNames.value.get(profile) : undefined))
+const reportedContextTokens = computed(() => chat.contextUsage?.contextTokens
+  || chat.contextUsage?.totalTokens
+  || (activeSession.value?.inputTokens ?? 0) + (activeSession.value?.outputTokens ?? 0))
+const estimatedContextTokens = computed(() => estimateConversationTokens(chat.messages))
+const contextUsed = computed(() => reportedContextTokens.value || estimatedContextTokens.value)
+const contextIsEstimated = computed(() => !reportedContextTokens.value && contextUsed.value > 0)
+const conversationMediaItems = computed(() => mediaItemsFromMessages(messages.value))
+const lightboxMedia = computed(() => conversationMediaItems.value.map(item => ({ url: item.previewUrl || item.downloadUrl || '', name: item.name, type: item.kind as 'image' | 'video' })).filter(item => item.url))
+const activeSession = computed(() => chat.activeSession)
+const actionSession = computed(() => chat.sessions.find(session => session.id === actionSessionId.value))
+const connected = computed(() => ['connected', 'ready'].includes(chat.connectionState))
+const interaction = computed(() => chatInteraction(chat.pendingApproval, chat.pendingClarification))
+const reference = computed<ComposerReference | null>(() => quoted.value ? { id: quoted.value.id, content: quoted.value.content, author: quoted.value.author } : null)
+const forkSourceTitle = computed(() => {
+  const parentId = activeSession.value?.parentSessionId
+  if (!parentId) return ''
+  return chat.sessions.find(session => session.id === parentId && session.profile === activeSession.value?.profile)?.title || parentId
+})
+const selectedModelId = computed(() => chat.selectedModel ? modelChoiceId(chat.selectedModel) : '')
+const reasoningOptions: ChoiceOption[] = [
+  { id: '', label: '默认', description: '使用模型或 Profile 的默认强度' },
+  { id: 'none', label: '关闭', description: '不使用额外推理' },
+  { id: 'low', label: '低', description: '更快的轻量推理' },
+  { id: 'medium', label: '中', description: '速度与深度平衡' },
+  { id: 'high', label: '高', description: '更深入地分析复杂问题' },
+  { id: 'xhigh', label: '极高', description: '适用于最复杂的任务' },
+]
+const reasoningComposerOptions = computed<ComposerOption[]>(() => reasoningOptions.map(option => ({
+  id: option.id,
+  label: option.label,
+  detail: option.description,
+})))
+const reasoningLabel = computed(() => reasoningOptions.find(option => option.id === (chat.reasoningEffort || ''))?.label || '默认')
+const actionMenuStyle = computed(() => ({ left: `${actionMenuPosition.value.x}px`, top: `${actionMenuPosition.value.y}px` }))
+
+function routeProfile(): string {
+  const value = typeof route.query.profile === 'string' ? route.query.profile : ''
+  return auth.profiles.some(profile => profile.name === value) ? value : ''
+}
+
+function sessionLocation(id: string, profile: string) {
+  return { path: `/history/${encodeURIComponent(id)}`, query: { profile } }
+}
+
+async function findLegacySessionProfile(sessionId: string): Promise<string> {
+  for (const profile of auth.profiles) {
+    try {
+      const session = await getSession(sessionId, profile.name)
+      if (session.id === sessionId) return profile.name
+    } catch { /* Try the next profile: 9119 returns 404 for a foreign session. */ }
+  }
+  return auth.activeProfile?.name || ''
+}
+
+async function ensureRouteProfile(profile: string): Promise<void> {
+  if (!profile || auth.activeProfile?.name === profile) return
+  chat.switchProfile(profile)
+  restoringRouteProfile.value = profile
+  auth.selectProfile(profile)
+  await nextTick()
+}
+
+async function refreshSessions() {
+  const requestedId = typeof route.params.sessionId === 'string' ? route.params.sessionId : ''
+  const profile = routeProfile() || (requestedId ? await findLegacySessionProfile(requestedId) : auth.activeProfile?.name || '')
+  if (profile && auth.activeProfile?.name !== profile) {
+    if (requestedId) await router.replace(sessionLocation(requestedId, profile))
+    await ensureRouteProfile(profile)
+  }
+  // The session route is the authoritative navigation target. A secondary
+  // unread-count request must never prevent its history from being restored.
+  await chat.loadSessions(profile, 'history')
+  if (requestedId) {
+    const target = await getSession(requestedId, profile).catch(() => undefined)
+    if (target?.source === 'web') {
+      await router.replace({ path: `/chat/${encodeURIComponent(requestedId)}`, query: { profile } })
+      return
+    }
+  }
+  if (requestedId && chat.activeSessionId !== requestedId) await chat.selectSession(requestedId, profile)
+  if (requestedId && profile && routeProfile() !== profile) await router.replace(sessionLocation(requestedId, profile))
+  void chat.loadUnread(profile).catch(() => undefined)
+  if (!requestedId && chat.activeSession?.source === 'web') chat.clearSelection()
+}
+
+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))
+  await ensureRouteProfile(profile)
+  if (chat.activeProfileName !== profile) await chat.loadSessions(profile, 'history')
+  await chat.selectSession(id, profile)
+  await chat.markRead(id, profile).catch(() => undefined)
+  quoted.value = null
+}
+
+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'))
+}
+
+async function send(payload: ComposerSubmit) {
+  try {
+    await chat.send(payload.text, payload.files, queueMode.value ? 'queue' : chat.isStreaming ? 'steer' : 'submit')
+    composer.value?.clearAfterSend()
+  } catch { /* store exposes the error and the composer keeps its draft */ }
+}
+
+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 { outlineOpen.value = false; preview.value = null; filePreview.value = item }
+}
+
+function openMedia(item: UiLibraryItem) {
+  const target = mediaUrlIdentity(item.previewUrl || item.downloadUrl || '')
+  const index = lightboxMedia.value.findIndex(media => mediaUrlIdentity(media.url) === target)
+  mediaPreviewIndex.value = index >= 0 ? index : null
+}
+
+function openLocalFile({ name, url }: { name: string; url: string }) {
+  const item = previewItemFromUrl(name, url)
+  if (item.kind === 'image' || item.kind === 'video') openMedia(item)
+  else { outlineOpen.value = false; filePreview.value = item }
+}
+
+async function addPreviewToComposer(item: UiLibraryItem) {
+  const file = await loadComposerFile(item)
+  if (!file || !composer.value) return
+  composer.value.attachFiles([file])
+  preview.value = null
+  filePreview.value = null
+  mediaPreviewIndex.value = null
+  await nextTick()
+  composer.value?.focus()
+}
+
+async function addMediaToComposer(media: PreviewMedia) {
+  await addPreviewToComposer(previewItemFromUrl(media.name, media.url, `preview:${media.url}`, media.type))
+}
+
+async function selectModel(id: string) {
+  if (id === selectedModelId.value) {
+    modelDialog.value = false
+    return
+  }
+  const model = modelForChoiceId(chat.models, id)
+  if (!model || modelSwitching.value) return
+  modelSwitching.value = true
+  try {
+    await chat.setModel(model)
+    modelDialog.value = false
+  } finally {
+    modelSwitching.value = false
+  }
+}
+
+function selectReasoning(id: string) {
+  chat.reasoningEffort = id || undefined
+}
+
+async function toggleFastMode(enabled: boolean) {
+  try { await chat.setFastMode(enabled) } catch { /* store restores the acknowledged state */ }
+}
+
+function restoreShowThinking(profile = auth.activeProfile?.name || 'default') {
+  showThinking.value = readAgentShowThinking(auth.user?.id ?? 'local', profile)
+}
+
+function toggleShowThinking() {
+  showThinking.value = !showThinking.value
+  writeAgentShowThinking(auth.user?.id ?? 'local', auth.activeProfile?.name || 'default', showThinking.value)
+}
+
+function openSessionActions(id: string, event?: MouseEvent) {
+  actionSessionId.value = id
+  const session = chat.sessions.find(item => item.id === id)
+  renameValue.value = session?.title || ''
+  renaming.value = false
+  const menuWidth = 208
+  const menuHeight = 176
+  const inset = 8
+  const anchor = event?.currentTarget instanceof HTMLElement ? event.currentTarget.getBoundingClientRect() : null
+  const requestedX = event?.type === 'contextmenu' ? event.clientX : anchor ? anchor.right - menuWidth : window.innerWidth - menuWidth - 14
+  const requestedY = event?.type === 'contextmenu' ? event.clientY : anchor ? anchor.bottom + 5 : 46
+  actionMenuPosition.value = {
+    x: Math.max(inset, Math.min(requestedX, window.innerWidth - menuWidth - inset)),
+    y: Math.max(inset, Math.min(requestedY, window.innerHeight - menuHeight - inset)),
+  }
+}
+
+function closeSessionActions() {
+  actionSessionId.value = ''
+  renaming.value = false
+}
+
+function handleSessionActionPointer(event: PointerEvent) {
+  if (!actionSessionId.value || (event.target as HTMLElement).closest('.session-actions')) return
+  closeSessionActions()
+}
+
+function handleSessionActionKey(event: KeyboardEvent) {
+  if (event.key === 'Escape' && actionSessionId.value) closeSessionActions()
+}
+
+async function saveRename() {
+  const session = chat.sessions.find(item => item.id === actionSessionId.value)
+  if (!session || !renameValue.value.trim()) return
+  await chat.renameSession(session.id, renameValue.value.trim(), session.profile)
+  closeSessionActions()
+}
+
+async function toggleSessionPinned() {
+  const session = actionSession.value
+  if (!session) return
+  await chat.setSessionPinned(session.id, !session.pinned, session.profile)
+  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
+  await chat.removeSession(session.id, session.profile)
+  closeSessionActions()
+  if (route.params.sessionId === session.id) await router.replace('/chat')
+}
+
+async function branch() {
+  const id = await chat.branchSession()
+  if (id) await router.push(sessionLocation(id, chat.activeProfileName || auth.activeProfile?.name || 'default'))
+}
+
+async function revealSourceMessage() {
+  const messageId = typeof route.query.message === 'string' ? route.query.message : ''
+  if (!messageId) return
+  await nextTick()
+  for (let attempt = 0; attempt < 10; attempt += 1) {
+    if (timeline.value?.scrollToMessage(messageId)) return
+    if (!chat.hasMoreBefore) return
+    await chat.loadOlder()
+    await nextTick()
+  }
+}
+
+onMounted(async () => {
+  restoreShowThinking()
+  document.addEventListener('pointerdown', handleSessionActionPointer)
+  document.addEventListener('keydown', handleSessionActionKey)
+  window.addEventListener(MODEL_CATALOG_CHANGED_EVENT, handleModelCatalogChanged)
+  await refreshSessions()
+  await revealSourceMessage()
+})
+
+onBeforeUnmount(() => {
+  document.removeEventListener('pointerdown', handleSessionActionPointer)
+  document.removeEventListener('keydown', handleSessionActionKey)
+  window.removeEventListener(MODEL_CATALOG_CHANGED_EVENT, handleModelCatalogChanged)
+  if (modelSyncTimer !== undefined) window.clearInterval(modelSyncTimer)
+})
+
+watch(() => auth.activeProfile?.name, async (profile, previous) => {
+  if (!profile || profile === previous) return
+  restoreShowThinking(profile)
+  outlineOpen.value = false
+  preview.value = null
+  if (restoringRouteProfile.value === profile) {
+    restoringRouteProfile.value = ''
+    return
+  }
+  chat.switchProfile(profile)
+  const requestedId = typeof route.params.sessionId === 'string' ? route.params.sessionId : ''
+  if (requestedId && routeProfile() === profile) {
+    await Promise.allSettled([chat.loadSessions(profile, 'history'), chat.loadUnread(profile)])
+    await chat.selectSession(requestedId, profile)
+    return
+  }
+  await router.replace({ path: '/history', query: { profile } })
+  await Promise.allSettled([chat.loadSessions(profile, 'history'), chat.loadUnread(profile)])
+})
+
+watch(() => chat.activeSessionId, async id => {
+  const profile = chat.activeProfileName || auth.activeProfile?.name || ''
+  if (!id || (route.params.sessionId === id && routeProfile() === profile)) return
+  await router.replace(sessionLocation(id, profile))
+})
+</script>
+
+<template>
+  <WorkspaceView sidebar-title="历史记录" :sidebar-subtitle="`${sessions.length} 个会话`" :inspector-open="!!preview || outlineOpen" :inspector-close-label="outlineOpen ? '关闭会话大纲' : '关闭预览'" @close-inspector="closeInspector">
+    <template #sidebar>
+      <ResourceSidebar
+        :items="sidebarItems"
+        :active-id="chat.activeSessionId"
+        :loading="chat.isLoading"
+        external-search
+        single-line
+        :has-more="chat.hasMoreSessions"
+        :loading-more="chat.isLoadingMoreSessions"
+        search-placeholder="搜索会话"
+        empty-title="还没有历史会话"
+        empty-description="Hermes 会话会在这里以只读历史显示。"
+        @load-more="chat.loadMoreSessions(auth.activeProfile?.name)"
+        @select="chooseSession"
+        @more="openSessionActions"
+        @context-menu="openSessionActions"
+      />
+    </template>
+
+    <div class="chat-workspace">
+      <MessageTimeline
+        ref="timeline"
+        :messages="messages"
+        :title="activeSession?.title || '历史记录'"
+        :subtitle="activeSession ? `${activeSession.profile || auth.activeProfile?.name || ''} · ${activeSession.model || '默认模型'}` : '选择左侧会话查看历史'"
+        :loading="chat.activeRouteState?.isLoadingHistory || chat.isLoading"
+        :loading-older="chat.activeRouteState?.isLoadingHistory"
+        :has-older="chat.hasMoreBefore"
+        :connected="false"
+        :synced="chat.historySynced"
+        :show-tools="showThinking"
+        :thinking="false"
+        :show-assistant-identity="false"
+        :agent-avatars="agentAvatars"
+        :allow-branch="false"
+        read-only
+        empty-logo
+        transparent-header
+        @load-older="chat.loadOlder"
+        @preview="openAttachment"
+        @preview-file="openLocalFile"
+      >
+        <template #header-actions>
+          <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>
+    </div>
+
+    <template #inspector>
+      <SessionOutline v-if="outlineOpen" :messages="messages" :has-older="chat.hasMoreBefore" @navigate="navigateOutline" />
+      <PreviewInspector v-else-if="preview" :item="preview" :can-add-to-composer="false" @close="preview = null" />
+    </template>
+  </WorkspaceView>
+
+  <FloatingResourceSearch section="chat" label="搜索会话" :items="sidebarItems" @select="chooseSession" />
+  <PreviewModal v-if="filePreview" :item="filePreview" :items="conversationMediaItems" :can-add-to-composer="false" @close="filePreview = null" @source="filePreview = null" />
+  <ImagePreviewLightbox v-model="mediaPreviewIndex" :images="lightboxMedia" :can-add="false" />
+
+  <Teleport to="body">
+    <Transition name="session-menu">
+      <section v-if="actionSessionId" class="session-actions" :style="actionMenuStyle" role="menu" aria-label="会话操作" @contextmenu.prevent>
+        <button class="action-row" role="menuitem" type="button" @click="openSessionOutline"><AppIcon name="menu" :size="14" />会话大纲</button>
+      </section>
+    </Transition>
+  </Teleport>
+</template>
+
+<style scoped>
+.chat-workspace { display: flex; min-width: 0; min-height: 0; flex: 1; flex-direction: column; }
+.sidebar-primary-action { display: flex; width: 100%; min-height: 40px; align-items: center; gap: 10px; padding: 0 11px; border: 0; border-radius: 9px; background: transparent; color: var(--text-primary); cursor: pointer; font-size: 12px; font-weight: 610; text-align: left; transition: background-color 120ms ease; }
+.sidebar-primary-action:hover, .sidebar-primary-action:focus-visible { background: var(--surface-hover); outline: 0; }
+.sidebar-primary-action:focus-visible { box-shadow: inset 0 0 0 1px var(--line-strong); }
+.header-session-menu { color: var(--text-muted); background: transparent; }.header-session-menu:hover { color: var(--text-primary); background: var(--surface-soft); }
+.chat-error { display: flex; width: min(760px, calc(100% - 32px)); margin: 0 auto 4px; align-items: center; gap: 6px; color: var(--danger); font-size: 9px; }
+.session-actions { position: fixed; z-index: 205; width: 208px; box-sizing: border-box; padding: 6px; border: 1px solid var(--line); border-radius: 11px; background: var(--surface-raised); box-shadow: 0 12px 34px rgba(0,0,0,.16); }.action-row { display: flex; width: 100%; min-height: 34px; align-items: center; gap: 8px; padding: 0 9px; border: 0; border-radius: 7px; background: transparent; color: var(--text-secondary); cursor: pointer; font-size: 11px; text-align: left; }.action-row:hover, .action-row:focus-visible { outline: 0; background: var(--surface-soft); color: var(--text-primary); }.action-row.danger { color: var(--danger); }.session-actions label { display: flex; flex-direction: column; gap: 6px; padding: 4px; color: var(--text-muted); font-size: 9px; }.session-actions input { width: 100%; height: 32px; box-sizing: border-box; padding: 0 8px; border: 1px solid var(--line); border-radius: 7px; outline: 0; background: var(--surface-soft); color: var(--text-primary); font-size: 11px; }.session-actions input:focus { border-color: var(--line-strong); box-shadow: 0 0 0 3px var(--focus-ring); }.session-actions > div { display: flex; justify-content: flex-end; gap: 5px; margin-top: 7px; padding: 0 4px 3px; }.session-actions :deep(.quiet-button), .session-actions :deep(.solid-button) { min-height: 29px; padding-inline: 10px; font-size: 10px; }
+.session-menu-enter-active, .session-menu-leave-active { transition: opacity 100ms ease, transform 120ms var(--ease-out); transform-origin: top right; }.session-menu-enter-from, .session-menu-leave-to { opacity: 0; transform: translateY(-3px) scale(.98); }
+@media (prefers-reduced-motion: reduce) { .sidebar-primary-action, .session-menu-enter-active, .session-menu-leave-active { transition: none; } }
+</style>

+ 18 - 8
src/server/realtimeApi.ts

@@ -24,6 +24,22 @@ export class RealtimeAPI {
       if (change.kind === 'reset') session.invalidateAuthentication()
     })
   }
+  private async canResume(profile: string, sessionId: string, jar?: CookieJar): Promise<boolean> {
+    const search = new URLSearchParams({ profile })
+    const path = `/api/sessions/${encodeURIComponent(sessionId)}`
+    const response = jar
+      ? await this.upstream.request(path, jar, { search })
+      : await this.session.request(path, { search })
+    if (response.status < 200 || response.status >= 300) return false
+    try {
+      const payload = JSON.parse(response.body.toString()) as Record<string, unknown>
+      const session = payload.session && typeof payload.session === 'object'
+        ? payload.session as Record<string, unknown> : payload
+      return session.source === 'web'
+    } catch {
+      return false
+    }
+  }
   private principal(ctx: Koa.Context, device?: string): RealtimePrincipal {
     let key: string
     let valid: () => boolean
@@ -54,6 +70,7 @@ export class RealtimeAPI {
     }
     return {
       key, instanceKey: this.config.upstream.href, upstreamKey: `${this.config.upstream.href}:${device ? `device:${device}` : 'service'}`, paired: Boolean(device), valid, authorize,
+      canResume: (profile, sessionId) => this.canResume(profile, sessionId, jar),
       agent: this.upstream.directAgent,
       observeCommand: f => observer?.observeClientFrame(f),
       observeEvent: f => observer?.observeUpstreamFrame(Buffer.from(f), false),
@@ -146,7 +163,7 @@ export class RealtimeAPI {
       ctx.set('Cache-Control', 'no-store')
       const path = match![2]!
       if (ctx.method === 'GET' && path === '/capabilities') {
-        ctx.body = { protocolVersion: 1, channels: [], brokerEpoch: this.broker.epoch,
+        ctx.body = { protocolVersion: 1, channels: ['chat'], brokerEpoch: this.broker.epoch,
           ...(device ? {} : { csrfToken: this.csrf.issue(ctx) }) }
         return
       }
@@ -154,13 +171,6 @@ export class RealtimeAPI {
       if (ctx.method === 'POST' && path === '/channels') {
         const body = await this.body(ctx)
         if (body.channel !== 'chat' && body.channel !== 'groups') throw new HttpError(400, 'Invalid channel', 'invalid_channel')
-        if (body.channel === 'chat') {
-          throw new HttpError(
-            410,
-            '原生 Hermes 会话仅供历史查看;请在聊天中开始或继续对话',
-            'native_sessions_read_only',
-          )
-        }
         const c = await this.broker.create(principal, body.channel, body.channel === 'groups'
           ? { epoch: canonicalEpoch(body.epoch), cursor: groupCursor(body.cursor) } : undefined)
         ctx.status = 201; ctx.body = { id: c.id, brokerEpoch: this.broker.epoch }; return

+ 4 - 0
src/server/realtimeBroker.ts

@@ -14,6 +14,7 @@ export interface RealtimePrincipal {
   agent?: WebSocket.ClientOptions['agent']
   valid(): boolean
   authorize?(kind: 'chat' | 'groups', method?: string): void
+  canResume?(profile: string, sessionId: string): Promise<boolean>
   url(channel: 'chat' | 'groups', anchor?: { epoch: string; cursor: number }): Promise<URL>
   observeCommand?(frame: string): void
   observeEvent?(frame: string): void
@@ -193,6 +194,9 @@ export class RealtimeBroker {
     const execute = async () => {
       if (!c.principal.valid()) throw new HttpError(401, 'Authentication expired', 'authentication_required')
       if (method === 'session.resume') {
+        if (c.principal.canResume && !await c.principal.canResume(String(p.profile), String(p.session_id))) {
+          throw new HttpError(403, '此会话属于历史记录,不能继续聊天', 'history_session_read_only')
+        }
         const ownerKey = JSON.stringify([c.principal.instanceKey ?? c.principal.upstreamKey, p.profile, p.session_id])
         const owner = this.routeOwners.get(ownerKey)
         if (owner && owner !== c.principal.upstreamKey) throw new HttpError(409, 'Session is attached through another credential scope', 'session_scope_conflict')

+ 27 - 3
src/server/routes.ts

@@ -326,6 +326,13 @@ function nativeSessionsReadOnly(): never {
   )
 }
 
+function requireWritableWebSession(response: UpstreamResponse): void {
+  const payload = requireSuccess(response)
+  const session = payload.session && typeof payload.session === 'object' && !Array.isArray(payload.session)
+    ? payload.session as JsonObject : payload
+  if (session.source !== 'web') nativeSessionsReadOnly()
+}
+
 function pairedProxyPath(rawPath: string): string {
   const path = rawPath.startsWith('/') ? rawPath : `/${rawPath}`
   if (path.includes('\\') || path.includes('\u0000') || path.length > 2_048) {
@@ -1793,8 +1800,10 @@ export function createApiRouter(dependencies: RouteDependencies): Router {
     )
   })
   router.get('/api/app/sessions', async (ctx) => {
+    const view = ctx.query.view === 'history' || ctx.query.view === 'chat' ? ctx.query.view : undefined
     const search = searchFrom(ctx, ['limit', 'offset', 'order', 'archived', 'profile', 'source'])
-    search.set('exclude_sources', 'cron,ios_group,yaoyao_workspace')
+    if (view === 'chat') search.set('source', 'web')
+    else search.set('exclude_sources', view === 'history' ? 'cron,ios_group,yaoyao_workspace,web' : 'cron,ios_group,yaoyao_workspace')
     const path = search.get('profile') ? '/api/sessions' : '/api/profiles/sessions'
     await proxy(ctx, dependencies.upstream, path, { search })
   })
@@ -1818,10 +1827,25 @@ export function createApiRouter(dependencies: RouteDependencies): Router {
     })
   })
   router.patch('/api/app/sessions/:sessionID', async (ctx) => {
-    nativeSessionsReadOnly()
+    const id = safeIdentifier(ctx.params.sessionID, 'session ID')
+    const search = searchFrom(ctx, ['profile'])
+    await withJar(ctx, async jar => {
+      requireWritableWebSession(await dependencies.upstream.request(`/api/sessions/${encodeURIComponent(id)}`, jar, { search }))
+      const request = body(ctx)
+      const profile = search.get('profile')
+      if (profile) request.profile = profile
+      const response = await dependencies.upstream.request(`/api/sessions/${encodeURIComponent(id)}`, jar, { method: 'PATCH', search, body: request })
+      sendUpstreamResponse(ctx, response, jar)
+    })
   })
   router.delete('/api/app/sessions/:sessionID', async (ctx) => {
-    nativeSessionsReadOnly()
+    const id = safeIdentifier(ctx.params.sessionID, 'session ID')
+    const search = searchFrom(ctx, ['profile'])
+    await withJar(ctx, async jar => {
+      requireWritableWebSession(await dependencies.upstream.request(`/api/sessions/${encodeURIComponent(id)}`, jar, { search }))
+      const response = await dependencies.upstream.request(`/api/sessions/${encodeURIComponent(id)}`, jar, { method: 'DELETE', search })
+      sendUpstreamResponse(ctx, response, jar)
+    })
   })
 
   router.all('/api/*gatewayPath', async (ctx) => {

+ 2 - 0
tests/client/interfaceMode.test.ts

@@ -10,6 +10,8 @@ describe('last interface mode', () => {
     expect(savedInterfacePath()).toBe('/conversations')
     rememberInterfacePath('/files')
     expect(savedInterfacePath()).toBe('/conversations')
+    rememberInterfacePath('/history/existing-session')
+    expect(savedInterfacePath()).toBe('/conversations')
     rememberInterfacePath('/chat/existing-session')
     expect(savedInterfacePath()).toBe('/chat')
   })

+ 2 - 0
tests/client/workspaceShell.test.ts

@@ -25,6 +25,7 @@ async function mountShell(start = '/chat') {
     history: createMemoryHistory(),
     routes: [
       { path: '/chat', component: { template: '<div></div>' } },
+      { path: '/history', component: { template: '<div></div>' } },
       { path: '/conversations', component: { template: '<div></div>' } },
       { path: '/groups', component: { template: '<div></div>' } },
       { path: '/kanban', component: { template: '<div></div>' } },
@@ -68,6 +69,7 @@ describe('Workspace shell account controls', () => {
   it('includes the Kanban workspace in desktop and mobile navigation', async () => {
     const wrapper = await mountShell()
     expect(wrapper.get('.desktop-sidebar').text()).toContain('看板')
+    expect(wrapper.get('.desktop-sidebar').text()).toContain('历史记录')
     expect(wrapper.get('.mobile-drawer').text()).toContain('看板')
     await wrapper.get('.desktop-sidebar .sidebar-feature-nav button[title="看板"]').trigger('click')
     await vi.waitFor(() => expect(wrapper.get('.sidebar-context__heading').text()).toContain('看板列表'))

+ 16 - 0
tests/e2e/workspace.spec.ts

@@ -36,6 +36,22 @@ test.beforeEach(async ({ page }) => {
   await expect(page.getByRole('navigation')).toBeVisible()
 })
 
+test('separates writable Web chats from read-only Hermes history', async ({ page }) => {
+  const sidebar = page.locator('.desktop-sidebar')
+  await expect(page.getByRole('button', { name: '新建聊天', exact: true })).toBeVisible()
+  await expect(sidebar.getByText('Hermes 外部历史', { exact: true })).toHaveCount(0)
+  await sidebar.getByRole('button', { name: '历史记录', exact: true }).click()
+  await expect(page).toHaveURL(/\/history/)
+  await expect(sidebar.getByText('Hermes 外部历史', { exact: true })).toBeVisible()
+  await expect(sidebar.getByText('夭夭 Web 验收会话', { exact: true })).toHaveCount(0)
+  await sidebar.getByText('Hermes 外部历史', { exact: true }).click()
+  await expect(page).toHaveURL(/\/history\/session-history-only/)
+  await expect(page.locator('.composer-shell')).toHaveCount(0)
+  await sidebar.getByRole('button', { name: '聊天', exact: true }).click()
+  await expect(page).toHaveURL(/\/chat/)
+  await expect(page.getByRole('button', { name: '新建聊天', exact: true })).toBeVisible()
+})
+
 test('explains optional upstream credentials without blocking a ready connection', async ({ page }, testInfo) => {
   await page.getByRole('button', { name: /^(打开设置中心|设置与模式)$/ }).first().click()
   if (await page.getByRole('menuitem', { name: '进入设置', exact: true }).isVisible()) await page.getByRole('menuitem', { name: '进入设置', exact: true }).click()

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

@@ -103,6 +103,7 @@ const profiles = [
 // This intentionally differs from local timestamp/pin ranking. Browser tests
 // verify that the WebUI preserves the ordering returned by 9119.
 const sessions = [
+  { id: 'session-history-only', profile: 'yaoyao', source: 'cli', title: 'Hermes 外部历史', preview: '只读历史记录', message_count: 1, tool_call_count: 0, started_at: now() - 1800, last_active_at: now() - 900, model: 'gpt-5.6', provider: 'openai' },
   { id: 'session-second', profile: 'yaoyao', source: 'web', title: '第二个会话', preview: '用于验证列表和切换', message_count: 2, tool_call_count: 0, started_at: now() - 7200, last_active_at: now() - 600, model: 'gpt-5.5', provider: 'openai' },
   { id: 'session-demo', profile: 'yaoyao', source: 'web', title: '夭夭 Web 验收会话', preview: '文件库与群聊已经就绪', message_count: 4, tool_call_count: 1, started_at: now() - 3600, last_active_at: now(), pinned: true, model: 'gpt-5.6', provider: 'openai' },
   { id: 'session-yaoer', profile: 'yaoer', source: 'web', title: '瑶儿专属会话', message_count: 1, tool_call_count: 0, started_at: now() - 3500, last_active_at: now() - 20, model: 'gpt-5.6', provider: 'openai' },
@@ -335,7 +336,9 @@ const server = createServer(async (request, response) => {
   if (url.pathname === '/api/profiles/sessions' || url.pathname === '/api/sessions') {
     const offset = Math.max(0, Number(url.searchParams.get('offset') || 0))
     const limit = Math.max(1, Number(url.searchParams.get('limit') || 100))
-    const scoped = url.searchParams.get('profile') ? sessions.filter(item => item.profile === url.searchParams.get('profile')) : sessions
+    const excluded = new Set((url.searchParams.get('exclude_sources') || '').split(',').filter(Boolean))
+    const scoped = sessions.filter(item => (!url.searchParams.get('profile') || item.profile === url.searchParams.get('profile'))
+      && (!url.searchParams.get('source') || item.source === url.searchParams.get('source')) && !excluded.has(item.source))
     return json(response, 200, { sessions: scoped.slice(offset, offset + limit), total: scoped.length, offset, limit })
   }
   if (/^\/api\/sessions\/[^/]+\/messages$/.test(url.pathname)) {

+ 13 - 10
tests/server/realtimeApi.test.ts

@@ -14,11 +14,11 @@ afterEach(() => {
   for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true })
 })
 
-describe('native Hermes sessions are read-only on 8800', () => {
-  it('does not advertise realtime chat and rejects all native session writes locally', async () => {
+describe('native Hermes chat and history are separated on 8800', () => {
+  it('opens realtime chat while keeping non-Web history mutations read-only', async () => {
     const home = mkdtempSync(join(tmpdir(), 'yaoyao-native-history-'))
     roots.push(home)
-    let upstreamCalls = 0
+    let upstreamCalls = 0, upstreamWrites = 0
     const config: ServerConfig = {
       host: '127.0.0.1', port: 8800, upstream: new URL('http://127.0.0.1:9119'),
       allowedHosts: new Set(), home, mediaRoot: home, attachmentsRoot: home,
@@ -29,6 +29,7 @@ describe('native Hermes sessions are read-only on 8800', () => {
       config,
       fetchImpl: (async (input, init) => {
         upstreamCalls += 1
+        if (!['GET', 'HEAD'].includes(init?.method ?? 'GET')) upstreamWrites += 1
         const path = new URL(String(input)).pathname
         if (path === '/api/status') return Response.json({ auth_required: true })
         if (path === '/api/auth/me') return Response.json({ user_id: 'service' })
@@ -37,6 +38,8 @@ describe('native Hermes sessions are read-only on 8800', () => {
           headers: { 'content-type': 'application/json', 'set-cookie': 'hermes_service=session; Path=/; HttpOnly' },
         })
         if (path === '/api/profiles') return Response.json({ profiles: [{ name: 'default' }] })
+        if (path === '/api/auth/ws-ticket') return Response.json({ ticket: 'fixture-ticket' })
+        if (path === '/api/sessions/session-web') return Response.json(init?.method === 'PATCH' ? { ok: true } : { id: 'session-web', source: 'web' })
         return Response.json({ method: init?.method ?? 'GET' })
       }) as typeof fetch,
     })
@@ -54,12 +57,8 @@ describe('native Hermes sessions are read-only on 8800', () => {
     const csrf = credentials.body.csrfToken
 
     const capabilities = await agent.get('/api/realtime/capabilities').set('Host', '127.0.0.1:8800').expect(200)
-    expect(capabilities.body.channels).toEqual([])
-    await agent.post('/api/realtime/channels').set('Host', '127.0.0.1:8800')
-      .set('Origin', origin).set('X-CSRF-Token', csrf).send({ channel: 'chat' })
-      .expect(410, { error: '原生 Hermes 会话仅供历史查看;请在聊天中开始或继续对话', code: 'native_sessions_read_only' })
-
-    const beforeWrites = upstreamCalls
+    expect(capabilities.body.channels).toEqual(['chat'])
+    const beforeWrites = upstreamCalls, beforeUpstreamWrites = upstreamWrites
     for (const path of ['/api/app/sessions/session-1', '/api/sessions/session-1']) {
       const response = await agent.patch(path).set('Host', '127.0.0.1:8800')
         .set('Origin', origin).set('X-CSRF-Token', csrf).send({ title: '不能修改' }).expect(410)
@@ -78,6 +77,10 @@ describe('native Hermes sessions are read-only on 8800', () => {
       .send({ title: '不能修改' })
       .expect(410)
     expect(pairedWrite.body.code).toBe('native_sessions_read_only')
-    expect(upstreamCalls).toBe(beforeWrites)
+    expect(upstreamCalls).toBeGreaterThan(beforeWrites)
+    expect(upstreamWrites).toBe(beforeUpstreamWrites)
+    await agent.patch('/api/app/sessions/session-web?profile=default').set('Host', '127.0.0.1:8800')
+      .set('Origin', origin).set('X-CSRF-Token', csrf).send({ title: '可修改的聊天' }).expect(200)
+    expect(upstreamWrites).toBe(beforeUpstreamWrites + 1)
   })
 })

+ 8 - 0
tests/server/realtimeBroker.test.ts

@@ -52,6 +52,14 @@ async function fixture() {
 }
 const resume = { method: 'session.resume', params: { session_id: 'stored-1', profile: 'default' } }
 describe('realtime broker', () => {
+  it('rejects resuming a history-only session before opening an upstream route', async () => {
+    const f = await fixture()
+    const principal = { ...f.principal('alice'), canResume: async () => false }
+    const channel = await f.broker.create(principal, 'chat')
+    const receipt = await f.broker.command(channel, 'history-resume', resume)
+    expect(receipt).toMatchObject({ state: 'rejected', response: { error: { code: 'history_session_read_only' } } })
+    expect(f.commands).toEqual([])
+  })
   it('shares upstream across subscribers and isolates unregistered routes', async () => {
     const f = await fixture()
     const a = await f.broker.create(f.principal('alice'), 'chat')

+ 2 - 3
tests/server/realtimeMigration.test.ts

@@ -147,13 +147,12 @@ describe('HTTP+SSE migration', () => {
     })
     expect(retired.status).toBe(410)
     const capabilities = await fetch(`${claim.serverUrl}/api/realtime/capabilities`, { headers: { Authorization: authorization } })
-    expect(await capabilities.json()).toMatchObject({ protocolVersion: 1, channels: [] })
+    expect(await capabilities.json()).toMatchObject({ protocolVersion: 1, channels: ['chat'] })
     const opened = await fetch(`${claim.serverUrl}/api/realtime/channels`, {
       method: 'POST', headers: { Authorization: authorization, 'Content-Type': 'application/json' },
       body: JSON.stringify({ channel: 'chat' }),
     })
-    expect(opened.status).toBe(410)
-    expect(await opened.json()).toMatchObject({ code: 'native_sessions_read_only' })
+    expect(opened.status).toBe(201)
     expect(pairedFrames).toEqual([])
     const revoke = await fetch(`${origin}/api/pair/v1/devices/${claim.deviceId}`, {
       method: 'DELETE', headers: { Authorization: authorization },