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

Add topic support to group chat navigation and messaging

samien 2 месяцев назад
Родитель
Сommit
2fa2ccb864

+ 19 - 7
src/client/api/groups.ts

@@ -1,10 +1,10 @@
 import type {
 import type {
-  GroupAgent, GroupCapabilities, GroupInteraction, GroupMessagePage, GroupRoomDetail, GroupRoomPage, JsonValue, UploadReference,
+  GroupAgent, GroupCapabilities, GroupInteraction, GroupMessagePage, GroupRoomDetail, GroupRoomPage, GroupRun, GroupTopicPage, JsonValue, UploadReference,
 } from '@shared/types'
 } from '@shared/types'
 import { apiRequest, apiUrl, unwrapData } from './client'
 import { apiRequest, apiUrl, unwrapData } from './client'
 import {
 import {
   normalizeGroupAgent, normalizeGroupCapabilities, normalizeGroupInteraction, normalizeGroupMessage,
   normalizeGroupAgent, normalizeGroupCapabilities, normalizeGroupInteraction, normalizeGroupMessage,
-  normalizeGroupRoom, normalizeGroupRoomDetail, record, values,
+  normalizeGroupRoom, normalizeGroupRoomDetail, normalizeGroupRun, normalizeGroupTopic, record, values,
 } from '@/utils/normalize'
 } from '@/utils/normalize'
 import { createUuid } from '@/utils/id'
 import { createUuid } from '@/utils/id'
 
 
@@ -29,6 +29,17 @@ export async function getGroupRoom(roomId: string): Promise<GroupRoomDetail> {
   return normalizeGroupRoomDetail(unwrapData(await apiRequest<unknown>(`${BASE}/rooms/${encodeURIComponent(roomId)}`)))
   return normalizeGroupRoomDetail(unwrapData(await apiRequest<unknown>(`${BASE}/rooms/${encodeURIComponent(roomId)}`)))
 }
 }
 
 
+export async function getGroupTopics(roomId: string, cursor?: string, limit = 50): Promise<GroupTopicPage> {
+  const payload = unwrapData(await apiRequest<unknown>(apiUrl(`${BASE}/rooms/${encodeURIComponent(roomId)}/topics`, {
+    cursor, limit: Math.max(1, Math.min(100, limit)),
+  })))
+  const source = record(payload)
+  return {
+    items: values(source.items ?? source.topics ?? payload).map(normalizeGroupTopic).filter(topic => topic.id && topic.roomId),
+    nextCursor: typeof source.nextCursor === 'string' ? source.nextCursor : typeof source.next_cursor === 'string' ? source.next_cursor : null,
+  }
+}
+
 export interface AgentSeed {
 export interface AgentSeed {
   profile: string
   profile: string
   displayName: string
   displayName: string
@@ -82,10 +93,10 @@ export async function interruptGroupAgent(roomId: string, agentId: string): Prom
   }))
   }))
 }
 }
 
 
