Переглянути джерело

发布 v0.3.12:按参考图调整 Bot 模式并恢复上次模式

LSamien 1 місяць тому
батько
коміт
2383db15b6

+ 1 - 1
README.md

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

+ 15 - 0
docs/releases/v0.3.12.md

@@ -0,0 +1,15 @@
+# v0.3.12
+
+原团队群聊升级为 Bot 模式,统一从设置进入,并记住当前设备上次使用的模式。
+
+- Bot 模式继续使用 Web 保存的自建 Bot 与群聊,不混入基础 Agent 的原生会话。
+- 移除旧团队导航入口;普通聊天与 Bot 模式在设置中互相切换,重新打开时恢复最后使用的模式。
+- 列表按参考照片调整为顶部账号头像、搜索和加号;大头像、名称、最后消息和简洁时间排列在同一列表中,群头像由真实成员合成。
+- 手机网页直接显示 Bot 列表,打开会话后可返回列表;搜索弹层保留未归档和已归档分类。
+- 配套 iOS 1.3(174)同步设置入口、列表样式、模式记忆及搜索。
+
+## 验证
+
+- Web 86 个测试文件、500 项测试通过,类型检查与生产构建通过。
+- 浏览器验证设置切换、两种模式恢复、手机列表、群聊及搜索。
+- iOS 40 项单元测试通过;模拟器验证两种模式各自重启恢复、设置切换、搜索与打开旧聊天,签名构建为 1.3(174)。

+ 3 - 3
package-lock.json