-export async function getGroupMessages(roomId: string, options: { beforeSeq?: number; afterSeq?: number; limit?: number } = {}): Promise<GroupMessagePage> {
+export async function getGroupMessages(roomId: string, options: { topicId?: string; beforeSeq?: number; afterSeq?: number; limit?: number } = {}): Promise<GroupMessagePage> {
   if (options.beforeSeq && options.afterSeq) throw new Error('beforeSeq 与 afterSeq 不能同时使用')
   if (options.beforeSeq && options.afterSeq) throw new Error('beforeSeq 与 afterSeq 不能同时使用')
   const payload = unwrapData(await apiRequest<unknown>(apiUrl(`${BASE}/rooms/${encodeURIComponent(roomId)}/messages`, {
   const payload = unwrapData(await apiRequest<unknown>(apiUrl(`${BASE}/rooms/${encodeURIComponent(roomId)}/messages`, {
-    beforeSeq: options.beforeSeq, afterSeq: options.afterSeq, limit: Math.max(1, Math.min(100, options.limit ?? 100)),
+    topicId: options.topicId, beforeSeq: options.beforeSeq, afterSeq: options.afterSeq, limit: Math.max(1, Math.min(100, options.limit ?? 100)),
   })))
   })))
   const source = record(payload)
   const source = record(payload)
   return { items: values(source.items ?? source.messages ?? payload).map(normalizeGroupMessage).filter(message => message.id) }
   return { items: values(source.items ?? source.messages ?? payload).map(normalizeGroupMessage).filter(message => message.id) }
@@ -96,13 +107,14 @@ export async function sendGroupMessage(
   content: string,
   content: string,
   mentionAgentIds: string[],
   mentionAgentIds: string[],
   clientMessageId: string,
   clientMessageId: string,
+  topicId?: string,
   uploadIds: string[] = [],
   uploadIds: string[] = [],
   stableRequestId = requestId(),
   stableRequestId = requestId(),
-): Promise<{ message: ReturnType<typeof normalizeGroupMessage>; runs: unknown[] }> {
+): Promise<{ message: ReturnType<typeof normalizeGroupMessage>; runs: GroupRun[] }> {
   const payload = record(unwrapData(await apiRequest<unknown>(`${BASE}/rooms/${encodeURIComponent(roomId)}/messages`, {
   const payload = record(unwrapData(await apiRequest<unknown>(`${BASE}/rooms/${encodeURIComponent(roomId)}/messages`, {
-    method: 'POST', body: { requestId: stableRequestId, clientMessageId, content, mentionAgentIds, uploadIds } as JsonValue,
+    method: 'POST', body: { requestId: stableRequestId, clientMessageId, topicId, content, mentionAgentIds, uploadIds } as JsonValue,
   })))
   })))
-  return { message: normalizeGroupMessage(payload.message), runs: values(payload.runs) }
+  return { message: normalizeGroupMessage(payload.message), runs: values(payload.runs).map(normalizeGroupRun).filter(run => run.id) }
 }
 }
 
 
 export async function approveGroupInteraction(roomId: string, interactionId: string, choice: 'once' | 'session' | 'always' | 'deny'): Promise<GroupInteraction | unknown> {
 export async function approveGroupInteraction(roomId: string, interactionId: string, choice: 'once' | 'session' | 'always' | 'deny'): Promise<GroupInteraction | unknown> {

+ 23 - 3
src/client/components/app/ResourceSidebar.vue

@@ -38,13 +38,14 @@ const emit = defineEmits<{
   search: [value: string]
   search: [value: string]
   more: [id: string, event: MouseEvent]
   more: [id: string, event: MouseEvent]
   contextMenu: [id: string, event: MouseEvent]
   contextMenu: [id: string, event: MouseEvent]
+  toggle: [id: string]
   loadMore: []
   loadMore: []
 }>()
 }>()
 
 
 const searchInput = ref<HTMLInputElement | null>(null)
 const searchInput = ref<HTMLInputElement | null>(null)
 const searchOpen = ref(Boolean(props.search))
 const searchOpen = ref(Boolean(props.search))
 const sectionCounts = computed(() => props.items.reduce<Record<string, number>>((counts, item) => {
 const sectionCounts = computed(() => props.items.reduce<Record<string, number>>((counts, item) => {
-  if (item.section) counts[item.section] = (counts[item.section] ?? 0) + 1
+  if (item.section && !item.nested) counts[item.section] = (counts[item.section] ?? 0) + 1
   return counts
   return counts
 }, {}))
 }, {}))
 const sidebarRows = computed(() => props.items.map((item, index) => ({
 const sidebarRows = computed(() => props.items.map((item, index) => ({
@@ -125,15 +126,27 @@ defineExpose({ focusSearch })
           <div v-if="row.section" class="sidebar-section-label" :class="{ 'sidebar-section-label--pinned': row.section === '已置顶' }" role="presentation"><span>{{ row.sectionLabel }}</span></div>
           <div v-if="row.section" class="sidebar-section-label" :class="{ 'sidebar-section-label--pinned': row.section === '已置顶' }" role="presentation"><span>{{ row.sectionLabel }}</span></div>
           <div
           <div
             class="sidebar-item"
             class="sidebar-item"
-            :class="{ active: row.item.id === activeId || row.item.active, 'sidebar-item--single-line': singleLine }"
+            :class="{ active: row.item.id === activeId || row.item.active, 'sidebar-item--single-line': singleLine, 'sidebar-item--nested': row.item.nested, 'sidebar-item--expandable': row.item.expandable }"
             role="option"
             role="option"
             tabindex="0"
             tabindex="0"
             :aria-selected="row.item.id === activeId || row.item.active"
             :aria-selected="row.item.id === activeId || row.item.active"
+            :data-sidebar-id="row.item.id"
             @click="emit('select', row.item.id)"
             @click="emit('select', row.item.id)"
             @contextmenu.prevent.stop="emit('contextMenu', row.item.id, $event)"
             @contextmenu.prevent.stop="emit('contextMenu', row.item.id, $event)"
             @keydown.enter.prevent="emit('select', row.item.id)"
             @keydown.enter.prevent="emit('select', row.item.id)"
             @keydown.space.prevent="emit('select', row.item.id)"
             @keydown.space.prevent="emit('select', row.item.id)"
           >
           >
+            <button
+              v-if="row.item.expandable"
+              class="sidebar-item__expand"
+              :class="{ 'sidebar-item__expand--collapsed': !row.item.expanded }"
+              type="button"
+              :aria-label="`${row.item.expanded ? '收起' : '展开'} ${row.item.title} 的话题`"
+              :aria-expanded="row.item.expanded"
+              @click.stop="emit('toggle', row.item.id)"
+            >
+              <AppIcon name="chevron-down" :size="13" />
+            </button>
             <span v-if="!singleLine" class="sidebar-item__icon" :class="{ 'sidebar-item__icon--avatar': !row.item.icon }">
             <span v-if="!singleLine" class="sidebar-item__icon" :class="{ 'sidebar-item__icon--avatar': !row.item.icon }">
               <AppIcon v-if="row.item.icon" :name="row.item.icon" :size="15" />
               <AppIcon v-if="row.item.icon" :name="row.item.icon" :size="15" />
               <template v-else>{{ row.item.title.slice(0, 1).toUpperCase() }}</template>
               <template v-else>{{ row.item.title.slice(0, 1).toUpperCase() }}</template>
@@ -149,7 +162,7 @@ defineExpose({ focusSearch })
                 <b v-if="row.item.unread">{{ row.item.unread > 99 ? '99+' : row.item.unread }}</b>
                 <b v-if="row.item.unread">{{ row.item.unread > 99 ? '99+' : row.item.unread }}</b>
               </span>
               </span>
             </span>
             </span>
-            <button class="sidebar-item__more" type="button" aria-label="更多操作" @click.stop="emit('more', row.item.id, $event)">
+            <button v-if="row.item.showMore !== false" class="sidebar-item__more" type="button" aria-label="更多操作" @click.stop="emit('more', row.item.id, $event)">
               <AppIcon name="dots" :size="16" />
               <AppIcon name="dots" :size="16" />
             </button>
             </button>
           </div>
           </div>
@@ -188,6 +201,13 @@ defineExpose({ focusSearch })
 .sidebar-item--single-line .sidebar-item__row { min-height: 27px; }
 .sidebar-item--single-line .sidebar-item__row { min-height: 27px; }
 .sidebar-item--single-line .sidebar-item__row strong { font-size: 11.5px; font-weight: 450; }
 .sidebar-item--single-line .sidebar-item__row strong { font-size: 11.5px; font-weight: 450; }
 .sidebar-item--single-line .sidebar-item__more { top: 1px; }
 .sidebar-item--single-line .sidebar-item__more { top: 1px; }
+.sidebar-item--nested { min-height: 32px; margin-left: 17px; width: calc(100% - 17px); padding-block: 1px; }
+.sidebar-item--nested .sidebar-item__icon { width: 19px; height: 19px; flex-basis: 19px; border-radius: 6px; color: var(--text-muted); }
+.sidebar-item--nested .sidebar-item__row { min-height: 25px; }
+.sidebar-item--nested .sidebar-item__row strong { font-size: 10.5px; font-weight: 500; }
+.sidebar-item--nested .sidebar-item__row--secondary { display: none; }
+.sidebar-item--nested .sidebar-item__more { display: none; }
+.sidebar-item__expand { display: grid; width: 18px; height: 24px; flex: 0 0 18px; place-items: center; padding: 0; border: 0; border-radius: 5px; background: transparent; color: var(--text-muted); cursor: pointer; }.sidebar-item__expand:hover, .sidebar-item__expand:focus-visible { outline: 0; background: var(--surface-hover); color: var(--text-primary); }.sidebar-item__expand :deep(.app-icon) { transition: transform 120ms ease; }.sidebar-item__expand--collapsed :deep(.app-icon) { transform: rotate(-90deg); }
 .sidebar-item__icon { position: relative; display: grid; place-items: center; width: 23px; height: 23px; flex: 0 0 23px; border: 0; border-radius: 7px; color: var(--text-muted); background: transparent; }
 .sidebar-item__icon { position: relative; display: grid; place-items: center; width: 23px; height: 23px; flex: 0 0 23px; border: 0; border-radius: 7px; color: var(--text-muted); background: transparent; }
 .sidebar-item.active .sidebar-item__icon, .sidebar-item:hover .sidebar-item__icon { color: var(--text-secondary); }
 .sidebar-item.active .sidebar-item__icon, .sidebar-item:hover .sidebar-item__icon { color: var(--text-secondary); }
 .sidebar-item__icon--avatar { background: var(--surface-hover); color: var(--text-secondary); font-size: 9px; font-weight: 700; }
 .sidebar-item__icon--avatar { background: var(--surface-hover); color: var(--text-secondary); font-size: 9px; font-weight: 700; }

+ 5 - 1
src/client/components/app/types.ts

@@ -7,6 +7,10 @@ export type SidebarItem = {
   active?: boolean
   active?: boolean
   unread?: number
   unread?: number
   pinned?: boolean
   pinned?: boolean
+  nested?: boolean
+  showMore?: boolean
+  expandable?: boolean
+  expanded?: boolean
   status?: 'online' | 'working' | 'offline'
   status?: 'online' | 'working' | 'offline'
-  icon?: 'chat' | 'groups' | 'file' | 'image' | 'video' | 'audio' | 'link' | 'artifacts'
+  icon?: 'chat' | 'groups' | 'file' | 'image' | 'video' | 'audio' | 'link' | 'artifacts' | 'branch'
 }
 }

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

@@ -5,7 +5,7 @@ const router = createRouter({
   routes: [
   routes: [
     { path: '/', redirect: '/chat' },
     { path: '/', redirect: '/chat' },
     { path: '/chat/:sessionId?', name: 'chat', component: () => import('@/views/ChatView.vue') },
     { path: '/chat/:sessionId?', name: 'chat', component: () => import('@/views/ChatView.vue') },
-    { path: '/groups/:roomId?', name: 'groups', component: () => import('@/views/GroupsView.vue') },
+    { path: '/groups/:roomId?/:topicId?', name: 'groups', component: () => import('@/views/GroupsView.vue') },
     { path: '/files', name: 'files', component: () => import('@/views/FilesView.vue') },
     { path: '/files', name: 'files', component: () => import('@/views/FilesView.vue') },
     { path: '/artifacts', redirect: '/chat' },
     { path: '/artifacts', redirect: '/chat' },
     { path: '/:pathMatch(.*)*', redirect: '/chat' },
     { path: '/:pathMatch(.*)*', redirect: '/chat' },

+ 247 - 38
src/client/stores/groups.ts

@@ -2,19 +2,19 @@ import { computed, ref, shallowRef, watch } from 'vue'
 import { defineStore } from 'pinia'
 import { defineStore } from 'pinia'
 import { isSupportedGroupProtocolVersion, SUPPORTED_GROUP_PROTOCOL_VERSION_LABEL } from '@shared/types'
 import { isSupportedGroupProtocolVersion, SUPPORTED_GROUP_PROTOCOL_VERSION_LABEL } from '@shared/types'
 import type {
 import type {
-  GroupAgent, GroupCapabilities, GroupMessage, GroupMessagePage, GroupRoomDetail, GroupRoomSummary, GroupSocketEnvelope, RealtimeConnectionState,
+  GroupAgent, GroupCapabilities, GroupMessage, GroupMessagePage, GroupRoomDetail, GroupRoomSummary, GroupRun, GroupSocketEnvelope, GroupTopicSummary, RealtimeConnectionState,
 } from '@shared/types'
 } from '@shared/types'
 import {
 import {
   addGroupAgent, approveGroupInteraction as approveApi, archiveGroupRoom as archiveApi,
   addGroupAgent, approveGroupInteraction as approveApi, archiveGroupRoom as archiveApi,
   clarifyGroupInteraction as clarifyApi, createGroupRoom as createApi, getGroupCapabilities,
   clarifyGroupInteraction as clarifyApi, createGroupRoom as createApi, getGroupCapabilities,
-  getGroupMessages, getGroupRoom, getGroupRooms, interruptGroupAgent as interruptApi,
+  getGroupMessages, getGroupRoom, getGroupRooms, getGroupTopics, interruptGroupAgent as interruptApi,
   removeGroupAgent as removeAgentApi, sendGroupMessage as sendApi, updateGroupAgent as updateAgentApi,
   removeGroupAgent as removeAgentApi, sendGroupMessage as sendApi, updateGroupAgent as updateAgentApi,
   updateGroupRoom as updateRoomApi, uploadGroupFiles, type AgentSeed,
   updateGroupRoom as updateRoomApi, uploadGroupFiles, type AgentSeed,
 } from '@/api/groups'
 } from '@/api/groups'
 import { GroupEventSocket } from '@/api/realtime'
 import { GroupEventSocket } from '@/api/realtime'
 import { ScopedCache } from '@/utils/cache'
 import { ScopedCache } from '@/utils/cache'
 import {
 import {
-  applyGroupEnvelope, snapshotGroupRoom, upsertGroupMessage, type GroupProtocolState,
+  applyGroupEnvelope, snapshotGroupRoom, upsertGroupMessage, upsertGroupTopic, type GroupProtocolState,
 } from '@/utils/groupProtocol'
 } from '@/utils/groupProtocol'
 import { createUuid } from '@/utils/id'
 import { createUuid } from '@/utils/id'
 import { ApiError } from '@/api/client'
 import { ApiError } from '@/api/client'
@@ -23,25 +23,30 @@ import { useAuthStore } from './auth'
 type GroupAvailability = 'checking' | 'available' | 'unsupported' | 'unavailable'
 type GroupAvailability = 'checking' | 'available' | 'unsupported' | 'unavailable'
 
 
 interface CachedGroups { state: GroupProtocolState; savedAt: number }
 interface CachedGroups { state: GroupProtocolState; savedAt: number }
-const cache = new ScopedCache<CachedGroups>('groups-v2')
+const cache = new ScopedCache<CachedGroups>('groups-v3')
 
 
 function initialProtocol(): GroupProtocolState {
 function initialProtocol(): GroupProtocolState {
-  return { epoch: '', cursor: 0, rooms: [], roomDetails: {}, messagesByRoom: {} }
+  return { epoch: '', cursor: 0, rooms: [], roomDetails: {}, messagesByRoom: {}, topicsByRoom: {}, messagesByTopic: {} }
 }
 }
 
 
 function errorMessage(cause: unknown): string { return cause instanceof Error ? cause.message : '群聊请求失败' }
 function errorMessage(cause: unknown): string { return cause instanceof Error ? cause.message : '群聊请求失败' }
 
 
+function topicTitle(content: string): string {
+  return content.split(/\s+/).filter(Boolean).join(' ').slice(0, 100) || '新话题'
+}
+
 export const useGroupsStore = defineStore('groups', () => {
 export const useGroupsStore = defineStore('groups', () => {
   const auth = useAuthStore()
   const auth = useAuthStore()
   const availability = ref<GroupAvailability>('checking')
   const availability = ref<GroupAvailability>('checking')
   const capabilities = ref<GroupCapabilities>()
   const capabilities = ref<GroupCapabilities>()
   const protocol = shallowRef<GroupProtocolState>(initialProtocol())
   const protocol = shallowRef<GroupProtocolState>(initialProtocol())
   const selectedRoomId = ref<string>()
   const selectedRoomId = ref<string>()
+  const selectedTopicId = ref<string>()
   const connectionState = ref<RealtimeConnectionState>('idle')
   const connectionState = ref<RealtimeConnectionState>('idle')
   const isLoading = ref(false)
   const isLoading = ref(false)
   const isSending = ref(false)
   const isSending = ref(false)
   const error = ref<string>()
   const error = ref<string>()
-  const hasOlderByRoom = ref<Record<string, boolean>>({})
+  const hasOlderByConversation = ref<Record<string, boolean>>({})
   const socket = new GroupEventSocket()
   const socket = new GroupEventSocket()
   let generation = 0
   let generation = 0
   let rebuildPromise: Promise<void> | undefined
   let rebuildPromise: Promise<void> | undefined
@@ -54,10 +59,17 @@ export const useGroupsStore = defineStore('groups', () => {
 
 
   const rooms = computed(() => protocol.value.rooms)
   const rooms = computed(() => protocol.value.rooms)
   const selectedRoom = computed(() => selectedRoomId.value ? protocol.value.roomDetails[selectedRoomId.value] : undefined)
   const selectedRoom = computed(() => selectedRoomId.value ? protocol.value.roomDetails[selectedRoomId.value] : undefined)
-  const messages = computed(() => selectedRoomId.value ? protocol.value.messagesByRoom[selectedRoomId.value] ?? [] : [])
+  const topicProtocol = computed(() => (capabilities.value?.protocolVersion ?? 0) >= 4)
+  const topics = computed(() => selectedRoomId.value ? protocol.value.topicsByRoom[selectedRoomId.value] ?? [] : [])
+  const selectedTopic = computed(() => selectedTopicId.value ? topics.value.find(topic => topic.id === selectedTopicId.value) : undefined)
+  const messages = computed(() => topicProtocol.value
+    ? (selectedTopicId.value ? protocol.value.messagesByTopic[selectedTopicId.value] ?? [] : [])
+    : (selectedRoomId.value ? protocol.value.messagesByRoom[selectedRoomId.value] ?? [] : []))
   const agents = computed(() => selectedRoom.value?.agents ?? [])
   const agents = computed(() => selectedRoom.value?.agents ?? [])
-  const pendingInteractions = computed(() => selectedRoom.value?.pendingInteractions ?? [])
-  const hasMoreBefore = computed(() => selectedRoomId.value ? hasOlderByRoom.value[selectedRoomId.value] ?? false : false)
+  const pendingInteractions = computed(() => (selectedRoom.value?.pendingInteractions ?? []).filter(interaction =>
+    !topicProtocol.value || !interaction.topicId || interaction.topicId === selectedTopicId.value))
+  const conversationKey = computed(() => topicProtocol.value ? selectedTopicId.value : selectedRoomId.value)
+  const hasMoreBefore = computed(() => conversationKey.value ? hasOlderByConversation.value[conversationKey.value] ?? false : false)
 
 
   function scope(): string { return `${auth.user?.id ?? 'anonymous'}:${auth.activeProfile?.name ?? 'all'}` }
   function scope(): string { return `${auth.user?.id ?? 'anonymous'}:${auth.activeProfile?.name ?? 'all'}` }
 
 
@@ -80,6 +92,36 @@ export const useGroupsStore = defineStore('groups', () => {
     return loaded.sort((a, b) => b.updatedAt - a.updatedAt)
     return loaded.sort((a, b) => b.updatedAt - a.updatedAt)
   }
   }
 
 
+  async function loadTopics(roomId: string): Promise<GroupTopicSummary[]> {
+    const loaded: GroupTopicSummary[] = []
+    const seen = new Set<string>()
+    let cursor: string | undefined
+    do {
+      const key = cursor ?? '<first-page>'
+      if (seen.has(key)) throw new Error('群聊话题分页返回了重复游标')
+      seen.add(key)
+      const page = await getGroupTopics(roomId, cursor, 50)
+      loaded.push(...page.items)
+      cursor = page.nextCursor ?? undefined
+    } while (cursor && loaded.length < 2_000)
+    return loaded.sort((a, b) => b.updatedAt - a.updatedAt || b.id.localeCompare(a.id))
+  }
+
+  function topicsForRoom(roomId: string): GroupTopicSummary[] {
+    return protocol.value.topicsByRoom[roomId] ?? []
+  }
+
+  async function loadRoomTopics(roomId: string): Promise<void> {
+    if (!topicProtocol.value || Object.prototype.hasOwnProperty.call(protocol.value.topicsByRoom, roomId)) return
+    const expectedGeneration = generation
+    const loaded = await loadTopics(roomId)
+    if (expectedGeneration !== generation) return
+    protocol.value = {
+      ...protocol.value,
+      topicsByRoom: { ...protocol.value.topicsByRoom, [roomId]: loaded },
+    }
+  }
+
   async function connectEvents(anchor: GroupCapabilities, expectedGeneration: number): Promise<void> {
   async function connectEvents(anchor: GroupCapabilities, expectedGeneration: number): Promise<void> {
     await socket.connect({
     await socket.connect({
       epoch: anchor.journalEpoch,
       epoch: anchor.journalEpoch,
@@ -164,11 +206,16 @@ export const useGroupsStore = defineStore('groups', () => {
           rooms: loadedRooms,
           rooms: loadedRooms,
           roomDetails: {},
           roomDetails: {},
           messagesByRoom: {},
           messagesByRoom: {},
+          topicsByRoom: {},
+          messagesByTopic: {},
         }
         }
         const restoreId = selectedRoomId.value && loadedRooms.some(room => room.id === selectedRoomId.value)
         const restoreId = selectedRoomId.value && loadedRooms.some(room => room.id === selectedRoomId.value)
           ? selectedRoomId.value
           ? selectedRoomId.value
           : loadedRooms[0]?.id
           : loadedRooms[0]?.id
-        if (restoreId) await loadRoomSnapshot(restoreId, expectedGeneration)
+        if (restoreId) {
+          const restoreTopicId = restoreId === selectedRoomId.value ? selectedTopicId.value : undefined
+          await loadRoomSnapshot(restoreId, expectedGeneration, selectionGeneration, restoreTopicId)
+        }
         if (expectedGeneration !== generation) return
         if (expectedGeneration !== generation) return
         availability.value = 'available'
         availability.value = 'available'
         recoveryAttempt = 0
         recoveryAttempt = 0
@@ -222,8 +269,9 @@ export const useGroupsStore = defineStore('groups', () => {
       stop()
       stop()
       protocol.value = initialProtocol()
       protocol.value = initialProtocol()
       selectedRoomId.value = undefined
       selectedRoomId.value = undefined
+      selectedTopicId.value = undefined
       capabilities.value = undefined
       capabilities.value = undefined
-      hasOlderByRoom.value = {}
+      hasOlderByConversation.value = {}
     }
     }
   })
   })
 
 
@@ -231,13 +279,24 @@ export const useGroupsStore = defineStore('groups', () => {
     roomId: string,
     roomId: string,
     expectedGeneration = generation,
     expectedGeneration = generation,
     expectedSelection = selectionGeneration,
     expectedSelection = selectionGeneration,
+    requestedTopicId?: string,
   ): Promise<void> {
   ): Promise<void> {
     const startingCursor = protocol.value.cursor
     const startingCursor = protocol.value.cursor
     loadingRoomEvents.set(roomId, [])
     loadingRoomEvents.set(roomId, [])
     let detail: GroupRoomDetail
     let detail: GroupRoomDetail
     let page: GroupMessagePage
     let page: GroupMessagePage
+    let loadedTopics: GroupTopicSummary[] | undefined
+    let topicId: string | undefined
     try {
     try {
-      ;[detail, page] = await Promise.all([getGroupRoom(roomId), getGroupMessages(roomId, { limit: 100 })])
+      if (topicProtocol.value) {
+        ;[detail, loadedTopics] = await Promise.all([getGroupRoom(roomId), loadTopics(roomId)])
+        topicId = requestedTopicId || loadedTopics[0]?.id || createUuid()
+        page = loadedTopics.some(topic => topic.id === topicId)
+          ? await getGroupMessages(roomId, { topicId, limit: 100 })
+          : { items: [] }
+      } else {
+        ;[detail, page] = await Promise.all([getGroupRoom(roomId), getGroupMessages(roomId, { limit: 100 })])
+      }
     } catch (cause) {
     } catch (cause) {
       loadingRoomEvents.delete(roomId)
       loadingRoomEvents.delete(roomId)
       throw cause
       throw cause
@@ -247,17 +306,21 @@ export const useGroupsStore = defineStore('groups', () => {
       return
       return
     }
     }
     selectedRoomId.value = roomId
     selectedRoomId.value = roomId
+    selectedTopicId.value = topicId
     let merged = page.items.sort((a, b) => a.seq - b.seq)
     let merged = page.items.sort((a, b) => a.seq - b.seq)
     // Socket events can land while the REST baseline is in flight. Replay the
     // Socket events can land while the REST baseline is in flight. Replay the
     // already-published live rows last so a stale snapshot never erases or
     // already-published live rows last so a stale snapshot never erases or
     // rolls back a streaming upsert.
     // rolls back a streaming upsert.
-    for (const live of protocol.value.messagesByRoom[roomId] ?? []) {
+    const liveMessages = topicId
+      ? protocol.value.messagesByTopic[topicId] ?? []
+      : protocol.value.messagesByRoom[roomId] ?? []
+    for (const live of liveMessages) {
       merged = upsertGroupMessage(merged, live)
       merged = upsertGroupMessage(merged, live)
     }
     }
     const liveDetail = protocol.value.cursor > startingCursor
     const liveDetail = protocol.value.cursor > startingCursor
       ? protocol.value.roomDetails[roomId]
       ? protocol.value.roomDetails[roomId]
       : undefined
       : undefined
-    protocol.value = snapshotGroupRoom(protocol.value, liveDetail ?? detail, merged)
+    protocol.value = snapshotGroupRoom(protocol.value, liveDetail ?? detail, merged, { topicId, topics: loadedTopics })
     for (const envelope of loadingRoomEvents.get(roomId) ?? []) {
     for (const envelope of loadingRoomEvents.get(roomId) ?? []) {
       const durableCursor = protocol.value.cursor
       const durableCursor = protocol.value.cursor
       const replayBase = envelope.cursor == null
       const replayBase = envelope.cursor == null
@@ -267,41 +330,121 @@ export const useGroupsStore = defineStore('groups', () => {
       protocol.value = { ...replayed, cursor: Math.max(durableCursor, replayed.cursor) }
       protocol.value = { ...replayed, cursor: Math.max(durableCursor, replayed.cursor) }
     }
     }
     loadingRoomEvents.delete(roomId)
     loadingRoomEvents.delete(roomId)
-    hasOlderByRoom.value = { ...hasOlderByRoom.value, [roomId]: page.items.length >= 100 }
+    const key = topicId || roomId
+    hasOlderByConversation.value = { ...hasOlderByConversation.value, [key]: page.items.length >= 100 }
   }
   }
 
 
-  async function selectRoom(roomId: string): Promise<void> {
+  async function loadTopicSnapshot(
+    roomId: string,
+    topicId: string,
+    expectedGeneration = generation,
+    expectedSelection = selectionGeneration,
+  ): Promise<void> {
+    selectedTopicId.value = topicId
+    const persisted = (protocol.value.topicsByRoom[roomId] ?? []).some(topic => topic.id === topicId)
+    if (!persisted) {
+      protocol.value = {
+        ...protocol.value,
+        messagesByTopic: { ...protocol.value.messagesByTopic, [topicId]: protocol.value.messagesByTopic[topicId] ?? [] },
+      }
+      hasOlderByConversation.value = { ...hasOlderByConversation.value, [topicId]: false }
+      return
+    }
+    const page = await getGroupMessages(roomId, { topicId, limit: 100 })
+    if (expectedGeneration !== generation || expectedSelection !== selectionGeneration) return
+    let merged = page.items.sort((a, b) => a.seq - b.seq)
+    for (const live of protocol.value.messagesByTopic[topicId] ?? []) merged = upsertGroupMessage(merged, live)
+    protocol.value = {
+      ...protocol.value,
+      messagesByTopic: { ...protocol.value.messagesByTopic, [topicId]: merged },
+    }
+    hasOlderByConversation.value = { ...hasOlderByConversation.value, [topicId]: page.items.length >= 100 }
+  }
+
+  async function selectRoom(roomId: string, requestedTopicId?: string): Promise<void> {
     const expectedSelection = ++selectionGeneration
     const expectedSelection = ++selectionGeneration
+    const previousRoomId = selectedRoomId.value
+    const previousTopicId = selectedTopicId.value
+    const sameRoom = selectedRoomId.value === roomId
     selectedRoomId.value = roomId
     selectedRoomId.value = roomId
-    if (!protocol.value.roomDetails[roomId]) {
+    const rollback = () => {
+      if (expectedSelection !== selectionGeneration) return
+      selectedRoomId.value = previousRoomId
+      selectedTopicId.value = previousTopicId
+    }
+    const topicsLoaded = Object.prototype.hasOwnProperty.call(protocol.value.topicsByRoom, roomId)
+    if (!protocol.value.roomDetails[roomId] || (topicProtocol.value && !topicsLoaded)) {
       isLoading.value = true
       isLoading.value = true
-      try { await loadRoomSnapshot(roomId, generation, expectedSelection) }
-      catch (cause) { error.value = errorMessage(cause); throw cause }
+      const desiredTopicId = requestedTopicId || (sameRoom ? selectedTopicId.value : undefined)
+      try { await loadRoomSnapshot(roomId, generation, expectedSelection, desiredTopicId) }
+      catch (cause) { rollback(); error.value = errorMessage(cause); throw cause }
       finally { isLoading.value = false }
       finally { isLoading.value = false }
+      return
     }
     }
+    if (!topicProtocol.value) {
+      selectedTopicId.value = undefined
+      return
+    }
+    const topicId = requestedTopicId || (sameRoom ? selectedTopicId.value : undefined)
+      || protocol.value.topicsByRoom[roomId]?.[0]?.id || createUuid()
+    isLoading.value = true
+    try { await loadTopicSnapshot(roomId, topicId, generation, expectedSelection) }
+    catch (cause) { rollback(); error.value = errorMessage(cause); throw cause }
+    finally { isLoading.value = false }
+  }
+
+  async function selectTopic(topicId: string): Promise<void> {
+    const roomId = selectedRoomId.value
+    if (!roomId || !topicProtocol.value || topicId === selectedTopicId.value) return
+    const expectedSelection = ++selectionGeneration
+    const previousTopicId = selectedTopicId.value
+    isLoading.value = true
+    try { await loadTopicSnapshot(roomId, topicId, generation, expectedSelection) }
+    catch (cause) {
+      if (expectedSelection === selectionGeneration) selectedTopicId.value = previousTopicId
+      error.value = errorMessage(cause)
+      throw cause
+    }
+    finally { isLoading.value = false }
+  }
+
+  async function startNewTopic(): Promise<string | undefined> {
+    if (!selectedRoomId.value || !topicProtocol.value) return undefined
+    const topicId = createUuid()
+    await selectTopic(topicId)
+    return topicId
   }
   }
 
 
   async function refresh(): Promise<void> { await rebuildSnapshot() }
   async function refresh(): Promise<void> { await rebuildSnapshot() }
 
 
   async function createRoom(input: { name: string; cwd?: string; agents: AgentSeed[]; maxReplyRounds?: number }): Promise<GroupRoomDetail> {
   async function createRoom(input: { name: string; cwd?: string; agents: AgentSeed[]; maxReplyRounds?: number }): Promise<GroupRoomDetail> {
     const detail = await createApi(input)
     const detail = await createApi(input)
-    protocol.value = snapshotGroupRoom(protocol.value, detail, [])
+    protocol.value = snapshotGroupRoom(protocol.value, detail, [], topicProtocol.value ? { topics: [] } : {})
     selectedRoomId.value = detail.id
     selectedRoomId.value = detail.id
+    selectedTopicId.value = undefined
+    if (topicProtocol.value) await startNewTopic()
     await saveCache()
     await saveCache()
     return detail
     return detail
   }
   }
 
 
   async function updateRoom(roomId: string, input: { name?: string; cwd?: string; maxReplyRounds?: number }): Promise<GroupRoomDetail> {
   async function updateRoom(roomId: string, input: { name?: string; cwd?: string; maxReplyRounds?: number }): Promise<GroupRoomDetail> {
     const detail = await updateRoomApi(roomId, input)
     const detail = await updateRoomApi(roomId, input)
-    protocol.value = snapshotGroupRoom(protocol.value, detail, protocol.value.messagesByRoom[roomId] ?? [])
+    const topicId = topicProtocol.value && selectedRoomId.value === roomId ? selectedTopicId.value : undefined
+    const roomMessages = topicId ? protocol.value.messagesByTopic[topicId] ?? [] : protocol.value.messagesByRoom[roomId] ?? []
+    protocol.value = snapshotGroupRoom(protocol.value, detail, roomMessages, { topicId })
     await saveCache()
     await saveCache()
     return detail
     return detail
   }
   }
 
 
   async function archiveRoom(roomId: string): Promise<void> {
   async function archiveRoom(roomId: string): Promise<void> {
     const detail = await archiveApi(roomId)
     const detail = await archiveApi(roomId)
-    protocol.value = snapshotGroupRoom(protocol.value, detail, protocol.value.messagesByRoom[roomId] ?? [])
-    if (selectedRoomId.value === roomId) selectedRoomId.value = protocol.value.rooms.find(room => !room.archived && room.id !== roomId)?.id
+    const topicId = topicProtocol.value && selectedRoomId.value === roomId ? selectedTopicId.value : undefined
+    const roomMessages = topicId ? protocol.value.messagesByTopic[topicId] ?? [] : protocol.value.messagesByRoom[roomId] ?? []
+    protocol.value = snapshotGroupRoom(protocol.value, detail, roomMessages, { topicId })
+    if (selectedRoomId.value === roomId) {
+      selectedRoomId.value = protocol.value.rooms.find(room => !room.archived && room.id !== roomId)?.id
+      selectedTopicId.value = undefined
+    }
     await saveCache()
     await saveCache()
   }
   }
 
 
@@ -318,6 +461,21 @@ export const useGroupsStore = defineStore('groups', () => {
     }
     }
   }
   }
 
 
+  function publishRuns(roomId: string, incoming: GroupRun[]): void {
+    const detail = protocol.value.roomDetails[roomId]
+    if (!detail || !incoming.length) return
+    const runs = [...detail.runs]
+    for (const run of incoming) {
+      const index = runs.findIndex(item => item.id === run.id)
+      if (index < 0) runs.push(run)
+      else if (runs[index]!.updatedAt <= run.updatedAt) runs[index] = { ...runs[index]!, ...run }
+    }
+    protocol.value = {
+      ...protocol.value,
+      roomDetails: { ...protocol.value.roomDetails, [roomId]: { ...detail, runs } },
+    }
+  }
+
   async function addAgent(roomId: string, input: AgentSeed): Promise<GroupAgent> {
   async function addAgent(roomId: string, input: AgentSeed): Promise<GroupAgent> {
     const agent = await addGroupAgent(roomId, input)
     const agent = await addGroupAgent(roomId, input)
     publishAgent(roomId, agent)
     publishAgent(roomId, agent)
@@ -342,6 +500,11 @@ export const useGroupsStore = defineStore('groups', () => {
   async function sendMessage(content: string, mentionAgentIds: string[] = [], files: File[] = []): Promise<void> {
   async function sendMessage(content: string, mentionAgentIds: string[] = [], files: File[] = []): Promise<void> {
     const roomId = selectedRoomId.value
     const roomId = selectedRoomId.value
     if (!roomId) return
     if (!roomId) return
+    let topicId = topicProtocol.value ? selectedTopicId.value : undefined
+    if (topicProtocol.value && !topicId) {
+      topicId = createUuid()
+      selectedTopicId.value = topicId
+    }
     const text = content.trim()
     const text = content.trim()
     let uploadIds: string[] = []
     let uploadIds: string[] = []
     let optimisticText = text
     let optimisticText = text
@@ -355,10 +518,12 @@ export const useGroupsStore = defineStore('groups', () => {
     const clientMessageId = createUuid()
     const clientMessageId = createUuid()
     const stableRequestId = createUuid()
     const stableRequestId = createUuid()
     const now = Date.now() / 1000
     const now = Date.now() / 1000
+    const wasNewTopic = Boolean(topicId && !(protocol.value.topicsByRoom[roomId] ?? []).some(topic => topic.id === topicId))
     const optimistic: GroupMessage = {
     const optimistic: GroupMessage = {
       seq: Math.max(0, ...messages.value.map(message => message.seq)) + 1,
       seq: Math.max(0, ...messages.value.map(message => message.seq)) + 1,
       id: clientMessageId,
       id: clientMessageId,
       roomId,
       roomId,
+      topicId: topicId || null,
       senderKind: 'human',
       senderKind: 'human',
       senderId: auth.user?.id ?? 'me',
       senderId: auth.user?.id ?? 'me',
       senderName: auth.user?.displayName ?? auth.user?.username ?? '我',
       senderName: auth.user?.displayName ?? auth.user?.username ?? '我',
@@ -372,12 +537,36 @@ export const useGroupsStore = defineStore('groups', () => {
       createdAt: now,
       createdAt: now,
       updatedAt: now,
       updatedAt: now,
     }
     }
-    protocol.value = {
-      ...protocol.value,
-      messagesByRoom: {
-        ...protocol.value.messagesByRoom,
-        [roomId]: upsertGroupMessage(protocol.value.messagesByRoom[roomId] ?? [], optimistic),
-      },
+    if (topicId) {
+      const existing = (protocol.value.topicsByRoom[roomId] ?? []).find(topic => topic.id === topicId)
+      const optimisticTopic: GroupTopicSummary = {
+        id: topicId,
+        roomId,
+        title: existing?.title || topicTitle(text || optimisticText),
+        preview: optimisticText,
+        messageCount: (existing?.messageCount ?? 0) + 1,
+        createdAt: existing?.createdAt ?? now,
+        updatedAt: now,
+      }
+      protocol.value = {
+        ...protocol.value,
+        topicsByRoom: {
+          ...protocol.value.topicsByRoom,
+          [roomId]: upsertGroupTopic(protocol.value.topicsByRoom[roomId] ?? [], optimisticTopic),
+        },
+        messagesByTopic: {
+          ...protocol.value.messagesByTopic,
+          [topicId]: upsertGroupMessage(protocol.value.messagesByTopic[topicId] ?? [], optimistic),
+        },
+      }
+    } else {
+      protocol.value = {
+        ...protocol.value,
+        messagesByRoom: {
+          ...protocol.value.messagesByRoom,
+          [roomId]: upsertGroupMessage(protocol.value.messagesByRoom[roomId] ?? [], optimistic),
+        },
+      }
     }
     }
     isSending.value = true
     isSending.value = true
     try {
     try {
@@ -385,8 +574,16 @@ export const useGroupsStore = defineStore('groups', () => {
       const resolvedMentions = mentionAgentIds.includes('all')
       const resolvedMentions = mentionAgentIds.includes('all')
         ? [...enabled]
         ? [...enabled]
         : [...new Set(mentionAgentIds.filter(id => enabled.has(id)))]
         : [...new Set(mentionAgentIds.filter(id => enabled.has(id)))]
-      const response = await sendApi(roomId, text, resolvedMentions, clientMessageId, uploadIds, stableRequestId)
-      protocol.value = {
+      const response = await sendApi(roomId, text, resolvedMentions, clientMessageId, topicId, uploadIds, stableRequestId)
+      publishRuns(roomId, response.runs)
+      const responseTopicId = response.message.topicId || topicId
+      protocol.value = responseTopicId ? {
+        ...protocol.value,
+        messagesByTopic: {
+          ...protocol.value.messagesByTopic,
+          [responseTopicId]: upsertGroupMessage(protocol.value.messagesByTopic[responseTopicId] ?? [], response.message),
+        },
+      } : {
         ...protocol.value,
         ...protocol.value,
         messagesByRoom: {
         messagesByRoom: {
           ...protocol.value.messagesByRoom,
           ...protocol.value.messagesByRoom,
@@ -401,7 +598,14 @@ export const useGroupsStore = defineStore('groups', () => {
         error: explicitRejection ? errorMessage(cause) : '送达状态未知;刷新房间后再决定是否重试',
         error: explicitRejection ? errorMessage(cause) : '送达状态未知;刷新房间后再决定是否重试',
         updatedAt: Date.now() / 1000,
         updatedAt: Date.now() / 1000,
       }
       }
-      protocol.value = {
+      protocol.value = topicId ? {
+        ...protocol.value,
+        topicsByRoom: explicitRejection && wasNewTopic ? {
+          ...protocol.value.topicsByRoom,
+          [roomId]: (protocol.value.topicsByRoom[roomId] ?? []).filter(topic => topic.id !== topicId),
+        } : protocol.value.topicsByRoom,
+        messagesByTopic: { ...protocol.value.messagesByTopic, [topicId]: upsertGroupMessage(protocol.value.messagesByTopic[topicId] ?? [], failed) },
+      } : {
         ...protocol.value,
         ...protocol.value,
         messagesByRoom: { ...protocol.value.messagesByRoom, [roomId]: upsertGroupMessage(protocol.value.messagesByRoom[roomId] ?? [], failed) },
         messagesByRoom: { ...protocol.value.messagesByRoom, [roomId]: upsertGroupMessage(protocol.value.messagesByRoom[roomId] ?? [], failed) },
       }
       }
@@ -413,11 +617,15 @@ export const useGroupsStore = defineStore('groups', () => {
     const roomId = selectedRoomId.value
     const roomId = selectedRoomId.value
     const oldest = messages.value.find(message => message.seq > 0)?.seq
     const oldest = messages.value.find(message => message.seq > 0)?.seq
     if (!roomId || !oldest) return
     if (!roomId || !oldest) return
-    const page = await getGroupMessages(roomId, { beforeSeq: oldest, limit: 100 })
-    let merged = protocol.value.messagesByRoom[roomId] ?? []
+    const topicId = topicProtocol.value ? selectedTopicId.value : undefined
+    const page = await getGroupMessages(roomId, { topicId, beforeSeq: oldest, limit: 100 })
+    let merged = topicId ? protocol.value.messagesByTopic[topicId] ?? [] : protocol.value.messagesByRoom[roomId] ?? []
     for (const message of page.items) merged = upsertGroupMessage(merged, message)
     for (const message of page.items) merged = upsertGroupMessage(merged, message)
-    protocol.value = { ...protocol.value, messagesByRoom: { ...protocol.value.messagesByRoom, [roomId]: merged } }
-    hasOlderByRoom.value = { ...hasOlderByRoom.value, [roomId]: page.items.length >= 100 }
+    protocol.value = topicId
+      ? { ...protocol.value, messagesByTopic: { ...protocol.value.messagesByTopic, [topicId]: merged } }
+      : { ...protocol.value, messagesByRoom: { ...protocol.value.messagesByRoom, [roomId]: merged } }
+    const key = topicId || roomId
+    hasOlderByConversation.value = { ...hasOlderByConversation.value, [key]: page.items.length >= 100 }
   }
   }
 
 
   async function interruptAgent(agentId: string, roomId = selectedRoomId.value): Promise<void> {
   async function interruptAgent(agentId: string, roomId = selectedRoomId.value): Promise<void> {
@@ -446,9 +654,10 @@ export const useGroupsStore = defineStore('groups', () => {
   }
   }
 
 
   return {
   return {
-    availability, capabilities, rooms, selectedRoomId, selectedRoom, messages, agents, pendingInteractions,
+    availability, capabilities, topicProtocol, rooms, selectedRoomId, selectedRoom, topics, topicsForRoom, loadRoomTopics, selectedTopicId, selectedTopic,
+    messages, agents, pendingInteractions,
     connectionState, isLoading, isSending, error, hasMoreBefore,
     connectionState, isLoading, isSending, error, hasMoreBefore,
-    start, stop, refresh, selectRoom, createRoom, updateRoom, archiveRoom, addAgent, updateAgent,
+    start, stop, refresh, selectRoom, selectTopic, startNewTopic, createRoom, updateRoom, archiveRoom, addAgent, updateAgent,
     removeAgent, sendMessage, loadOlder, interruptAgent, approveInteraction, clarifyInteraction,
     removeAgent, sendMessage, loadOlder, interruptAgent, approveInteraction, clarifyInteraction,
   }
   }
 })
 })

+ 36 - 8
src/client/utils/groupProtocol.ts

@@ -1,8 +1,9 @@
 import type {
 import type {
-  GroupAgent, GroupInteraction, GroupMessage, GroupRoomDetail, GroupRoomSummary, GroupSocketEnvelope, JsonValue,
+  GroupAgent, GroupInteraction, GroupMessage, GroupRoomDetail, GroupRoomSummary, GroupSocketEnvelope, GroupTopicSummary,
 } from '@shared/types'
 } from '@shared/types'
 import {
 import {
-  normalizeGroupAgent, normalizeGroupInteraction, normalizeGroupMessage, normalizeGroupRoom, normalizeGroupRoomDetail, record, string,
+  normalizeGroupAgent, normalizeGroupInteraction, normalizeGroupMessage, normalizeGroupRoom, normalizeGroupRoomDetail,
+  normalizeGroupRun, normalizeGroupTopic, record, string,
 } from './normalize'
 } from './normalize'
 
 
 export interface GroupProtocolState {
 export interface GroupProtocolState {
@@ -11,6 +12,8 @@ export interface GroupProtocolState {
   rooms: GroupRoomSummary[]
   rooms: GroupRoomSummary[]
   roomDetails: Record<string, GroupRoomDetail>
   roomDetails: Record<string, GroupRoomDetail>
   messagesByRoom: Record<string, GroupMessage[]>
   messagesByRoom: Record<string, GroupMessage[]>
+  topicsByRoom: Record<string, GroupTopicSummary[]>
+  messagesByTopic: Record<string, GroupMessage[]>
 }
 }
 
 
 export type GroupEnvelopeVerdict = 'ready' | 'apply' | 'ignore' | 'heartbeat' | 'reset'
 export type GroupEnvelopeVerdict = 'ready' | 'apply' | 'ignore' | 'heartbeat' | 'reset'
@@ -33,6 +36,10 @@ function upsert<T extends { id: string }>(items: T[], incoming: T): T[] {
   return result
   return result
 }
 }
 
 
+export function upsertGroupTopic(items: GroupTopicSummary[], incoming: GroupTopicSummary): GroupTopicSummary[] {
+  return upsert(items, incoming).sort((a, b) => b.updatedAt - a.updatedAt || b.id.localeCompare(a.id))
+}
+
 export function upsertGroupMessage(items: GroupMessage[], incoming: GroupMessage): GroupMessage[] {
 export function upsertGroupMessage(items: GroupMessage[], incoming: GroupMessage): GroupMessage[] {
   const index = items.findIndex(item => item.id === incoming.id
   const index = items.findIndex(item => item.id === incoming.id
     || Boolean(item.clientMessageId && incoming.clientMessageId && item.clientMessageId === incoming.clientMessageId))
     || Boolean(item.clientMessageId && incoming.clientMessageId && item.clientMessageId === incoming.clientMessageId))
@@ -56,6 +63,8 @@ export function applyGroupEnvelope(state: GroupProtocolState, envelope: GroupSoc
     rooms: [...state.rooms],
     rooms: [...state.rooms],
     roomDetails: { ...state.roomDetails },
     roomDetails: { ...state.roomDetails },
     messagesByRoom: { ...state.messagesByRoom },
     messagesByRoom: { ...state.messagesByRoom },
+    topicsByRoom: { ...state.topicsByRoom },
+    messagesByTopic: { ...state.messagesByTopic },
   }
   }
   switch (envelope.event) {
   switch (envelope.event) {
     case 'room.created':
     case 'room.created':
@@ -97,12 +106,22 @@ export function applyGroupEnvelope(state: GroupProtocolState, envelope: GroupSoc
     case 'message.upsert': {
     case 'message.upsert': {
       const message = normalizeGroupMessage(payload)
       const message = normalizeGroupMessage(payload)
       if (!message.id || !message.roomId) break
       if (!message.id || !message.roomId) break
-      next.messagesByRoom[message.roomId] = upsertGroupMessage(next.messagesByRoom[message.roomId] ?? [], message)
+      if (message.topicId) {
+        next.messagesByTopic[message.topicId] = upsertGroupMessage(next.messagesByTopic[message.topicId] ?? [], message)
+      } else {
+        next.messagesByRoom[message.roomId] = upsertGroupMessage(next.messagesByRoom[message.roomId] ?? [], message)
+      }
       next.rooms = next.rooms.map(room => room.id === message.roomId
       next.rooms = next.rooms.map(room => room.id === message.roomId
         ? { ...room, lastMessage: message, updatedAt: Math.max(room.updatedAt, message.updatedAt) }
         ? { ...room, lastMessage: message, updatedAt: Math.max(room.updatedAt, message.updatedAt) }
         : room)
         : room)
       break
       break
     }
     }
+    case 'topic.updated': {
+      const topic = normalizeGroupTopic(payload)
+      if (!topic.id || !topic.roomId) break
+      next.topicsByRoom[topic.roomId] = upsertGroupTopic(next.topicsByRoom[topic.roomId] ?? [], topic)
+      break
+    }
     case 'interaction.requested':
     case 'interaction.requested':
     case 'interaction.resolved': {
     case 'interaction.resolved': {
       const interaction = normalizeGroupInteraction(payload)
       const interaction = normalizeGroupInteraction(payload)
@@ -116,10 +135,10 @@ export function applyGroupEnvelope(state: GroupProtocolState, envelope: GroupSoc
       break
       break
     }
     }
     case 'run.updated': {
     case 'run.updated': {
-      const run = payload as unknown as GroupRoomDetail['runs'][number]
-      const detail = next.roomDetails[roomId]
-      if (detail && string((run as unknown as Record<string, JsonValue>).id)) {
-        next.roomDetails[roomId] = { ...detail, runs: upsert(detail.runs, run) }
+      const run = normalizeGroupRun(payload)
+      const detail = next.roomDetails[run.roomId || roomId]
+      if (detail && run.id) {
+        next.roomDetails[detail.id] = { ...detail, runs: upsert(detail.runs, run) }
       }
       }
       break
       break
     }
     }
@@ -131,13 +150,22 @@ export function snapshotGroupRoom(
   state: GroupProtocolState,
   state: GroupProtocolState,
   roomValue: unknown,
   roomValue: unknown,
   messages: GroupMessage[],
   messages: GroupMessage[],
+  options: { topicId?: string; topics?: GroupTopicSummary[] } = {},
 ): GroupProtocolState {
 ): GroupProtocolState {
   const room = normalizeGroupRoomDetail(roomValue)
   const room = normalizeGroupRoomDetail(roomValue)
+  const messagesByRoom = options.topicId
+    ? state.messagesByRoom
+    : { ...state.messagesByRoom, [room.id]: messages }
+  const messagesByTopic = options.topicId
+    ? { ...state.messagesByTopic, [options.topicId]: messages }
+    : state.messagesByTopic
   return {
   return {
     ...state,
     ...state,
     rooms: upsert(state.rooms, normalizeGroupRoom(roomValue)),
     rooms: upsert(state.rooms, normalizeGroupRoom(roomValue)),
     roomDetails: { ...state.roomDetails, [room.id]: room },
     roomDetails: { ...state.roomDetails, [room.id]: room },
-    messagesByRoom: { ...state.messagesByRoom, [room.id]: messages },
+    messagesByRoom,
+    topicsByRoom: options.topics ? { ...state.topicsByRoom, [room.id]: options.topics } : state.topicsByRoom,
+    messagesByTopic,
     cursor: Math.max(state.cursor, room.latestCursor),
     cursor: Math.max(state.cursor, room.latestCursor),
   }
   }
 }
 }

+ 38 - 2
src/client/utils/normalize.ts

@@ -10,6 +10,8 @@ import type {
   GroupMessage,
   GroupMessage,
   GroupRoomDetail,
   GroupRoomDetail,
   GroupRoomSummary,
   GroupRoomSummary,
+  GroupRun,
+  GroupTopicSummary,
   JsonValue,
   JsonValue,
   ModelOption,
   ModelOption,
   Profile,
   Profile,
@@ -364,6 +366,7 @@ export function normalizeGroupMessage(value: unknown): GroupMessage {
   const status = string(source.status, 'unknown') as GroupMessage['status']
   const status = string(source.status, 'unknown') as GroupMessage['status']
   return {
   return {
     seq: number(source.seq), id: string(source.id), roomId: string(pick(source, 'roomId', 'room_id')),
     seq: number(source.seq), id: string(source.id), roomId: string(pick(source, 'roomId', 'room_id')),
+    topicId: string(pick(source, 'topicId', 'topic_id')) || null,
     senderKind: ['human', 'agent', 'system'].includes(senderKind) ? senderKind : 'unknown',
     senderKind: ['human', 'agent', 'system'].includes(senderKind) ? senderKind : 'unknown',
     senderId: string(pick(source, 'senderId', 'sender_id')), senderName: string(pick(source, 'senderName', 'sender_name')),
     senderId: string(pick(source, 'senderId', 'sender_id')), senderName: string(pick(source, 'senderName', 'sender_name')),
     rootMessageId: string(pick(source, 'rootMessageId', 'root_message_id')), replyToMessageId: string(pick(source, 'replyToMessageId', 'reply_to_message_id')) || null,
     rootMessageId: string(pick(source, 'rootMessageId', 'root_message_id')), replyToMessageId: string(pick(source, 'replyToMessageId', 'reply_to_message_id')) || null,
@@ -377,13 +380,46 @@ export function normalizeGroupMessage(value: unknown): GroupMessage {
 export function normalizeGroupInteraction(value: unknown): GroupInteraction {
 export function normalizeGroupInteraction(value: unknown): GroupInteraction {
   const source = record(value)
   const source = record(value)
   return {
   return {
-    id: string(source.id), roomId: string(pick(source, 'roomId', 'room_id')), agentId: string(pick(source, 'agentId', 'agent_id')),
+    id: string(source.id), roomId: string(pick(source, 'roomId', 'room_id')), topicId: string(pick(source, 'topicId', 'topic_id')) || null,
+    agentId: string(pick(source, 'agentId', 'agent_id')),
     runId: string(pick(source, 'runId', 'run_id')), kind: string(source.kind, 'unknown') as GroupInteraction['kind'],
     runId: string(pick(source, 'runId', 'run_id')), kind: string(source.kind, 'unknown') as GroupInteraction['kind'],
     payload: (source.payload ?? null) as JsonValue, status: string(source.status, 'unknown') as GroupInteraction['status'],
     payload: (source.payload ?? null) as JsonValue, status: string(source.status, 'unknown') as GroupInteraction['status'],
     createdAt: number(pick(source, 'createdAt', 'created_at')), resolvedAt: pick(source, 'resolvedAt', 'resolved_at') == null ? null : number(pick(source, 'resolvedAt', 'resolved_at')),
     createdAt: number(pick(source, 'createdAt', 'created_at')), resolvedAt: pick(source, 'resolvedAt', 'resolved_at') == null ? null : number(pick(source, 'resolvedAt', 'resolved_at')),
   }
   }
 }
 }
 
 
+export function normalizeGroupRun(value: unknown): GroupRun {
+  const source = record(value)
+  const status = string(source.status, 'unknown') as GroupRun['status']
+  const replyMode = string(pick(source, 'replyMode', 'reply_mode'))
+  return {
+    id: string(source.id), roomId: string(pick(source, 'roomId', 'room_id')),
+    topicId: string(pick(source, 'topicId', 'topic_id')) || null,
+    agentId: string(pick(source, 'agentId', 'agent_id')),
+    triggerMessageId: string(pick(source, 'triggerMessageId', 'trigger_message_id')),
+    responseMessageId: string(pick(source, 'responseMessageId', 'response_message_id')),
+    rootMessageId: string(pick(source, 'rootMessageId', 'root_message_id')),
+    depth: number(source.depth),
+    status: ['queued', 'running', 'awaiting_input', 'completed', 'failed', 'interrupted'].includes(status) ? status : 'unknown',
+    runtimeSessionId: string(pick(source, 'runtimeSessionId', 'runtime_session_id')) || null,
+    error: string(source.error),
+    replyMode: replyMode === 'mentioned' || replyMode === 'automatic' ? replyMode : null,
+    createdAt: number(pick(source, 'createdAt', 'created_at')),
+    updatedAt: number(pick(source, 'updatedAt', 'updated_at')),
+  }
+}
+
+export function normalizeGroupTopic(value: unknown): GroupTopicSummary {
+  const source = record(value)
+  return {
+    id: string(source.id), roomId: string(pick(source, 'roomId', 'room_id')),
+    title: string(source.title, '新话题'), preview: string(source.preview),
+    messageCount: number(pick(source, 'messageCount', 'message_count')),
+    createdAt: number(pick(source, 'createdAt', 'created_at')),
+    updatedAt: number(pick(source, 'updatedAt', 'updated_at')),
+  }
+}
+
 export function normalizeGroupRoom(value: unknown): GroupRoomSummary {
 export function normalizeGroupRoom(value: unknown): GroupRoomSummary {
   const source = record(value)
   const source = record(value)
   const rawAgents = source.agents
   const rawAgents = source.agents
@@ -402,7 +438,7 @@ export function normalizeGroupRoomDetail(value: unknown): GroupRoomDetail {
   return {
   return {
     id: summary.id, name: summary.name, cwd: summary.cwd, createdAt: summary.createdAt, updatedAt: summary.updatedAt,
     id: summary.id, name: summary.name, cwd: summary.cwd, createdAt: summary.createdAt, updatedAt: summary.updatedAt,
     archived: summary.archived, maxReplyRounds: summary.maxReplyRounds,
     archived: summary.archived, maxReplyRounds: summary.maxReplyRounds,
-    agents: values(source.agents).map(normalizeGroupAgent), runs: values(source.runs).map(run => record(run) as unknown as GroupRoomDetail['runs'][number]),
+    agents: values(source.agents).map(normalizeGroupAgent), runs: values(source.runs).map(normalizeGroupRun),
     pendingInteractions: values(pick(source, 'pendingInteractions', 'pending_interactions')).map(normalizeGroupInteraction),
     pendingInteractions: values(pick(source, 'pendingInteractions', 'pending_interactions')).map(normalizeGroupInteraction),
     latestCursor: number(pick(source, 'latestCursor', 'latest_cursor')),
     latestCursor: number(pick(source, 'latestCursor', 'latest_cursor')),
   }
   }

+ 216 - 22
src/client/views/GroupsView.vue

@@ -19,6 +19,7 @@ import MessageTimeline from '@/components/messages/MessageTimeline.vue'
 import type { UiMessage } from '@/components/messages/types'
 import type { UiMessage } from '@/components/messages/types'
 import ResourceSidebar from '@/components/app/ResourceSidebar.vue'
 import ResourceSidebar from '@/components/app/ResourceSidebar.vue'
 import FloatingResourceSearch from '@/components/app/FloatingResourceSearch.vue'
 import FloatingResourceSearch from '@/components/app/FloatingResourceSearch.vue'
+import type { SidebarItem } from '@/components/app/types'
 import WorkspaceView from '@/components/workspace/WorkspaceView.vue'
 import WorkspaceView from '@/components/workspace/WorkspaceView.vue'
 import { loadComposerFile } from '@/components/workspace/pendingComposer'
 import { loadComposerFile } from '@/components/workspace/pendingComposer'
 import { readAgentShowThinking, writeAgentShowThinking } from '@/utils/sessionPreferences'
 import { readAgentShowThinking, writeAgentShowThinking } from '@/utils/sessionPreferences'
@@ -43,9 +44,48 @@ const modelOptionsLoading = ref<Record<string, boolean>>({})
 const modelOptionsError = ref<Record<string, string>>({})
 const modelOptionsError = ref<Record<string, string>>({})
 const agentUpdateBusy = ref<Record<string, boolean>>({})
 const agentUpdateBusy = ref<Record<string, boolean>>({})
 const agentUpdateError = ref<Record<string, string>>({})
 const agentUpdateError = ref<Record<string, string>>({})
+const roomActionMenu = ref<{ roomId: string; x: number; y: number } | null>(null)
+const expandedRoomIds = ref(new Set<string>())
 
 
 const activeRooms = computed(() => groups.rooms.filter(room => !room.archived))
 const activeRooms = computed(() => groups.rooms.filter(room => !room.archived))
-const sidebarItems = computed(() => activeRooms.value.map(roomSidebarItem))
+const roomSidebarItems = computed(() => activeRooms.value.map(roomSidebarItem))
+function topicSidebarItemId(roomId: string, topicId: string): string { return `topic:${roomId}:${topicId}` }
+function topicFromSidebarItemId(id: string): { roomId: string; topicId: string } | undefined {
+  const match = /^topic:([^:]+):([^:]+)$/.exec(id)
+  return match ? { roomId: match[1]!, topicId: match[2]! } : undefined
+}
+const sidebarItems = computed<SidebarItem[]>(() => activeRooms.value.flatMap(room => {
+  const roomItem = roomSidebarItem(room)
+  const expanded = groups.topicProtocol && expandedRoomIds.value.has(room.id)
+  roomItem.expandable = groups.topicProtocol
+  roomItem.expanded = expanded
+  const items: SidebarItem[] = [roomItem]
+  if (!expanded) return items
+  items.push(...groups.topicsForRoom(room.id).map(topic => ({
+    id: topicSidebarItemId(room.id, topic.id),
+    title: topic.title,
+    subtitle: topic.preview || `${topic.messageCount} 条消息`,
+    meta: `${topic.messageCount} 条`,
+    section: roomItem.section,
+    icon: 'branch' as const,
+    nested: true,
+    showMore: false,
+    active: topic.id === groups.selectedTopicId,
+  })))
+  if (room.id === groups.selectedRoomId && groups.selectedTopicId && !groups.selectedTopic) {
+    items.push({
+      id: topicSidebarItemId(room.id, groups.selectedTopicId),
+      title: '新话题',
+      subtitle: '发送第一条消息以创建',
+      section: roomItem.section,
+      icon: 'branch',
+      nested: true,
+      showMore: false,
+      active: true,
+    })
+  }
+  return items
+}))
 const messages = computed(() => groups.messages.map(groupMessageToUi))
 const messages = computed(() => groups.messages.map(groupMessageToUi))
 const conversationMediaItems = computed(() => mediaItemsFromMessages(messages.value))
 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 lightboxMedia = computed(() => conversationMediaItems.value.map(item => ({ url: item.previewUrl || item.downloadUrl || '', name: item.name, type: item.kind as 'image' | 'video' })).filter(item => item.url))
@@ -63,8 +103,22 @@ const mentionOptions = computed<ComposerOption[]>(() => [
   { id: 'all', label: '所有人', detail: '通知房间内全部 Agent' },
   { id: 'all', label: '所有人', detail: '通知房间内全部 Agent' },
   ...groups.agents.map(agent => ({ id: agent.id, label: agent.displayName || agent.profile, detail: agent.profile, disabled: !agent.enabled })),
   ...groups.agents.map(agent => ({ id: agent.id, label: agent.displayName || agent.profile, detail: agent.profile, disabled: !agent.enabled })),
 ])
 ])
+const activeAgentIds = computed(() => {
+  if (!groups.topicProtocol) {
+    return new Set(groups.agents.filter(agent => ['queued', 'running'].includes(agent.status)).map(agent => agent.id))
+  }
+  return new Set((groups.selectedRoom?.runs ?? [])
+    .filter(run => run.topicId === groups.selectedTopicId && ['queued', 'running'].includes(run.status))
+    .map(run => run.agentId))
+})
+const typingAgentIds = computed(() => {
+  if (!groups.topicProtocol) return new Set(groups.agents.filter(agent => agent.status === 'running').map(agent => agent.id))
+  return new Set((groups.selectedRoom?.runs ?? [])
+    .filter(run => run.topicId === groups.selectedTopicId && run.status === 'running')
+    .map(run => run.agentId))
+})
 const typingAgentNames = computed(() => [...new Set(groups.agents
 const typingAgentNames = computed(() => [...new Set(groups.agents
-  .filter(agent => agent.status === 'running')
+  .filter(agent => typingAgentIds.value.has(agent.id))
   .map(agent => agent.displayName || agent.profile))])
   .map(agent => agent.displayName || agent.profile))])
 const typingActivity = computed(() => {
 const typingActivity = computed(() => {
   if (!connected.value) return ''
   if (!connected.value) return ''
@@ -74,6 +128,16 @@ const typingActivity = computed(() => {
   return `${names.slice(0, 2).join('、')}等 ${names.length} 个 Agent 正在输入…`
   return `${names.slice(0, 2).join('、')}等 ${names.length} 个 Agent 正在输入…`
 })
 })
 
 
+function groupRoute(roomId = groups.selectedRoomId, topicId = groups.selectedTopicId): string {
+  if (!roomId) return '/groups'
+  const roomPath = `/groups/${encodeURIComponent(roomId)}`
+  return groups.topicProtocol && topicId ? `${roomPath}/${encodeURIComponent(topicId)}` : roomPath
+}
+
+const roomActionMenuStyle = computed(() => roomActionMenu.value
+  ? { left: `${roomActionMenu.value.x}px`, top: `${roomActionMenu.value.y}px` }
+  : {})
+
 function restoreShowThinking(profile = auth.activeProfile?.name || 'default') {
 function restoreShowThinking(profile = auth.activeProfile?.name || 'default') {
   showThinking.value = readAgentShowThinking(auth.user?.id ?? 'local', profile)
   showThinking.value = readAgentShowThinking(auth.user?.id ?? 'local', profile)
 }
 }
@@ -83,12 +147,106 @@ function toggleShowThinking() {
   writeAgentShowThinking(auth.user?.id ?? 'local', auth.activeProfile?.name || 'default', showThinking.value)
   writeAgentShowThinking(auth.user?.id ?? 'local', auth.activeProfile?.name || 'default', showThinking.value)
 }
 }
 
 
+function stopActiveTopic() {
+  for (const agentId of activeAgentIds.value) void groups.interruptAgent(agentId).catch(() => undefined)
+}
+
 async function selectRoom(id: string) {
 async function selectRoom(id: string) {
-  await router.push(`/groups/${encodeURIComponent(id)}`)
-  await groups.selectRoom(id)
+  try { await groups.selectRoom(id) }
+  catch { return }
+  expandRoom(id)
+  await router.push(groupRoute())
   quoted.value = null
   quoted.value = null
 }
 }
 
 
+async function selectTopic(id: string) {
+  if (!id || id === groups.selectedTopicId) return
+  try { await groups.selectTopic(id) }
+  catch { return }
+  await router.push(groupRoute())
+  quoted.value = null
+}
+
+async function selectSidebarItem(id: string) {
+  const topic = topicFromSidebarItemId(id)
+  if (topic) {
+    if (topic.roomId !== groups.selectedRoomId) {
+      try { await groups.selectRoom(topic.roomId, topic.topicId) }
+      catch { return }
+      expandRoom(topic.roomId)
+      await router.push(groupRoute())
+      quoted.value = null
+      return
+    }
+    await selectTopic(topic.topicId)
+    return
+  }
+  await selectRoom(id)
+}
+
+function expandRoom(roomId: string) {
+  if (!groups.topicProtocol || expandedRoomIds.value.has(roomId)) return
+  expandedRoomIds.value = new Set([...expandedRoomIds.value, roomId])
+}
+
+async function toggleRoomTopics(roomId: string) {
+  if (!groups.topicProtocol) return
+  if (expandedRoomIds.value.has(roomId)) {
+    const next = new Set(expandedRoomIds.value)
+    next.delete(roomId)
+    expandedRoomIds.value = next
+    return
+  }
+  expandRoom(roomId)
+  try { await groups.loadRoomTopics(roomId) }
+  catch { /* store-level request errors are surfaced when the room is opened */ }
+}
+
+async function openRoomManager(id: string) {
+  if (topicFromSidebarItemId(id)) return
+  await selectRoom(id)
+  managerOpen.value = true
+}
+
+function openRoomActions(roomId: string, event: MouseEvent) {
+  if (!groups.topicProtocol || topicFromSidebarItemId(roomId) || !activeRooms.value.some(room => room.id === roomId)) return
+  const width = 174
+  const height = 46
+  const inset = 8
+  roomActionMenu.value = {
+    roomId,
+    x: Math.max(inset, Math.min(event.clientX, window.innerWidth - width - inset)),
+    y: Math.max(inset, Math.min(event.clientY, window.innerHeight - height - inset)),
+  }
+}
+
+function closeRoomActions() { roomActionMenu.value = null }
+
+function handleRoomActionPointer(event: PointerEvent) {
+  if (!roomActionMenu.value || (event.target as HTMLElement).closest('.group-room-actions')) return
+  closeRoomActions()
+}
+
+function handleRoomActionKey(event: KeyboardEvent) {
+  if (event.key === 'Escape' && roomActionMenu.value) closeRoomActions()
+}
+
+async function startTopicFromRoomAction() {
+  const roomId = roomActionMenu.value?.roomId
+  closeRoomActions()
+  if (!roomId) return
+  try {
+    await groups.selectRoom(roomId)
+    expandRoom(roomId)
+    const topicId = await groups.startNewTopic()
+    if (!topicId) return
+    await router.push(groupRoute(roomId, topicId))
+    quoted.value = null
+    await nextTick()
+    composer.value?.focus()
+  } catch { /* store publishes the error */ }
+}
+
 async function createRoom(payload: { name: string; profiles: string[]; autoReply: boolean; replyRounds: number }) {
 async function createRoom(payload: { name: string; profiles: string[]; autoReply: boolean; replyRounds: number }) {
   const detail = await groups.createRoom({
   const detail = await groups.createRoom({
     name: payload.name,
     name: payload.name,
@@ -96,7 +254,7 @@ async function createRoom(payload: { name: string; profiles: string[]; autoReply
     maxReplyRounds: payload.replyRounds,
     maxReplyRounds: payload.replyRounds,
   })
   })
   createOpen.value = false
   createOpen.value = false
-  await router.push(`/groups/${encodeURIComponent(detail.id)}`)
+  await router.push(groupRoute(detail.id, groups.selectedTopicId))
 }
 }
 
 
 async function send(payload: ComposerSubmit) {
 async function send(payload: ComposerSubmit) {
@@ -152,7 +310,7 @@ async function archiveRoom() {
   if (!groups.selectedRoom) return
   if (!groups.selectedRoom) return
   await groups.archiveRoom(groups.selectedRoom.id)
   await groups.archiveRoom(groups.selectedRoom.id)
   managerOpen.value = false
   managerOpen.value = false
-  await router.replace(groups.selectedRoomId ? `/groups/${encodeURIComponent(groups.selectedRoomId)}` : '/groups')
+  await router.replace(groupRoute())
 }
 }
 
 
 function openLocalFile({ name, url }: { name: string; url: string }) {
 function openLocalFile({ name, url }: { name: string; url: string }) {
@@ -190,20 +348,43 @@ async function addMediaToComposer(media: PreviewMedia) {
 }
 }
 
 
 onMounted(async () => {
 onMounted(async () => {
+  document.addEventListener('pointerdown', handleRoomActionPointer)
+  document.addEventListener('keydown', handleRoomActionKey)
   restoreShowThinking()
   restoreShowThinking()
   try {
   try {
     await groups.start()
     await groups.start()
     const requested = typeof route.params.roomId === 'string' ? route.params.roomId : ''
     const requested = typeof route.params.roomId === 'string' ? route.params.roomId : ''
-    if (requested && requested !== groups.selectedRoomId) await groups.selectRoom(requested)
-    else if (groups.selectedRoomId) await router.replace(`/groups/${encodeURIComponent(groups.selectedRoomId)}`)
+    const requestedTopic = typeof route.params.topicId === 'string' ? route.params.topicId : undefined
+    if (requested) {
+      try { await groups.selectRoom(requested, requestedTopic) }
+      catch {
+        if (groups.selectedRoomId) await router.replace(groupRoute())
+        return
+      }
+    }
+    if (groups.selectedRoomId) {
+      expandRoom(groups.selectedRoomId)
+      if (route.fullPath !== groupRoute()) await router.replace(groupRoute())
+    }
   } catch { /* availability state renders the error */ }
   } catch { /* availability state renders the error */ }
 })
 })
 
 
-onBeforeUnmount(() => groups.stop())
+onBeforeUnmount(() => {
+  document.removeEventListener('pointerdown', handleRoomActionPointer)
+  document.removeEventListener('keydown', handleRoomActionKey)
+  groups.stop()
+})
 
 
-watch(() => route.params.roomId, async roomId => {
-  if (typeof roomId === 'string' && roomId && roomId !== groups.selectedRoomId) await groups.selectRoom(roomId)
+watch(() => [route.params.roomId, route.params.topicId] as const, async ([roomValue, topicValue]) => {
+  const roomId = typeof roomValue === 'string' ? roomValue : ''
+  const topicId = typeof topicValue === 'string' ? topicValue : undefined
+  if (roomId && (roomId !== groups.selectedRoomId || (groups.topicProtocol && topicId && topicId !== groups.selectedTopicId))) {
+    try { await groups.selectRoom(roomId, topicId) }
+    catch { if (groups.selectedRoomId && route.fullPath !== groupRoute()) await router.replace(groupRoute()) }
+  }
 })
 })
+watch(() => groups.selectedRoomId, roomId => { if (roomId) expandRoom(roomId) })
+watch(() => groups.topicProtocol, supported => { if (supported && groups.selectedRoomId) expandRoom(groups.selectedRoomId) })
 watch(() => auth.activeProfile?.name, profile => { if (profile) restoreShowThinking(profile) })
 watch(() => auth.activeProfile?.name, profile => { if (profile) restoreShowThinking(profile) })
 </script>
 </script>
 
 
@@ -224,8 +405,10 @@ watch(() => auth.activeProfile?.name, profile => { if (profile) restoreShowThink
         search-placeholder="搜索群聊"
         search-placeholder="搜索群聊"
         empty-title="还没有群聊"
         empty-title="还没有群聊"
         empty-description="新建群聊,邀请 1–8 个 Agent 一起协作。"
         empty-description="新建群聊,邀请 1–8 个 Agent 一起协作。"
-        @select="selectRoom"
-        @more="id => { selectRoom(id); managerOpen = true }"
+        @select="selectSidebarItem"
+        @more="openRoomManager"
+        @context-menu="openRoomActions"
+        @toggle="toggleRoomTopics"
       />
       />
     </template>
     </template>
 
 
@@ -235,8 +418,8 @@ watch(() => auth.activeProfile?.name, profile => { if (profile) restoreShowThink
     <div v-else class="groups-workspace">
     <div v-else class="groups-workspace">
       <MessageTimeline
       <MessageTimeline
         :messages="messages"
         :messages="messages"
-        :title="groups.selectedRoom?.name || '群聊'"
-        :subtitle="groups.selectedRoom ? `${groups.agents.length} 个 Agent · 最多 ${groups.selectedRoom.maxReplyRounds} 轮回复` : '选择或新建一个群聊'"
+        :title="groups.topicProtocol ? (groups.selectedTopic?.title || '新话题') : (groups.selectedRoom?.name || '群聊')"
+        :subtitle="groups.selectedRoom ? `${groups.selectedRoom.name} · ${groups.agents.length} 个 Agent · 最多 ${groups.selectedRoom.maxReplyRounds} 轮回复` : '选择或新建一个群聊'"
         :loading="groups.isLoading"
         :loading="groups.isLoading"
         :has-older="groups.hasMoreBefore"
         :has-older="groups.hasMoreBefore"
         :connected="connected"
         :connected="connected"
@@ -244,8 +427,8 @@ watch(() => auth.activeProfile?.name, profile => { if (profile) restoreShowThink
         :show-tools="showThinking"
         :show-tools="showThinking"
         :interaction="activeInteraction"
         :interaction="activeInteraction"
         :mention-names="mentionNames"
         :mention-names="mentionNames"
-        empty-title="让多个 Agent 一起工作"
-        empty-description="使用 @ 提及指定 Agent,或直接发送消息触发已启用自动回复的成员。"
+        :empty-title="groups.topicProtocol ? '开始一个新话题' : '让多个 Agent 一起工作'"
+        :empty-description="groups.topicProtocol ? '第一条消息会创建独立话题,各话题分别保留 Agent 上下文。' : '使用 @ 提及指定 Agent,或直接发送消息触发已启用自动回复的成员。'"
         @load-older="groups.loadOlder"
         @load-older="groups.loadOlder"
         @quote="quoted = $event"
         @quote="quoted = $event"
         @preview="openAttachment"
         @preview="openAttachment"
@@ -264,9 +447,9 @@ watch(() => auth.activeProfile?.name, profile => { if (profile) restoreShowThink
       <ComposerShell
       <ComposerShell
         ref="composer"
         ref="composer"
         mode="group"
         mode="group"
-        :draft-key="`${auth.user?.id || 'local'}:${groups.selectedRoomId || 'new'}`"
+        :draft-key="`${auth.user?.id || 'local'}:${groups.selectedRoomId || 'new'}:${groups.selectedTopicId || 'legacy'}`"
         :disabled="!groups.selectedRoom || groups.availability !== 'available'"
         :disabled="!groups.selectedRoom || groups.availability !== 'available'"
-        :streaming="groups.agents.some(agent => ['queued', 'running'].includes(agent.status))"
+        :streaming="activeAgentIds.size > 0"
         :sending="groups.isSending"
         :sending="groups.isSending"
         :activity-text="typingActivity"
         :activity-text="typingActivity"
         :tool-trace-visible="showThinking"
         :tool-trace-visible="showThinking"
@@ -275,7 +458,7 @@ watch(() => auth.activeProfile?.name, profile => { if (profile) restoreShowThink
         :attachments-enabled="uploadsEnabled"
         :attachments-enabled="uploadsEnabled"
         placeholder="发消息给群聊,输入 @ 提及 Agent"
         placeholder="发消息给群聊,输入 @ 提及 Agent"
         @send="send"
         @send="send"
-        @stop="groups.agents.filter(agent => ['queued', 'running'].includes(agent.status)).forEach(agent => groups.interruptAgent(agent.id))"
+        @stop="stopActiveTopic"
         @tool-trace-toggle="toggleShowThinking"
         @tool-trace-toggle="toggleShowThinking"
         @clear-reference="quoted = null"
         @clear-reference="quoted = null"
       />
       />
@@ -304,14 +487,25 @@ watch(() => auth.activeProfile?.name, profile => { if (profile) restoreShowThink
     </template>
     </template>
   </WorkspaceView>
   </WorkspaceView>
 
 
-  <FloatingResourceSearch section="groups" label="搜索群聊" :items="sidebarItems" @select="selectRoom" />
+  <FloatingResourceSearch section="groups" label="搜索群聊" :items="roomSidebarItems" @select="selectRoom" />
   <CreateGroupDialog :open="createOpen" :profiles="profiles" :busy="groups.isLoading" @close="createOpen = false" @create="createRoom" />
   <CreateGroupDialog :open="createOpen" :profiles="profiles" :busy="groups.isLoading" @close="createOpen = false" @create="createRoom" />
   <PreviewModal v-if="preview" :item="preview" :items="conversationMediaItems" @close="preview = null" @add-to-composer="addPreviewToComposer" @source="preview = null" />
   <PreviewModal v-if="preview" :item="preview" :items="conversationMediaItems" @close="preview = null" @add-to-composer="addPreviewToComposer" @source="preview = null" />
   <ImagePreviewLightbox v-model="mediaPreviewIndex" :images="lightboxMedia" :can-add="uploadsEnabled" @add="addMediaToComposer" />
   <ImagePreviewLightbox v-model="mediaPreviewIndex" :images="lightboxMedia" :can-add="uploadsEnabled" @add="addMediaToComposer" />
+
+  <Teleport to="body">
+    <Transition name="group-menu">
+      <section v-if="roomActionMenu" class="group-room-actions" :style="roomActionMenuStyle" role="menu" aria-label="群聊房间操作" @contextmenu.prevent>
+        <button class="group-action-row" role="menuitem" type="button" @click="startTopicFromRoomAction"><AppIcon name="plus" :size="14" />新建话题</button>
+      </section>
+    </Transition>
+  </Teleport>
 </template>
 </template>
 
 
 <style scoped>
 <style scoped>
 .groups-workspace, .groups-unavailable { display: flex; min-width: 0; min-height: 0; flex: 1; flex-direction: column; background: var(--conversation-canvas); }.groups-unavailable { overflow: auto; }
 .groups-workspace, .groups-unavailable { display: flex; min-width: 0; min-height: 0; flex: 1; flex-direction: column; background: var(--conversation-canvas); }.groups-unavailable { overflow: auto; }
+.group-room-actions { position: fixed; z-index: 205; width: 174px; 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); }
+.group-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; }.group-action-row:hover, .group-action-row:focus-visible { outline: 0; background: var(--surface-soft); color: var(--text-primary); }
+.group-menu-enter-active, .group-menu-leave-active { transition: opacity 100ms ease, transform 120ms var(--ease-out); transform-origin: top right; }.group-menu-enter-from, .group-menu-leave-to { opacity: 0; transform: translateY(-3px) scale(.98); }
 .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 { 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: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); }
 .sidebar-primary-action:focus-visible { box-shadow: inset 0 0 0 1px var(--line-strong); }
@@ -319,5 +513,5 @@ watch(() => auth.activeProfile?.name, profile => { if (profile) restoreShowThink
 .group-header-actions { display: flex; align-items: center; gap: 8px; }.group-avatars { display: flex; align-items: center; }.group-avatars span, .group-avatars em { display: grid; width: 24px; height: 24px; margin-left: -5px; place-items: center; border: 2px solid var(--canvas); border-radius: 8px; background: var(--accent); color: var(--text-on-solid); font-size: 8px; font-style: normal; font-weight: 650; }.group-avatars span:first-child { margin-left: 0; }.group-avatars em { background: var(--surface-hover); color: var(--text-secondary); }
 .group-header-actions { display: flex; align-items: center; gap: 8px; }.group-avatars { display: flex; align-items: center; }.group-avatars span, .group-avatars em { display: grid; width: 24px; height: 24px; margin-left: -5px; place-items: center; border: 2px solid var(--canvas); border-radius: 8px; background: var(--accent); color: var(--text-on-solid); font-size: 8px; font-style: normal; font-weight: 650; }.group-avatars span:first-child { margin-left: 0; }.group-avatars em { background: var(--surface-hover); color: var(--text-secondary); }
 .group-error { display: flex; width: min(760px, calc(100% - 32px)); margin: 0 auto 4px; align-items: center; gap: 6px; color: var(--danger); font-size: 9px; }
 .group-error { display: flex; width: min(760px, calc(100% - 32px)); margin: 0 auto 4px; align-items: center; gap: 6px; color: var(--danger); font-size: 9px; }
 @media (max-width: 480px) { .group-avatars { display: none; } }
 @media (max-width: 480px) { .group-avatars { display: none; } }
-@media (prefers-reduced-motion: reduce) { .sidebar-primary-action { transition: none; } }
+@media (prefers-reduced-motion: reduce) { .sidebar-primary-action, .group-menu-enter-active, .group-menu-leave-active { transition: none; } }
 </style>
 </style>

+ 6 - 1
src/server/routes.ts

@@ -669,9 +669,14 @@ export function createApiRouter(dependencies: RouteDependencies): Router {
       method: 'POST', requestBody: body(ctx),
       method: 'POST', requestBody: body(ctx),
     })
     })
   })
   })
+  router.get('/api/app/groups/rooms/:roomID/topics', async (ctx) => {
+    await proxy(ctx, dependencies.upstream, groupPath(ctx, '/topics'), {
+      search: searchFrom(ctx, ['limit', 'cursor']),
+    })
+  })
   router.get('/api/app/groups/rooms/:roomID/messages', async (ctx) => {
   router.get('/api/app/groups/rooms/:roomID/messages', async (ctx) => {
     await proxy(ctx, dependencies.upstream, groupPath(ctx, '/messages'), {
     await proxy(ctx, dependencies.upstream, groupPath(ctx, '/messages'), {
-      search: searchFrom(ctx, ['beforeSeq', 'afterSeq', 'limit']),
+      search: searchFrom(ctx, ['topicId', 'beforeSeq', 'afterSeq', 'limit']),
     })
     })
   })
   })
   router.post('/api/app/groups/rooms/:roomID/messages', async (ctx) => {
   router.post('/api/app/groups/rooms/:roomID/messages', async (ctx) => {

+ 20 - 1
src/shared/types.ts

@@ -241,7 +241,7 @@ export interface GroupLimits {
 }
 }
 
 
 export const MIN_SUPPORTED_GROUP_PROTOCOL_VERSION = 2
 export const MIN_SUPPORTED_GROUP_PROTOCOL_VERSION = 2
-export const MAX_SUPPORTED_GROUP_PROTOCOL_VERSION = 3
+export const MAX_SUPPORTED_GROUP_PROTOCOL_VERSION = 4
 export const SUPPORTED_GROUP_PROTOCOL_VERSION_LABEL = `v${MIN_SUPPORTED_GROUP_PROTOCOL_VERSION}–v${MAX_SUPPORTED_GROUP_PROTOCOL_VERSION}`
 export const SUPPORTED_GROUP_PROTOCOL_VERSION_LABEL = `v${MIN_SUPPORTED_GROUP_PROTOCOL_VERSION}–v${MAX_SUPPORTED_GROUP_PROTOCOL_VERSION}`
 
 
 export function isSupportedGroupProtocolVersion(value: number): boolean {
 export function isSupportedGroupProtocolVersion(value: number): boolean {
@@ -284,6 +284,7 @@ export interface GroupMessage {
   seq: number
   seq: number
   id: string
   id: string
   roomId: string
   roomId: string
+  topicId?: string | null
   senderKind: 'human' | 'agent' | 'system' | 'unknown'
   senderKind: 'human' | 'agent' | 'system' | 'unknown'
   senderId: string
   senderId: string
   senderName: string
   senderName: string
@@ -302,6 +303,7 @@ export interface GroupMessage {
 export interface GroupRun {
 export interface GroupRun {
   id: string
   id: string
   roomId: string
   roomId: string
+  topicId?: string | null
   agentId: string
   agentId: string
   triggerMessageId: string
   triggerMessageId: string
   responseMessageId: string
   responseMessageId: string
@@ -318,6 +320,7 @@ export interface GroupRun {
 export interface GroupInteraction {
 export interface GroupInteraction {
   id: string
   id: string
   roomId: string
   roomId: string
+  topicId?: string | null
   agentId: string
   agentId: string
   runId: string
   runId: string
   kind: 'approval' | 'clarification' | 'unknown'
   kind: 'approval' | 'clarification' | 'unknown'
@@ -352,6 +355,21 @@ export interface GroupRoomPage {
   nextCursor?: string | null
   nextCursor?: string | null
 }
 }
 
 
+export interface GroupTopicSummary {
+  id: string
+  roomId: string
+  title: string
+  preview: string
+  messageCount: number
+  createdAt: number
+  updatedAt: number
+}
+
+export interface GroupTopicPage {
+  items: GroupTopicSummary[]
+  nextCursor?: string | null
+}
+
 export interface GroupMessagePage {
 export interface GroupMessagePage {
   items: GroupMessage[]
   items: GroupMessage[]
 }
 }
@@ -365,6 +383,7 @@ export type GroupEventType =
   | 'agent.deleted'
   | 'agent.deleted'
   | 'agent.status'
   | 'agent.status'
   | 'message.upsert'
   | 'message.upsert'
+  | 'topic.updated'
   | 'interaction.requested'
   | 'interaction.requested'
   | 'interaction.resolved'
   | 'interaction.resolved'
   | 'run.updated'
   | 'run.updated'

+ 45 - 4
tests/client/groupProtocol.test.ts

@@ -6,9 +6,15 @@ import {
 
 
 const epoch = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa'
 const epoch = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa'
 
 
-function groupMessage(id: string, clientMessageId: string, content: string, status: GroupMessage['status']): GroupMessage {
+function groupMessage(
+  id: string,
+  clientMessageId: string,
+  content: string,
+  status: GroupMessage['status'],
+  topicId: string | null = null,
+): GroupMessage {
   return {
   return {
-    seq: status === 'queued' ? 0 : 1, id, roomId: 'room-1', senderKind: 'human', senderId: 'me', senderName: '我',
+    seq: status === 'queued' ? 0 : 1, id, roomId: 'room-1', topicId, senderKind: 'human', senderId: 'me', senderName: '我',
     rootMessageId: id, clientMessageId, content, reasoning: '', toolState: [], status, error: '', createdAt: 1, updatedAt: 1,
     rootMessageId: id, clientMessageId, content, reasoning: '', toolState: [], status, error: '', createdAt: 1, updatedAt: 1,
   }
   }
 }
 }
@@ -17,11 +23,11 @@ function initial(): GroupProtocolState {
   return {
   return {
     epoch, cursor: 8,
     epoch, cursor: 8,
     rooms: [{ id: 'room-1', name: '群聊', cwd: '', createdAt: 1, updatedAt: 1, archived: false, agentCount: 1, unreadCount: 0, maxReplyRounds: 3 }],
     rooms: [{ id: 'room-1', name: '群聊', cwd: '', createdAt: 1, updatedAt: 1, archived: false, agentCount: 1, unreadCount: 0, maxReplyRounds: 3 }],
-    roomDetails: {}, messagesByRoom: { 'room-1': [] },
+    roomDetails: {}, topicsByRoom: { 'room-1': [] }, messagesByRoom: { 'room-1': [] }, messagesByTopic: {},
   }
   }
 }
 }
 
 
-describe('group protocol v2 continuity', () => {
+describe('group protocol continuity', () => {
   it('accepts only exact ready and next cursor, ignoring duplicates', () => {
   it('accepts only exact ready and next cursor, ignoring duplicates', () => {
     expect(classifyGroupEnvelope(epoch, 8, { type: 'group.ready', epoch, cursor: 8 })).toBe('ready')
     expect(classifyGroupEnvelope(epoch, 8, { type: 'group.ready', epoch, cursor: 8 })).toBe('ready')
     expect(classifyGroupEnvelope(epoch, 8, { type: 'group.event', epoch, cursor: 8 })).toBe('ignore')
     expect(classifyGroupEnvelope(epoch, 8, { type: 'group.event', epoch, cursor: 8 })).toBe('ignore')
@@ -52,4 +58,39 @@ describe('group protocol v2 continuity', () => {
     expect(reduced.messagesByRoom['room-1']).toHaveLength(1)
     expect(reduced.messagesByRoom['room-1']).toHaveLength(1)
     expect(reduced.rooms[0].lastMessage?.id).toBe('server')
     expect(reduced.rooms[0].lastMessage?.id).toBe('server')
   })
   })
+
+  it('upserts topic.updated events in most-recently-active order', () => {
+    const state = initial()
+    state.topicsByRoom['room-1'] = [
+      { id: 'topic-1', roomId: 'room-1', title: '旧标题', preview: '旧内容', messageCount: 1, createdAt: 1, updatedAt: 2 },
+      { id: 'topic-2', roomId: 'room-1', title: '另一话题', preview: '另一内容', messageCount: 1, createdAt: 1, updatedAt: 4 },
+    ]
+    const envelope: GroupSocketEnvelope = {
+      type: 'group.event', epoch, cursor: 9, roomId: 'room-1', event: 'topic.updated',
+      payload: {
+        id: 'topic-1', roomId: 'room-1', title: '新标题', preview: '最新内容',
+        messageCount: 2, createdAt: 1, updatedAt: 5,
+      },
+    }
+
+    const reduced = applyGroupEnvelope(state, envelope)
+
+    expect(reduced.topicsByRoom['room-1']).toHaveLength(2)
+    expect(reduced.topicsByRoom['room-1'].map(topic => topic.id)).toEqual(['topic-1', 'topic-2'])
+    expect(reduced.topicsByRoom['room-1'][0]).toMatchObject({
+      title: '新标题', preview: '最新内容', messageCount: 2, updatedAt: 5,
+    })
+  })
+
+  it('routes v4 message events to their topic without polluting legacy room history', () => {
+    const payload = groupMessage('server-topic', 'client-topic', '话题回复', 'completed', 'topic-1')
+    const envelope: GroupSocketEnvelope = {
+      type: 'group.event', epoch, cursor: 9, roomId: 'room-1', event: 'message.upsert', payload: payload as never,
+    }
+
+    const reduced = applyGroupEnvelope(initial(), envelope)
+
+    expect(reduced.messagesByTopic['topic-1']).toEqual([expect.objectContaining({ id: 'server-topic', topicId: 'topic-1' })])
+    expect(reduced.messagesByRoom['room-1']).toEqual([])
+  })
 })
 })

+ 65 - 5
tests/e2e/workspace.spec.ts

@@ -47,7 +47,7 @@ test('navigates every 9119 workspace without blank transitions', async ({ page }
   await expect(page.getByRole('option').first()).toContainText('夭夭')
   await expect(page.getByRole('option').first()).toContainText('夭夭')
   await expect(page.locator('.desktop-sidebar').getByText('已置顶 1', { exact: true })).toBeVisible()
   await expect(page.locator('.desktop-sidebar').getByText('已置顶 1', { exact: true })).toBeVisible()
   await page.getByRole('button', { name: '群聊' }).click()
   await page.getByRole('button', { name: '群聊' }).click()
-  await expect(page.getByRole('heading', { name: '设计与工程协作' })).toBeVisible()
+  await expect(page.getByRole('heading', { name: '设计验收' })).toBeVisible()
   await expect(page.getByRole('button', { name: '对话', exact: true })).toBeVisible()
   await expect(page.getByRole('button', { name: '对话', exact: true })).toBeVisible()
   await expect(page.getByRole('button', { name: '群聊', exact: true })).toHaveCount(0)
   await expect(page.getByRole('button', { name: '群聊', exact: true })).toHaveCount(0)
   await expect(page.getByRole('button', { name: '文件库', exact: true })).toHaveCount(0)
   await expect(page.getByRole('button', { name: '文件库', exact: true })).toHaveCount(0)
@@ -105,7 +105,7 @@ test('uses the active session or group title in the browser title', async ({ pag
 
 
 test('uses a floating search dialog for group rooms', async ({ page }) => {
 test('uses a floating search dialog for group rooms', async ({ page }) => {
   await page.getByRole('button', { name: '群聊' }).click()
   await page.getByRole('button', { name: '群聊' }).click()
-  await expect(page.getByRole('heading', { name: '设计与工程协作' })).toBeVisible()
+  await expect(page.getByRole('heading', { name: '设计验收' })).toBeVisible()
 
 
   await page.locator('.desktop-sidebar').getByRole('button', { name: '搜索', exact: true }).click()
   await page.locator('.desktop-sidebar').getByRole('button', { name: '搜索', exact: true }).click()
   const searchDialog = page.getByRole('dialog', { name: '搜索群聊' })
   const searchDialog = page.getByRole('dialog', { name: '搜索群聊' })
@@ -117,9 +117,9 @@ test('uses a floating search dialog for group rooms', async ({ page }) => {
   await expect(searchDialog).toBeHidden()
   await expect(searchDialog).toBeHidden()
 })
 })
 
 
-test('edits every protocol v3 Agent setting with one inspector close control', async ({ page }) => {
+test('edits every protocol v4 Agent setting with one inspector close control', async ({ page }) => {
   await page.getByRole('button', { name: '群聊' }).click()
   await page.getByRole('button', { name: '群聊' }).click()
-  await expect(page.getByRole('heading', { name: '设计与工程协作' })).toBeVisible()
+  await expect(page.getByRole('heading', { name: '设计验收' })).toBeVisible()
   await page.getByRole('button', { name: '管理群聊' }).click()
   await page.getByRole('button', { name: '管理群聊' }).click()
 
 
   await expect(page.getByRole('button', { name: '关闭群聊管理' })).toHaveCount(1)
   await expect(page.getByRole('button', { name: '关闭群聊管理' })).toHaveCount(1)
@@ -199,7 +199,7 @@ test('recovers the group page after its initial upstream connection fails', asyn
   await page.getByRole('button', { name: '群聊' }).click()
   await page.getByRole('button', { name: '群聊' }).click()
   await expect(page.getByRole('heading', { name: '群聊服务暂不可用' })).toBeVisible()
   await expect(page.getByRole('heading', { name: '群聊服务暂不可用' })).toBeVisible()
   await page.request.post('http://127.0.0.1:19119/__test/groups/availability', { data: { available: true } })
   await page.request.post('http://127.0.0.1:19119/__test/groups/availability', { data: { available: true } })
-  await expect(page.getByRole('heading', { name: '设计与工程协作' })).toBeVisible({ timeout: 5_000 })
+  await expect(page.getByRole('heading', { name: '设计验收' })).toBeVisible({ timeout: 5_000 })
 })
 })
 
 
 test('previews an octet-stream Markdown file from the file library', async ({ page }) => {
 test('previews an octet-stream Markdown file from the file library', async ({ page }) => {
@@ -508,6 +508,66 @@ test('renders historical assistant MEDIA as Markdown in chat and group chat', as
   await expect(page.locator('.markdown img[alt="AppIcon-1024.png"]')).toBeVisible()
   await expect(page.locator('.markdown img[alt="AppIcon-1024.png"]')).toBeVisible()
 })
 })
 
 
+test('selects existing group topics and creates a new protocol v4 topic on first send', async ({ page }) => {
+  const roomId = '22222222-2222-4222-8222-222222222222'
+  const existingTopicId = '88888888-8888-4888-8888-888888888888'
+  const existingTopicSidebarId = `topic:${roomId}:${existingTopicId}`
+
+  await page.getByRole('button', { name: '群聊' }).click()
+  const sidebar = page.locator('.desktop-sidebar')
+  const roomItem = sidebar.locator(`[data-sidebar-id="${roomId}"]`)
+  const existingTopic = sidebar.locator(`[data-sidebar-id="${existingTopicSidebarId}"]`)
+  await expect(existingTopic).toContainText('发布检查')
+  await roomItem.getByRole('button', { name: '收起 设计与工程协作 的话题' }).click()
+  await expect(existingTopic).toHaveCount(0)
+  await roomItem.getByRole('button', { name: '展开 设计与工程协作 的话题' }).click()
+  await expect(existingTopic).toContainText('发布检查')
+
+  const existingTopicRequest = page.waitForRequest(request => {
+    const url = new URL(request.url())
+    return request.method() === 'GET'
+      && url.pathname === `/api/app/groups/rooms/${roomId}/messages`
+      && url.searchParams.get('topicId') === existingTopicId
+  })
+  await existingTopic.click()
+  await existingTopicRequest
+  await expect(page).toHaveURL(new RegExp(`/groups/${roomId}/${existingTopicId}$`))
+  const timeline = page.locator('.message-stack')
+  await expect(timeline.getByText('请核对发布话题的独立历史。', { exact: true })).toBeVisible()
+  await expect(timeline.getByText('大家好,检查一下群聊输入框。', { exact: true })).toHaveCount(0)
+
+  await roomItem.click({ button: 'right' })
+  const roomActions = page.getByRole('menu', { name: '群聊房间操作' })
+  await roomActions.getByRole('menuitem', { name: '新建话题' }).click()
+  const draftTopic = sidebar.locator('.sidebar-item--nested').filter({ hasText: '新话题' })
+  await expect(draftTopic).toBeVisible()
+  const draftSidebarId = await draftTopic.getAttribute('data-sidebar-id')
+  const newTopicId = draftSidebarId?.split(':').at(-1) || ''
+  expect(newTopicId).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i)
+  expect(newTopicId).not.toBe(existingTopicId)
+  await expect(page).toHaveURL(new RegExp(`/groups/${roomId}/${newTopicId}$`))
+
+  const content = '新的发布验收话题'
+  const sendRequestPromise = page.waitForRequest(request => {
+    const url = new URL(request.url())
+    return request.method() === 'POST' && url.pathname === `/api/app/groups/rooms/${roomId}/messages`
+  })
+  await page.getByRole('textbox', { name: '发消息给群聊,输入 @ 提及 Agent' }).fill(content)
+  await page.getByRole('button', { name: '发送消息' }).click()
+  const sendRequest = await sendRequestPromise
+  const sentPayload = sendRequest.postDataJSON() as { topicId: string; content: string; clientMessageId: string }
+  expect(sentPayload).toMatchObject({ topicId: newTopicId, content })
+
+  const createdTopic = sidebar.locator(`[data-sidebar-id="topic:${roomId}:${newTopicId}"]`)
+  const sentMessage = page.locator(`[data-message-id="${sentPayload.clientMessageId}"]`)
+  await expect(createdTopic).toContainText(content)
+  await expect(sentMessage).toContainText(content)
+
+  await page.reload()
+  await expect(createdTopic).toContainText(content)
+  await expect(sentMessage).toContainText(content)
+})
+
 test('renders persisted user file markers as attachment cards', async ({ page }) => {
 test('renders persisted user file markers as attachment cards', async ({ page }) => {
   await page.goto('/chat/session-demo?profile=yaoyao')
   await page.goto('/chat/session-demo?profile=yaoyao')
   const userMessage = page.locator('[data-message-id="message-user-file"]')
   const userMessage = page.locator('[data-message-id="message-user-file"]')

+ 46 - 10
tests/fixtures/fake-hermes.mjs

@@ -5,6 +5,8 @@ const port = Number(process.env.FAKE_HERMES_PORT || 19119)
 const epoch = '11111111-1111-4111-8111-111111111111'
 const epoch = '11111111-1111-4111-8111-111111111111'
 const roomId = '22222222-2222-4222-8222-222222222222'
 const roomId = '22222222-2222-4222-8222-222222222222'
 const agentId = '33333333-3333-4333-8333-333333333333'
 const agentId = '33333333-3333-4333-8333-333333333333'
+const topicId = '77777777-7777-4777-8777-777777777777'
+const releaseTopicId = '88888888-8888-4888-8888-888888888888'
 const now = () => Date.now() / 1000
 const now = () => Date.now() / 1000
 let groupCursor = 0
 let groupCursor = 0
 const groupClients = new Set()
 const groupClients = new Set()
@@ -57,7 +59,7 @@ const messages = [
   { id: 'message-thinking-tool', role: 'assistant', content: '', reasoning_content: '先检索文件,再归纳结果。', tool_calls: [{ id: 'tool-thinking', type: 'function', function: { name: 'file_search', arguments: '{"query":"产物"}' } }], timestamp: now() - 177 },
   { id: 'message-thinking-tool', role: 'assistant', content: '', reasoning_content: '先检索文件,再归纳结果。', tool_calls: [{ id: 'tool-thinking', type: 'function', function: { name: 'file_search', arguments: '{"query":"产物"}' } }], timestamp: now() - 177 },
   { id: 'message-tool-call', role: 'assistant', content: '', tool_calls: [{ id: 'tool-1', type: 'function', function: { name: 'file_search', arguments: '{"query":"产物"}' } }], timestamp: now() - 176 },
   { id: 'message-tool-call', role: 'assistant', content: '', tool_calls: [{ id: 'tool-1', type: 'function', function: { name: 'file_search', arguments: '{"query":"产物"}' } }], timestamp: now() - 176 },
   { id: 'message-tool', role: 'tool', tool_name: 'file_search', tool_call_id: 'tool-1', summary: '扫描产物目录', result: { path: '/tmp/demo-report.pdf', count: 1 }, timestamp: now() - 175 },
   { id: 'message-tool', role: 'tool', tool_name: 'file_search', tool_call_id: 'tool-1', summary: '扫描产物目录', result: { path: '/tmp/demo-report.pdf', count: 1 }, timestamp: now() - 175 },
-  { id: 'message-assistant', role: 'assistant', content: '已整理完成。下面是 **验收摘要**:\n\n## 验收摘要\n\n- 普通聊天已连接\n- 群聊 v2 已就绪\n- [打开报告](https://example.com/report)', reasoning_content: '先核对会话与文件索引。', timestamp: now() - 170 },
+  { id: 'message-assistant', role: 'assistant', content: '已整理完成。下面是 **验收摘要**:\n\n## 验收摘要\n\n- 普通聊天已连接\n- 群聊 v4 已就绪\n- [打开报告](https://example.com/report)', reasoning_content: '先核对会话与文件索引。', timestamp: now() - 170 },
   { id: 'message-image', role: 'assistant', content: '预览图:![夭夭 Logo](/brand/AppIcon-1024.png)', timestamp: now() - 160 },
   { id: 'message-image', role: 'assistant', content: '预览图:![夭夭 Logo](/brand/AppIcon-1024.png)', timestamp: now() - 160 },
   { id: 'message-image-second', role: 'assistant', content: '第二张:![夭夭 Logo 2](/brand/AppIcon-1024.png?variant=2)', timestamp: now() - 155 },
   { id: 'message-image-second', role: 'assistant', content: '第二张:![夭夭 Logo 2](/brand/AppIcon-1024.png?variant=2)', timestamp: now() - 155 },
   { id: 'message-legacy-media', role: 'assistant', content: '历史兼容:MEDIA:/brand/AppIcon-1024.png', timestamp: now() - 150 },
   { id: 'message-legacy-media', role: 'assistant', content: '历史兼容:MEDIA:/brand/AppIcon-1024.png', timestamp: now() - 150 },
@@ -67,9 +69,15 @@ const messages = [
   { id: 'message-system', role: 'user', content: '[System: The active model for this chat has changed to gpt-5.6-terra via provider openai.]', timestamp: now() - 130 },
   { id: 'message-system', role: 'user', content: '[System: The active model for this chat has changed to gpt-5.6-terra via provider openai.]', timestamp: now() - 130 },
 ]
 ]
 const groupAgent = { id: agentId, roomId, profile: 'yaoyao', displayName: '夭夭', description: '主 Agent', enabled: true, replyWithoutMention: true, model: 'gpt-5.6', provider: 'openai', reasoningEffort: 'high', fastMode: true, status: 'idle', createdAt: now() - 2000, updatedAt: now() }
 const groupAgent = { id: agentId, roomId, profile: 'yaoyao', displayName: '夭夭', description: '主 Agent', enabled: true, replyWithoutMention: true, model: 'gpt-5.6', provider: 'openai', reasoningEffort: 'high', fastMode: true, status: 'idle', createdAt: now() - 2000, updatedAt: now() }
-const groupMessage = { seq: 1, id: '44444444-4444-4444-8444-444444444444', roomId, senderKind: 'human', senderId: 'demo-user', senderName: '验收用户', content: '大家好,检查一下群聊输入框。', status: 'completed', createdAt: now() - 120, updatedAt: now() - 120 }
-const groupAssistantMessage = { seq: 2, id: '55555555-5555-4555-8555-555555555555', roomId, senderKind: 'agent', senderId: agentId, senderName: '夭夭', content: '历史兼容:MEDIA:/brand/AppIcon-1024.png', status: 'completed', createdAt: now() - 100, updatedAt: now() - 100 }
-const groupMessages = [groupMessage, groupAssistantMessage]
+const groupMessage = { seq: 1, id: '44444444-4444-4444-8444-444444444444', roomId, topicId, senderKind: 'human', senderId: 'demo-user', senderName: '验收用户', content: '大家好,检查一下群聊输入框。', status: 'completed', createdAt: now() - 120, updatedAt: now() - 120 }
+const groupAssistantMessage = { seq: 2, id: '55555555-5555-4555-8555-555555555555', roomId, topicId, senderKind: 'agent', senderId: agentId, senderName: '夭夭', content: '历史兼容:MEDIA:/brand/AppIcon-1024.png', status: 'completed', createdAt: now() - 100, updatedAt: now() - 100 }
+const releaseTopicMessage = { seq: 3, id: '99999999-9999-4999-8999-999999999999', roomId, topicId: releaseTopicId, senderKind: 'human', senderId: 'demo-user', senderName: '验收用户', content: '请核对发布话题的独立历史。', status: 'completed', createdAt: now() - 500, updatedAt: now() - 500 }
+const groupMessages = [groupMessage, groupAssistantMessage, releaseTopicMessage]
+const groupTopics = [
+  { id: topicId, roomId, title: '设计验收', preview: groupAssistantMessage.content, messageCount: 2, createdAt: now() - 120, updatedAt: now() - 100 },
+  { id: releaseTopicId, roomId, title: '发布检查', preview: releaseTopicMessage.content, messageCount: 1, createdAt: now() - 500, updatedAt: now() - 500 },
+]
+let nextGroupSeq = 4
 const room = { id: roomId, name: '设计与工程协作', cwd: '/tmp', createdAt: now() - 2000, updatedAt: now(), archived: false, agentCount: 1, maxReplyRounds: -1, lastMessage: groupAssistantMessage, unreadCount: 0 }
 const room = { id: roomId, name: '设计与工程协作', cwd: '/tmp', createdAt: now() - 2000, updatedAt: now(), archived: false, agentCount: 1, maxReplyRounds: -1, lastMessage: groupAssistantMessage, unreadCount: 0 }
 const rpcRequests = []
 const rpcRequests = []
 const previewPdf = Buffer.from('JVBERi0xLjQKMSAwIG9iago8PC9UeXBlL0NhdGFsb2cvUGFnZXMgMiAwIFI+PgplbmRvYmoKMiAwIG9iago8PC9UeXBlL1BhZ2VzL0tpZHNbMyAwIFJdL0NvdW50IDE+PgplbmRvYmoKMyAwIG9iago8PC9UeXBlL1BhZ2UvUGFyZW50IDIgMCBSL01lZGlhQm94WzAgMCAyMDAgMjAwXT4+CmVuZG9iagp4cmVmCjAgNAowMDAwMDAwMDAwIDY1NTM1IGYgCjAwMDAwMDAwMTAgMDAwMDAgbiAKMDAwMDAwMDA1MyAwMDAwMCBuIAowMDAwMDAwMTA1IDAwMDAwIG4gCnRyYWlsZXIKPDwvU2l6ZSA0L1Jvb3QgMSAwIFI+PgpzdGFydHhyZWYKMTY2CiUlRU9G', 'base64')
 const previewPdf = Buffer.from('JVBERi0xLjQKMSAwIG9iago8PC9UeXBlL0NhdGFsb2cvUGFnZXMgMiAwIFI+PgplbmRvYmoKMiAwIG9iago8PC9UeXBlL1BhZ2VzL0tpZHNbMyAwIFJdL0NvdW50IDE+PgplbmRvYmoKMyAwIG9iago8PC9UeXBlL1BhZ2UvUGFyZW50IDIgMCBSL01lZGlhQm94WzAgMCAyMDAgMjAwXT4+CmVuZG9iagp4cmVmCjAgNAowMDAwMDAwMDAwIDY1NTM1IGYgCjAwMDAwMDAwMTAgMDAwMDAgbiAKMDAwMDAwMDA1MyAwMDAwMCBuIAowMDAwMDAwMTA1IDAwMDAwIG4gCnRyYWlsZXIKPDwvU2l6ZSA0L1Jvb3QgMSAwIFI+PgpzdGFydHhyZWYKMTY2CiUlRU9G', 'base64')
@@ -131,7 +139,7 @@ const server = createServer(async (request, response) => {
     return session ? json(response, 200, session) : json(response, 404, { detail: 'Not found' })
     return session ? json(response, 200, session) : json(response, 404, { detail: 'Not found' })
   }
   }
   if (url.pathname === '/api/session-unread') return json(response, 200, { items: { 'session-demo': 0 } })
   if (url.pathname === '/api/session-unread') return json(response, 200, { items: { 'session-demo': 0 } })
-  if (url.pathname === '/api/plugins/yaoyao/v1/capabilities') return json(response, 200, { protocolVersion: 3, journalEpoch: epoch, latestCursor: groupCursor, limits: { maxAgentsPerRoom: 8, maxMessageBytes: 65536, maxMessagePageSize: 100, defaultMaxReplyRounds: 3, unlimitedReplyRoundsValue: -1, maxAgentDisplayNameLength: 100 }, eventTypes: ['message.upsert', 'agent.status'] })
+  if (url.pathname === '/api/plugins/yaoyao/v1/capabilities') return json(response, 200, { protocolVersion: 4, journalEpoch: epoch, latestCursor: groupCursor, limits: { maxAgentsPerRoom: 8, maxMessageBytes: 65536, maxMessagePageSize: 100, defaultMaxReplyRounds: 3, unlimitedReplyRoundsValue: -1, maxAgentDisplayNameLength: 100 }, eventTypes: ['message.upsert', 'topic.updated', 'run.updated', 'agent.status'] })
   if (url.pathname === '/api/plugins/yaoyao/v1/rooms') return json(response, 200, { items: [room], nextCursor: null })
   if (url.pathname === '/api/plugins/yaoyao/v1/rooms') return json(response, 200, { items: [room], nextCursor: null })
   if (url.pathname === `/api/plugins/yaoyao/v1/rooms/${roomId}`) return json(response, 200, { ...room, agents: [groupAgent], runs: [], pendingInteractions: [], latestCursor: groupCursor })
   if (url.pathname === `/api/plugins/yaoyao/v1/rooms/${roomId}`) return json(response, 200, { ...room, agents: [groupAgent], runs: [], pendingInteractions: [], latestCursor: groupCursor })
   if (url.pathname === `/api/plugins/yaoyao/v1/rooms/${roomId}/agents/${agentId}` && request.method === 'PATCH') {
   if (url.pathname === `/api/plugins/yaoyao/v1/rooms/${roomId}/agents/${agentId}` && request.method === 'PATCH') {
@@ -143,16 +151,44 @@ const server = createServer(async (request, response) => {
     groupAgent.updatedAt = now()
     groupAgent.updatedAt = now()
     return json(response, 200, groupAgent)
     return json(response, 200, groupAgent)
   }
   }
+  if (url.pathname === `/api/plugins/yaoyao/v1/rooms/${roomId}/topics`) {
+    return json(response, 200, { items: [...groupTopics].sort((a, b) => b.updatedAt - a.updatedAt), nextCursor: null })
+  }
   if (url.pathname === `/api/plugins/yaoyao/v1/rooms/${roomId}/messages` && request.method === 'POST') {
   if (url.pathname === `/api/plugins/yaoyao/v1/rooms/${roomId}/messages` && request.method === 'POST') {
     const input = await body(request)
     const input = await body(request)
-    const sent = { ...groupMessage, seq: 3, id: input.clientMessageId, clientMessageId: input.clientMessageId, content: input.content, createdAt: now(), updatedAt: now() }
-    json(response, 200, { message: sent, runs: [{ id: '66666666-6666-4666-8666-666666666666', roomId, agentId, status: 'queued' }] })
-    setTimeout(() => broadcastGroup('agent.status', { roomId, agentId, status: 'queued', runId: '66666666-6666-4666-8666-666666666666' }), 40)
-    setTimeout(() => broadcastGroup('agent.status', { roomId, agentId, status: 'running', runId: '66666666-6666-4666-8666-666666666666' }), 650)
+    if (typeof input.topicId !== 'string' || !input.topicId) return json(response, 400, { detail: 'topicId is required for group protocol v4' })
+    const sentAt = now()
+    const sent = { ...groupMessage, seq: nextGroupSeq++, id: input.clientMessageId, topicId: input.topicId, clientMessageId: input.clientMessageId, content: input.content, createdAt: sentAt, updatedAt: sentAt }
+    groupMessages.push(sent)
+    let topic = groupTopics.find(item => item.id === input.topicId)
+    if (topic) {
+      topic.preview = sent.content
+      topic.messageCount = groupMessages.filter(message => message.topicId === input.topicId).length
+      topic.updatedAt = sentAt
+    } else {
+      topic = { id: input.topicId, roomId, title: String(input.content || '').trim().split('\n')[0].slice(0, 40) || '新话题', preview: sent.content, messageCount: 1, createdAt: sentAt, updatedAt: sentAt }
+      groupTopics.push(topic)
+    }
+    room.lastMessage = sent
+    room.updatedAt = sentAt
+    const runId = '66666666-6666-4666-8666-666666666666'
+    const run = { id: runId, roomId, topicId: input.topicId, agentId, triggerMessageId: sent.id, responseMessageId: '', rootMessageId: sent.id, depth: 0, status: 'queued', runtimeSessionId: null, error: '', replyMode: 'automatic', createdAt: sentAt, updatedAt: sentAt }
+    json(response, 200, { message: sent, runs: [run] })
+    setTimeout(() => broadcastGroup('message.upsert', sent), 20)
+    setTimeout(() => broadcastGroup('topic.updated', topic), 30)
+    setTimeout(() => broadcastGroup('run.updated', { ...run, status: 'queued', updatedAt: now() }), 40)
+    setTimeout(() => broadcastGroup('agent.status', { roomId, agentId, status: 'queued', runId }), 40)
+    setTimeout(() => broadcastGroup('run.updated', { ...run, status: 'running', runtimeSessionId: 'group-runtime-demo', updatedAt: now() }), 650)
+    setTimeout(() => broadcastGroup('agent.status', { roomId, agentId, status: 'running', runId }), 650)
+    setTimeout(() => broadcastGroup('run.updated', { ...run, status: 'completed', runtimeSessionId: 'group-runtime-demo', updatedAt: now() }), 1800)
     setTimeout(() => broadcastGroup('agent.status', { roomId, agentId, status: 'idle', runId: null }), 1800)
     setTimeout(() => broadcastGroup('agent.status', { roomId, agentId, status: 'idle', runId: null }), 1800)
     return
     return
   }
   }
-  if (url.pathname === `/api/plugins/yaoyao/v1/rooms/${roomId}/messages`) return json(response, 200, { items: groupMessages })
+  if (url.pathname === `/api/plugins/yaoyao/v1/rooms/${roomId}/messages`) {
+    const selectedTopicId = url.searchParams.get('topicId')
+    const items = selectedTopicId ? groupMessages.filter(message => message.topicId === selectedTopicId) : groupMessages
+    return json(response, 200, { items })
+  }
   if (url.pathname === '/api/plugins/yaoyao/files') return json(response, 200, { items: [{ id: 1, path: '/tmp/demo-report.pdf', name: 'demo-report.pdf', extension: 'pdf', mimeType: 'application/pdf', size: 204800, modifiedAt: now(), exists: true, origins: [{ profile: 'yaoyao', sessionId: 'session-demo', sessionTitle: '夭夭 Web 验收会话', messageId: 'message-assistant', authorKind: 'assistant', authorName: '夭夭', observedAt: now() }] }], nextCursor: null, total: 1 })
   if (url.pathname === '/api/plugins/yaoyao/files') return json(response, 200, { items: [{ id: 1, path: '/tmp/demo-report.pdf', name: 'demo-report.pdf', extension: 'pdf', mimeType: 'application/pdf', size: 204800, modifiedAt: now(), exists: true, origins: [{ profile: 'yaoyao', sessionId: 'session-demo', sessionTitle: '夭夭 Web 验收会话', messageId: 'message-assistant', authorKind: 'assistant', authorName: '夭夭', observedAt: now() }] }], nextCursor: null, total: 1 })
   if (url.pathname === '/api/plugins/yaoyao/1/download') {
   if (url.pathname === '/api/plugins/yaoyao/1/download') {
     response.writeHead(200, { 'Content-Type': 'application/pdf', 'Content-Length': previewPdf.length })
     response.writeHead(200, { 'Content-Type': 'application/pdf', 'Content-Length': previewPdf.length })

+ 28 - 0
tests/server/app.test.ts

@@ -311,6 +311,34 @@ describe('8800 BFF', () => {
     expect(history.search.get('profile')).toBe('default')
     expect(history.search.get('profile')).toBe('default')
   })
   })
 
 
+  it('proxies topic pagination and forwards topic-scoped message queries', async () => {
+    const records: RecordedRequest[] = []
+    const runtime = createApplication({ config: makeConfig(), fetchImpl: fakeGateway(records) })
+    runtimes.push(runtime)
+    const roomID = '11111111-1111-4111-8111-111111111111'
+    const topicID = '22222222-2222-4222-8222-222222222222'
+
+    await request(runtime.app.callback())
+      .get(`/api/app/groups/rooms/${roomID}/topics?limit=25&cursor=next-topic&ignored=drop-me`)
+      .set('Host', '127.0.0.1:8800')
+      .expect(200)
+    const topics = records.at(-1)!
+    expect(topics.path).toBe(`/api/plugins/yaoyao/v1/rooms/${roomID}/topics`)
+    expect(topics.search.get('limit')).toBe('25')
+    expect(topics.search.get('cursor')).toBe('next-topic')
+    expect(topics.search.has('ignored')).toBe(false)
+
+    await request(runtime.app.callback())
+      .get(`/api/app/groups/rooms/${roomID}/messages?topicId=${topicID}&beforeSeq=40&limit=20`)
+      .set('Host', '127.0.0.1:8800')
+      .expect(200)
+    const messages = records.at(-1)!
+    expect(messages.path).toBe(`/api/plugins/yaoyao/v1/rooms/${roomID}/messages`)
+    expect(messages.search.get('topicId')).toBe(topicID)
+    expect(messages.search.get('beforeSeq')).toBe('40')
+    expect(messages.search.get('limit')).toBe('20')
+  })
+
   it('keeps unknown API routes out of the SPA fallback', async () => {
   it('keeps unknown API routes out of the SPA fallback', async () => {
     const runtime = createApplication({ config: makeConfig(), fetchImpl: fakeGateway([]) })
     const runtime = createApplication({ config: makeConfig(), fetchImpl: fakeGateway([]) })
     runtimes.push(runtime)
     runtimes.push(runtime)