@@ -1,12 +1,12 @@
 {
   "name": "hermes-yaoyao",
-  "version": "0.3.11",
+  "version": "0.3.12",
   "lockfileVersion": 3,
   "requires": true,
   "packages": {
     "": {
       "name": "hermes-yaoyao",
-      "version": "0.3.11",
+      "version": "0.3.12",
       "license": "BSL-1.1",
       "dependencies": {
         "@koa/bodyparser": "^5.0.0",
@@ -6215,7 +6215,7 @@
       }
     },
     "node_modules/seemly": {
-      "version": "0.3.11",
+      "version": "0.3.10",
       "resolved": "https://registry.npmmirror.com/seemly/-/seemly-0.3.10.tgz",
       "integrity": "sha512-2+SMxtG1PcsL0uyhkumlOU6Qo9TAQ/WyH7tthnPIOQB05/12jz9naq6GZ6iZ6ApVsO3rr2gsnTf3++OV63kE1Q==",
       "license": "MIT"

+ 1 - 1
package.json

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

+ 3 - 3
release.json

@@ -1,6 +1,6 @@
 {
   "schemaVersion": 1,
-  "releaseVersion": "0.3.11",
-  "webVersion": "0.3.11",
-  "gitTag": "v0.3.11"
+  "releaseVersion": "0.3.12",
+  "webVersion": "0.3.12",
+  "gitTag": "v0.3.12"
 }

+ 4 - 2
src/client/components/app/ResourceSidebar.vue

@@ -19,6 +19,7 @@ const props = withDefaults(defineProps<{
   emptyTitle?: string
   emptyDescription?: string
   singleLine?: boolean
+  avatarSize?: number
   hasMore?: boolean
   loadingMore?: boolean
 }>(), {
@@ -31,6 +32,7 @@ const props = withDefaults(defineProps<{
   emptyTitle: '暂无内容',
   emptyDescription: '新建一项开始使用。',
   singleLine: false,
+  avatarSize: 23,
   hasMore: false,
   loadingMore: false,
 })
@@ -150,8 +152,8 @@ defineExpose({ focusSearch })
               <AppIcon name="chevron-down" :size="13" />
             </button>
             <span v-if="!singleLine" class="sidebar-item__icon" :class="{ 'sidebar-item__icon--avatar': !row.item.icon }">
-              <AgentAvatar v-if="row.item.avatarKind === 'agent'" :name="row.item.title" :avatar="row.item.avatar" :state="row.item.avatarState" :size="23" />
-              <TeamAvatar v-else-if="row.item.avatar !== undefined || row.item.avatarMembers?.length" :name="row.item.title" :avatar="row.item.avatar || ''" :members="row.item.avatarMembers || []" :fallback-key="row.item.avatarFallbackKey || row.item.id" :size="23" />
+              <AgentAvatar v-if="row.item.avatarKind === 'agent'" :name="row.item.title" :avatar="row.item.avatar" :state="row.item.avatarState" :size="avatarSize" />
+              <TeamAvatar v-else-if="row.item.avatar !== undefined || row.item.avatarMembers?.length" :name="row.item.title" :avatar="row.item.avatar || ''" :members="row.item.avatarMembers || []" :fallback-key="row.item.avatarFallbackKey || row.item.id" :size="avatarSize" />
               <AppIcon v-else-if="row.item.icon" :name="row.item.icon" :size="row.item.topic ? 14 : 15" />
               <template v-else>{{ row.item.title.slice(0, 1).toUpperCase() }}</template>
               <span v-if="row.item.status" class="presence" :class="`presence--${row.item.status}`" />

+ 7 - 0
src/client/components/app/SettingsCenterDialog.vue

@@ -40,6 +40,7 @@ interface NavigationItem {
 
 const props = withDefaults(defineProps<{
   open: boolean
+  botMode?: boolean
   initialPage?: SettingsPage
   userName?: string
   pairingUserName?: string
@@ -77,6 +78,7 @@ const emit = defineEmits<{
   'select-profile': [profile: string]
   'save-identity': [input: ProfileIdentityInput]
   'set-theme': [theme: ThemePreference]
+  'switch-mode': []
 }>()
 
 const dialog = ref<HTMLElement>()
@@ -243,6 +245,10 @@ watch(() => [props.open, props.initialPage] as const, ([open, initialPage]) => {
   mobileDetailOpen.value = false
   void nextTick(() => dialog.value?.focus())
 }, { immediate: true })
+function requestModeSwitch() {
+  if (updateLocked.value || !confirmDiscard()) return
+  emit('switch-mode')
+}
 </script>
 
 <template>
@@ -282,6 +288,7 @@ watch(() => [props.open, props.initialPage] as const, ([open, initialPage]) => {
                 </div>
 
                 <nav>
+                  <section><button type="button" :disabled="updateLocked" @click="requestModeSwitch"><AppIcon :name="botMode ? 'chat' : 'users'" :size="18" /><span>{{ botMode ? '进入聊天模式' : '进入 Bot 模式' }}</span></button></section>
                   <section>
                     <h3>当前 Agent</h3>
                     <button v-for="item in agentItems" :key="item.key" type="button" :class="{ active: activePage === item.key }" :aria-current="activePage === item.key ? 'page' : undefined" @click="selectPage(item.key)"><AppIcon :name="item.icon" :size="20" /><span>{{ item.label }}</span></button>

+ 39 - 11
src/client/components/app/WorkspaceShell.vue

@@ -7,6 +7,7 @@ import type { ProfileIdentityInput } from '@/api/profiles'
 import AppIcon from '@/components/common/AppIcon.vue'
 import BrandMark from '@/components/common/BrandMark.vue'
 import SettingsCenterDialog from '@/components/app/SettingsCenterDialog.vue'
+import { rememberInterfacePath } from '@/utils/interfaceMode'
 import YaoYaoSidebarIcon from '@/components/common/YaoYaoSidebarIcon.vue'
 
 type NavItem = {
@@ -143,6 +144,7 @@ const hasPrimaryAction = computed(() => ['chat', 'groups', 'kanban', 'files'].in
 
 async function navigate(path: string) {
   mobileDrawerOpen.value = false
+  rememberInterfacePath(path)
   await router.push(path)
 }
 
@@ -208,11 +210,15 @@ async function openSettingsMenu(event: MouseEvent) {
 }
 function chooseSettingsAction(action: 'settings' | 'bots') {
   settingsMenuOpen.value = false
-  if (action === 'bots') { void navigate('/conversations'); return }
+  if (action === 'bots') { switchInterfaceMode(); return }
   settingsReturnFocus.value = settingsTrigger?.closest('.mobile-drawer')
     ? mobileNavigationTrigger.value : settingsTrigger ?? undefined
   openSettings('agent-identity')
 }
+function switchInterfaceMode() {
+  settingsOpen.value = false
+  void navigate(applicationWorkspace.value ? '/chat' : '/conversations')
+}
 function workspaceKeydown(event: KeyboardEvent) {
   if (!applicationWorkspace.value || !(event.metaKey || event.ctrlKey) || event.key.toLowerCase() !== 'k' || settingsOpen.value || document.querySelector('dialog[open]')) return
   event.preventDefault()
@@ -351,12 +357,12 @@ onBeforeUnmount(() => {
 </script>
 
 <template>
-  <div class="workspace-shell" :class="{ 'workspace-shell--collapsed': sidebarCollapsed, 'workspace-shell--sidebar-focused': sidebarFocusMode, 'workspace-shell--conversations': applicationWorkspace }" :inert="settingsOpen || (applicationWorkspace && sidebarSearchOpen)">
+  <div class="workspace-shell" :class="{ 'workspace-shell--collapsed': sidebarCollapsed && !applicationWorkspace, 'workspace-shell--sidebar-focused': sidebarFocusMode, 'workspace-shell--conversations': applicationWorkspace, 'workspace-shell--conversation-open': applicationWorkspace && !!route.params.id }" :inert="settingsOpen || (applicationWorkspace && sidebarSearchOpen)">
     <header class="mobile-header" :inert="mobileDrawerOpen">
       <button ref="mobileNavigationTrigger" class="icon-button" type="button" aria-label="打开导航" @click="openMobileDrawer">
         <AppIcon name="menu" :size="20" />
       </button>
-      <button class="mobile-brand" type="button" aria-label="返回聊天" @click="navigate('/conversations')">
+      <button class="mobile-brand" type="button" aria-label="返回聊天" @click="navigate(applicationWorkspace ? '/conversations' : '/chat')">
         <BrandMark :size="28" compact />
       </button>
       <button class="icon-button" type="button" :aria-label="theme === 'dark' ? '切换浅色主题' : '切换深色主题'" @click="emit('toggleTheme')">
@@ -366,16 +372,23 @@ onBeforeUnmount(() => {
 
     <aside
       class="desktop-sidebar rail"
-      :class="{ 'desktop-sidebar--collapsed': sidebarCollapsed }"
+      :class="{ 'desktop-sidebar--collapsed': sidebarCollapsed && !applicationWorkspace }"
       aria-label="主导航"
       :inert="mobileDrawerOpen"
     >
-      <div class="sidebar-brand-row">
-        <button class="rail__brand sidebar-brand" type="button" aria-label="返回聊天" title="夭夭 Web" @click="navigate('/conversations')">
+      <div v-if="applicationWorkspace" class="bot-list-header">
+        <button class="bot-account-trigger" type="button" aria-label="设置" @click="openSettings('account-security', $event)"><AgentAvatar :name="userName" :size="40" /></button>
+        <span class="bot-list-header__spacer" />
+        <button class="bot-list-toolbar-button sidebar-search-trigger" type="button" aria-label="搜索" :aria-expanded="sidebarSearchOpen" @click="openSidebarSearch(desktopSidebarContext)"><AppIcon name="search" :size="21" /></button>
+        <button class="bot-list-toolbar-button sidebar-create-trigger" type="button" aria-label="新建" aria-haspopup="menu" :aria-expanded="createMenuOpen" @click="openCreateMenu"><AppIcon name="plus" :size="23" /></button>
+      </div>
+      <div v-else class="sidebar-brand-row">
+        <button class="rail__brand sidebar-brand" type="button" aria-label="返回聊天" title="夭夭 Web" @click="navigate(applicationWorkspace ? '/conversations' : '/chat')">
           <BrandMark :size="sidebarCollapsed ? 26 : 32" :label="false" compact bare />
         </button>
       </div>
       <button
+        v-if="!applicationWorkspace"
         class="sidebar-collapse"
         :class="{ 'sidebar-collapse--collapsed': sidebarCollapsed }"
         type="button"
@@ -386,10 +399,9 @@ onBeforeUnmount(() => {
         <AppIcon name="chevron-left" :size="18" />
       </button>
 
-      <button v-if="applicationWorkspace" class="sidebar-create-trigger" type="button" aria-label="新建" title="新建" aria-haspopup="menu" :aria-expanded="createMenuOpen" @click="openCreateMenu" @keydown.down.prevent="openCreateMenu"><AppIcon name="plus" :size="19" /></button>
 
       <button
-        v-if="!['files', 'kanban'].includes(activeNav.key)"
+        v-if="!applicationWorkspace && !['files', 'kanban'].includes(activeNav.key)"
         class="sidebar-search-trigger"
         :class="{ 'sidebar-search-trigger--searching': sidebarSearchOpen && !applicationWorkspace }"
         type="button"
@@ -441,7 +453,7 @@ onBeforeUnmount(() => {
         </div>
       </section>
 
-      <div class="sidebar-footer">
+      <div v-if="!applicationWorkspace" class="sidebar-footer">
         <div class="sidebar-account-switcher">
           <button class="sidebar-account-switcher__main" type="button" :title="applicationWorkspace ? '当前账号' : `切换 Agent:${profileTitle(activeProfile)}`" :aria-haspopup="applicationWorkspace ? undefined : 'listbox'" :aria-expanded="applicationWorkspace ? undefined : profileMenuOpen" @click="applicationWorkspace ? openSettings('account-security', $event) : toggleProfileMenu($event)">
             <AgentAvatar :name="applicationWorkspace ? userName : profileTitle(activeProfile)" :avatar="applicationWorkspace ? '' : activeProfile?.agentAvatar || ''" :size="30" />
@@ -487,7 +499,7 @@ onBeforeUnmount(() => {
       :inert="!mobileDrawerOpen"
     >
       <div class="mobile-drawer__header">
-        <button class="sidebar-brand" type="button" aria-label="返回聊天" @click="navigate('/conversations')">
+        <button class="sidebar-brand" type="button" aria-label="返回聊天" @click="navigate(applicationWorkspace ? '/conversations' : '/chat')">
           <BrandMark :size="32" compact />
         </button>
         <div class="mobile-drawer__actions"><button ref="mobileDrawerClose" class="icon-button" type="button" aria-label="关闭导航" @click="closeMobileDrawer">
@@ -496,7 +508,7 @@ onBeforeUnmount(() => {
       </div>
 
       <button
-        v-if="!['files', 'kanban'].includes(activeNav.key)"
+        v-if="!applicationWorkspace && !['files', 'kanban'].includes(activeNav.key)"
         class="sidebar-search-trigger"
         :class="{ 'sidebar-search-trigger--searching': sidebarSearchOpen && !applicationWorkspace }"
         type="button"
@@ -599,6 +611,8 @@ onBeforeUnmount(() => {
 
     <SettingsCenterDialog
       :open="settingsOpen"
+      :bot-mode="applicationWorkspace"
+      @switch-mode="switchInterfaceMode"
       :initial-page="settingsPage"
       :user-name="userName"
       :pairing-user-name="pairingUserName"
@@ -863,4 +877,18 @@ onBeforeUnmount(() => {
 .workspace-create-menu{position:absolute;width:180px;padding:5px;border:1px solid var(--line);border-radius:11px;background:var(--surface-raised);box-shadow:var(--shadow-float)}
 .workspace-create-menu button{display:flex;width:100%;min-height:36px;align-items:center;gap:9px;padding:7px 10px;border:0;border-radius:7px;background:transparent;color:var(--text-primary);font:13px var(--font-ui);text-align:left;cursor:pointer}
 .workspace-create-menu button:hover,.workspace-create-menu button:focus-visible{outline:0;background:var(--surface-hover)}
+
+.bot-list-header{display:flex;align-items:center;gap:10px;padding:20px 20px 12px;min-height:74px}
+.bot-list-header__spacer{flex:1}
+.bot-account-trigger{display:grid;place-items:center;padding:0;border:0;background:transparent;cursor:pointer;border-radius:50%}
+.bot-list-header .bot-list-toolbar-button{position:static;display:grid;place-items:center;width:42px;height:42px;min-height:42px;flex:0 0 42px;margin:0;padding:0;border:1px solid var(--line);border-radius:50%;background:var(--surface);color:var(--text-primary)}
+.workspace-shell--conversations .sidebar-context{margin-top:0}
+@media(max-width:900px){
+ .workspace-shell--conversations{grid-template-rows:minmax(0,1fr);grid-template-columns:minmax(0,1fr)}
+ .workspace-shell--conversations>.mobile-header,.workspace-shell--conversations>.mobile-drawer{display:none}
+ .workspace-shell--conversations>.desktop-sidebar{display:flex;grid-row:1;border:0;padding-top:env(safe-area-inset-top)}
+ .workspace-shell--conversations>.workspace-main{display:none;grid-row:1}
+ .workspace-shell--conversation-open>.desktop-sidebar{display:none}
+ .workspace-shell--conversation-open>.workspace-main{display:flex}
+}
 </style>

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

@@ -230,6 +230,7 @@ defineExpose({ scrollToMessage, scrollToAnchor, scrollToBottom, isFollowingBotto
 <template>
   <section class="timeline-frame" :class="{ 'timeline-frame--transparent': transparentHeader }">
     <header v-if="title || transparentHeader" class="timeline-header" :class="{ 'timeline-header--transparent': transparentHeader }">
+      <slot name="header-leading" />
       <div v-if="!transparentHeader"><h2>{{ title }}</h2><p v-if="subtitle">{{ subtitle }}</p></div>
       <div v-if="!transparentHeader" class="timeline-state" :title="connected ? (synced ? '实时连接与历史均已同步' : '实时连接已就绪,历史同步中') : '实时连接未就绪'">
         <span class="status-dot" :class="connected ? (synced ? 'status-dot--online' : 'status-dot--working') : ''" />

+ 8 - 2
src/client/components/workspace/ConversationList.vue

@@ -10,7 +10,7 @@ const menuPosition = ref({ left: '8px', top: '8px' })
 const menuConversation = computed(() => props.conversations.find(c => c.id === menuId.value))
 const rows = computed(() => props.conversations.filter(c => !c.archived).map(c => ({
   ...workspaceConversationItem(c, props.agents),
-  section: c.pinned ? '已置顶' : '',
+  section: '',
 })))
 function openMenu(id: string, event: MouseEvent) {
   menuId.value = id
@@ -24,7 +24,7 @@ function action(kind: 'pin' | 'archive') {
 </script>
 <template>
   <div class="conversation-list">
-    <ResourceSidebar :items="rows" :active-id="selected" :searchable="false" external-search
+    <ResourceSidebar :items="rows" :active-id="selected" :searchable="false" :avatar-size="44" external-search
       empty-title="还没有聊天" empty-description="创建 Agent,或选择成员新建群聊。"
       @select="emit('select', $event)" @more="openMenu" @context-menu="openMenu" />
     <Teleport to="body">
@@ -39,5 +39,11 @@ function action(kind: 'pin' | 'archive') {
 </template>
 <style scoped>
 .conversation-list{display:flex;flex:1;flex-direction:column;min-height:0}
+.conversation-list :deep(.sidebar-list){padding:0 12px 18px;scrollbar-gutter:auto}
+.conversation-list :deep(.sidebar-item){min-height:76px;padding:14px 8px;gap:16px;border-radius:12px}
+.conversation-list :deep(.sidebar-item__icon){width:44px;height:44px;flex-basis:44px}
+.conversation-list :deep(.sidebar-item__row strong){font-size:17px;font-weight:600;line-height:24px}
+.conversation-list :deep(.sidebar-item__row--secondary){font-size:15px;line-height:21px;margin-top:4px}
+.conversation-list :deep(.sidebar-item__row small){font-size:12px;color:var(--text-muted)}
 .conversation-menu-dismiss{position:fixed;inset:0;z-index:200}.conversation-actions{position:absolute;display:grid;min-width:155px;padding:5px;border:1px solid var(--line);border-radius:10px;background:var(--surface-raised);box-shadow:var(--shadow-float)}.conversation-actions button{padding:9px 12px;border:0;border-radius:7px;background:transparent;color:var(--text-primary);text-align:left;cursor:pointer;font-size:12px}.conversation-actions button:hover{background:var(--surface-hover)}
 </style>

+ 2 - 2
src/client/components/workspace/viewModels.ts

@@ -17,7 +17,7 @@ import type { UiAgent, UiRoom } from '@/components/groups/types'
 import type { UiLibraryItem } from '@/components/library/types'
 import type { UiInteraction, UiMessage, UiMessageAttachment, UiToolCall } from '@/components/messages/types'
 import { normalizeAssistantMediaMarkdown } from '@/utils/mediaMarkdown'
-import { formatMessageTime } from '@/utils/messageTime'
+import { formatMessageTime, formatConversationTime } from '@/utils/messageTime'
 
 function record(value: unknown): Record<string, unknown> {
   return value && typeof value === 'object' && !Array.isArray(value) ? value as Record<string, unknown> : {}
@@ -412,7 +412,7 @@ export function workspaceConversationItem(c: import('@shared/workspace').Workspa
     id: c.id, title: c.name, subtitle: c.preview || '开始聊天', pinned: c.pinned,
     section: c.pinned ? '已置顶' : '聊天', avatar: c.kind === 'group' ? '' : c.avatar,
     avatarMembers: c.kind === 'group' ? workspaceAvatarMembers(c.memberIds, agents, c) : [],
-    meta: formatMessageTime(c.lastMessageAt ?? c.createdAt),
+    meta: formatConversationTime(c.lastMessageAt ?? c.createdAt),
     avatarKind: c.kind === 'direct' ? 'agent' : 'team',
     avatarState: workspaceAvatarState(c, c.memberIds[0] || ''),
     unread: c.unreadCount ?? Math.max(0, c.lastSeq - c.readSeq), status: c.activeRunId ? 'working' : undefined,

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

@@ -1,9 +1,10 @@
 import { createRouter, createWebHistory } from 'vue-router'
+import { rememberInterfacePath, savedInterfacePath } from '@/utils/interfaceMode'
 
 const router = createRouter({
   history: createWebHistory(),
   routes: [
-    { path: '/', redirect: '/conversations' },
+    { path: '/', redirect: () => savedInterfacePath() },
     { path: '/chat/:sessionId?', name: 'chat', component: () => import('@/views/ChatView.vue') },
     { path: '/conversations/:id?', name: 'conversations', component: () => import('@/views/ConversationsView.vue') },
     { path: '/kanban/:boardSlug?', name: 'kanban', component: () => import('@/views/KanbanView.vue') },
@@ -13,5 +14,6 @@ const router = createRouter({
   ],
   scrollBehavior: () => ({ top: 0 }),
 })
+router.afterEach(to => rememberInterfacePath(to.path))
 
 export default router

+ 14 - 0
src/client/utils/interfaceMode.ts

@@ -0,0 +1,14 @@
+export type InterfaceMode = 'chat' | 'bots'
+const KEY = 'hermes-yaoyao:interface-mode'
+
+export function savedInterfacePath(): '/chat' | '/conversations' {
+  try { return localStorage.getItem(KEY) === 'bots' ? '/conversations' : '/chat' }
+  catch { return '/chat' }
+}
+
+export function rememberInterfacePath(path: string): void {
+  const mode: InterfaceMode | undefined = /^\/conversations(?:\/|$)/.test(path) ? 'bots'
+    : /^\/chat(?:\/|$)/.test(path) ? 'chat' : undefined
+  if (!mode) return
+  try { localStorage.setItem(KEY, mode) } catch { /* storage may be unavailable */ }
+}

+ 12 - 0
src/client/utils/messageTime.ts

@@ -16,3 +16,15 @@ export function formatMessageTime(value: string | number | Date | undefined, now
   const dateLabel = `${date.getMonth() + 1}月${date.getDate()}日`
   return date.getFullYear() === now.getFullYear() ? `${dateLabel} ${time}` : `${date.getFullYear()}年${dateLabel} ${time}`
 }
+
+/** Compact list timestamp, matching the native Bot roster. */
+export function formatConversationTime(value: string | number | Date | undefined, now = new Date()): string {
+  if (!value) return ''
+  const date = value instanceof Date ? value : new Date(value)
+  if (Number.isNaN(date.getTime())) return ''
+  const days = Math.round((calendarStart(now) - calendarStart(date)) / 86_400_000)
+  if (days === 0) return new Intl.DateTimeFormat('zh-CN', { hour: '2-digit', minute: '2-digit', hour12: false }).format(date)
+  if (days === 1) return '昨天'
+  if (days >= 2 && days < 7) return `星期${'日一二三四五六'[date.getDay()]}`
+  return `${date.getFullYear() === now.getFullYear() ? '' : date.getFullYear() + '年'}${date.getMonth() + 1}月${date.getDate()}日`
+}

+ 3 - 1
src/client/views/ConversationsView.vue

@@ -514,7 +514,7 @@ onBeforeUnmount(() => {
       ><ConversationList :conversations="conversations" :agents="agents" :selected="selected" @select="select" @pin="action('pin', $event)" @archive="action('archive', $event)"
     /></template>
     <section class="workspace-chat" aria-label="聊天">
-      <MessageTimeline ref="timeline" :messages="uiMessages" :title="active?.name || '团队'"
+      <MessageTimeline ref="timeline" :messages="uiMessages" :title="active?.name || 'Bot 模式'"
         :subtitle="active?.kind === 'group' ? `${members.length} 位成员` : ''"
         :loading="loading" :has-older="older && !!active" :connected="!error" :synced="!loading"
         :show-tools="showThinking" :allow-branch="false" :thinking="!!active?.activeRunId"
@@ -527,6 +527,7 @@ onBeforeUnmount(() => {
         @load-older="loadOlder" @quote="quoted = $event" @preview="openPreview" @preview-file="openPreview"
         @approve="interactions[0] && respond(interactions[0], $event ? 'once' : 'deny')"
         @clarify="interactions[0] && respond(interactions[0], $event)">
+        <template #header-leading><button v-if="active" class="workspace-list-back icon-button" aria-label="返回 Bot 列表" @click="router.push('/conversations')"><AppIcon name="chevron-left" /></button></template>
         <template #header-actions>
           <div v-if="active" class="header-actions">
             <button class="icon-button" :aria-label="active.pinned ? '取消置顶' : '置顶聊天'" :title="active.pinned ? '取消置顶' : '置顶聊天'" @click="action('pin')"><AppIcon :name="active.pinned ? 'pin-off' : 'pin'" /></button>
@@ -723,4 +724,5 @@ onBeforeUnmount(() => {
 .status {
   margin-top: 8px;
 }
+.workspace-list-back{display:none}@media(max-width:900px){.workspace-list-back{display:grid}}
 </style>

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

@@ -0,0 +1,23 @@
+import { beforeEach, describe, expect, it } from 'vitest'
+import { rememberInterfacePath, savedInterfacePath } from '@/utils/interfaceMode'
+
+beforeEach(() => localStorage.clear())
+
+describe('last interface mode', () => {
+  it('restores Bot mode and chat mode after explicit navigation', () => {
+    expect(savedInterfacePath()).toBe('/chat')
+    rememberInterfacePath('/conversations/existing-group')
+    expect(savedInterfacePath()).toBe('/conversations')
+    rememberInterfacePath('/files')
+    expect(savedInterfacePath()).toBe('/conversations')
+    rememberInterfacePath('/chat/existing-session')
+    expect(savedInterfacePath()).toBe('/chat')
+  })
+
+  it('does not mistake unrelated paths or invalid storage for a mode', () => {
+    localStorage.setItem('hermes-yaoyao:interface-mode', 'invalid')
+    expect(savedInterfacePath()).toBe('/chat')
+    rememberInterfacePath('/conversations-other')
+    expect(savedInterfacePath()).toBe('/chat')
+  })
+})

+ 4 - 7
tests/client/workspaceShell.test.ts

@@ -144,17 +144,16 @@ describe('Workspace shell account controls', () => {
   })
 })
 
-it('keeps the conversation sidebar focused with a two-item create menu and modal search while collapsed', async () => {
+it('uses the reference Bot list header and keeps mode changes in settings', async () => {
   const wrapper = await mountShell('/conversations')
   const rail = wrapper.get('.desktop-sidebar')
   expect(rail.find('.sidebar-feature-nav').exists()).toBe(false)
   expect(rail.find('.sidebar-primary-action').exists()).toBe(false)
-  expect(rail.find('.sidebar-footer').exists()).toBe(true)
+  expect(rail.find('.sidebar-footer').exists()).toBe(false)
+  expect(rail.find('.sidebar-collapse').exists()).toBe(false)
   expect(rail.find('.sidebar-context__heading').exists()).toBe(false)
-  expect(wrapper.find('.mobile-drawer .sidebar-context__heading').exists()).toBe(false)
-  await rail.get('.sidebar-account-switcher__main').trigger('click')
+  await rail.get('.bot-account-trigger').trigger('click')
   expect(wrapper.get('[data-testid="settings-center"]').attributes('data-page')).toBe('account-security')
-  expect(document.querySelector('.workspace-settings-menu')).toBeNull()
   await wrapper.get('[data-testid="close-settings"]').trigger('click')
   await rail.get('.sidebar-create-trigger').trigger('click')
   const items = document.querySelectorAll<HTMLButtonElement>('.workspace-create-menu [role="menuitem"]')
@@ -162,9 +161,7 @@ it('keeps the conversation sidebar focused with a two-item create menu and modal
   items[0]?.click()
   await wrapper.vm.$nextTick()
   expect(wrapper.emitted('createAgent')).toHaveLength(1)
-  await rail.get('.sidebar-collapse').trigger('click')
   await rail.get('.sidebar-search-trigger').trigger('click')
-  expect(wrapper.classes()).toContain('workspace-shell--collapsed')
   document.dispatchEvent(new CustomEvent('hermes-yaoyao:sidebar-search-close'))
   await wrapper.vm.$nextTick()
   wrapper.unmount()

+ 22 - 18
tests/e2e/workspace-chat.spec.ts

@@ -39,23 +39,13 @@ test('created roles and editable teams share a durable chat list without plugin
   await expect(rail.locator('.sidebar-context__heading')).toHaveCount(0)
   await expect(rail.getByRole('checkbox', { name: '显示已归档' })).toHaveCount(0)
   const plus = rail.getByRole('button', { name: '新建', exact: true })
-  const collapse = rail.getByRole('button', { name: '折叠侧边栏', exact: true })
-  expect((await plus.boundingBox())!.x).toBeGreaterThan((await collapse.boundingBox())!.x)
-  await collapse.click()
-  await expect(rail).toHaveClass(/desktop-sidebar--collapsed/)
-  const logoBox = (await rail.locator('.sidebar-brand').boundingBox())!
-  const plusBox = (await plus.boundingBox())!
-  expect(plusBox.y).toBeGreaterThanOrEqual(logoBox.y + logoBox.height)
+  await expect(rail.locator('.sidebar-collapse')).toHaveCount(0)
   await plus.click()
   await expect(page.getByRole('menu', { name: '新建聊天' }).getByRole('menuitem')).toHaveCount(2)
-  await page.screenshot({ path: testInfo.outputPath('collapsed-create-menu.png'), fullPage: true })
   await page.keyboard.press('Escape')
   await rail.getByRole('button', { name: '搜索', exact: true }).click()
   await expect(page.getByRole('dialog', { name: '搜索聊天' })).toBeVisible()
-  await expect(rail).toHaveClass(/desktop-sidebar--collapsed/)
   await page.keyboard.press('Escape')
-  await expect(rail.getByRole('button', { name: '搜索', exact: true })).toBeFocused()
-  await rail.getByRole('button', { name: '展开侧边栏', exact: true }).click()
   await page.goto('/chat')
   await expect(rail).toHaveCSS('width', '264px')
   await expect(rail.getByRole('button', { name: '聊天', exact: true })).toHaveCount(0)
@@ -120,7 +110,7 @@ test('created roles and editable teams share a durable chat list without plugin
       .toBe(await direct.locator('.agent-avatar__body > :first-child').evaluate(el => el.tagName))
   }
   await expect(page.locator('.desktop-sidebar')).not.toContainText('话题')
-  await expect(page.locator('.desktop-sidebar .sidebar-account-switcher')).not.toContainText(
+  await expect(page.locator('.desktop-sidebar .bot-account-trigger')).not.toContainText(
     '通用助手',
   )
   await page.getByRole('button', { name: '聊天设置', exact: true }).click()
@@ -230,16 +220,30 @@ test('created roles and editable teams share a durable chat list without plugin
   await page.request.post('http://127.0.0.1:19120/__release')
   await expect.poll(async () => (await (await page.request.get(`/api/app/conversations/${detail.conversation.id}`)).json()).conversation.activeRunId).toBeUndefined()
   await page.setViewportSize({ width: 390, height: 844 })
-  await page.getByRole('button', { name: '打开导航', exact: true }).click()
-  const mobile = page.locator('.mobile-drawer')
-  await expect(mobile.locator('.sidebar-feature-nav')).toHaveCount(0)
-  await mobile.getByRole('button', { name: '新建', exact: true }).click()
+  await page.getByRole('button', { name: '返回 Bot 列表', exact: true }).click()
+  await expect(rail).toBeVisible()
+  await expect(rail).toHaveCSS('width', '390px')
+  await expect(page.locator('.mobile-header')).toBeHidden()
+  await expect(rail.locator('.sidebar-feature-nav')).toHaveCount(0)
+  await page.screenshot({ path: testInfo.outputPath('mobile-bot-list.png') })
+  await rail.getByRole('button', { name: '新建', exact: true }).click()
   await expect(page.getByRole('menuitem', { name: '新建 Bot', exact: true })).toBeVisible()
   await page.keyboard.press('Escape')
-  await mobile.getByRole('button', { name: '搜索', exact: true }).click()
+  await rail.getByRole('button', { name: '搜索', exact: true }).click()
   await expect(page.getByRole('dialog', { name: '搜索聊天' })).toBeVisible()
   await expect(page.getByRole('tab', { name: '未归档', exact: true })).toBeVisible()
-  await page.screenshot({ path: testInfo.outputPath('mobile-chat-search.png'), fullPage: true })
   await page.getByRole('button', { name: '关闭搜索', exact: true }).click()
+  await rail.getByRole('button', { name: '设置', exact: true }).click()
+  await page.getByRole('button', { name: '进入聊天模式', exact: true }).click()
+  await expect(page).toHaveURL(/\/chat$/)
+  await page.goto('/')
+  await expect(page).toHaveURL(/\/chat$/)
+  await page.setViewportSize({ width: 1280, height: 800 })
+  await page.getByRole('button', { name: '设置与模式', exact: true }).click()
+  await page.getByRole('menuitem', { name: '进入 Bot 模式', exact: true }).click()
+  await page.goto('/')
+  await expect(page).toHaveURL(/\/conversations$/)
+  await page.reload()
+  await expect(rail.locator('.bot-list-header')).toBeVisible()
   expect(pluginCalls).toEqual([])
 })