فهرست منبع

Add topic support to group chat navigation and messaging

samien 2 ماه پیش
والد
کامیت
2fa2ccb864

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

@@ -1,10 +1,10 @@
 import type {
-  GroupAgent, GroupCapabilities, GroupInteraction, GroupMessagePage, GroupRoomDetail, GroupRoomPage, JsonValue, UploadReference,
+  GroupAgent, GroupCapabilities, GroupInteraction, GroupMessagePage, GroupRoomDetail, GroupRoomPage, GroupRun, GroupTopicPage, JsonValue, UploadReference,
 } from '@shared/types'
 import { apiRequest, apiUrl, unwrapData } from './client'
 import {
   normalizeGroupAgent, normalizeGroupCapabilities, normalizeGroupInteraction, normalizeGroupMessage,
-  normalizeGroupRoom, normalizeGroupRoomDetail, record, values,
+  normalizeGroupRoom, normalizeGroupRoomDetail, normalizeGroupRun, normalizeGroupTopic, record, values,
 } from '@/utils/normalize'
 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)}`)))
 }
 
+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 {
   profile: 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 不能同时使用')
   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)
   return { items: values(source.items ?? source.messages ?? payload).map(normalizeGroupMessage).filter(message => message.id) }
@@ -96,13 +107,14 @@ export async function sendGroupMessage(
   content: string,
   mentionAgentIds: string[],
   clientMessageId: string,
+  topicId?: string,
   uploadIds: string[] = [],
   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`, {
-    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> {

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

@@ -38,13 +38,14 @@ const emit = defineEmits<{
   search: [value: string]
   more: [id: string, event: MouseEvent]
   contextMenu: [id: string, event: MouseEvent]
+  toggle: [id: string]
   loadMore: []
 }>()
 
 const searchInput = ref<HTMLInputElement | null>(null)
 const searchOpen = ref(Boolean(props.search))
 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
 }, {}))
 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
             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"
             tabindex="0"
             :aria-selected="row.item.id === activeId || row.item.active"
+            :data-sidebar-id="row.item.id"
             @click="emit('select', row.item.id)"
             @contextmenu.prevent.stop="emit('contextMenu', row.item.id, $event)"
             @keydown.enter.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 }">
               <AppIcon v-if="row.item.icon" :name="row.item.icon" :size="15" />
               <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>
               </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" />
             </button>
           </div>
@@ -188,6 +201,13 @@ defineExpose({ focusSearch })
 .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__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.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; }

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

@@ -7,6 +7,10 @@ export type SidebarItem = {
   active?: boolean
   unread?: number
   pinned?: boolean
+  nested?: boolean
+  showMore?: boolean
+  expandable?: boolean
+  expanded?: boolean
   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: [
     { path: '/', redirect: '/chat' },
     { 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: '/artifacts', 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 { isSupportedGroupProtocolVersion, SUPPORTED_GROUP_PROTOCOL_VERSION_LABEL } from '@shared/types'
 import type {
-  GroupAgent, GroupCapabilities, GroupMessage, GroupMessagePage, GroupRoomDetail, GroupRoomSummary, GroupSocketEnvelope, RealtimeConnectionState,
+  GroupAgent, GroupCapabilities, GroupMessage, GroupMessagePage, GroupRoomDetail, GroupRoomSummary, GroupRun, GroupSocketEnvelope, GroupTopicSummary, RealtimeConnectionState,
 } from '@shared/types'
 import {
   addGroupAgent, approveGroupInteraction as approveApi, archiveGroupRoom as archiveApi,
   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,
   updateGroupRoom as updateRoomApi, uploadGroupFiles, type AgentSeed,
 } from '@/api/groups'
 import { GroupEventSocket } from '@/api/realtime'
 import { ScopedCache } from '@/utils/cache'
 import {
-  applyGroupEnvelope, snapshotGroupRoom, upsertGroupMessage, type GroupProtocolState,
+  applyGroupEnvelope, snapshotGroupRoom, upsertGroupMessage, upsertGroupTopic, type GroupProtocolState,
 } from '@/utils/groupProtocol'
 import { createUuid } from '@/utils/id'
 import { ApiError } from '@/api/client'
@@ -23,25 +23,30 @@ import { useAuthStore } from './auth'
 type GroupAvailability = 'checking' | 'available' | 'unsupported' | 'unavailable'
 
 interface CachedGroups { state: GroupProtocolState; savedAt: number }
-const cache = new ScopedCache<CachedGroups>('groups-v2')
+const cache = new ScopedCache<CachedGroups>('groups-v3')
 
 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 topicTitle(content: string): string {
+  return content.split(/\s+/).filter(Boolean).join(' ').slice(0, 100) || '新话题'
+}
+
 export const useGroupsStore = defineStore('groups', () => {
   const auth = useAuthStore()
   const availability = ref<GroupAvailability>('checking')
   const capabilities = ref<GroupCapabilities>()
   const protocol = shallowRef<GroupProtocolState>(initialProtocol())
   const selectedRoomId = ref<string>()
+  const selectedTopicId = ref<string>()
   const connectionState = ref<RealtimeConnectionState>('idle')
   const isLoading = ref(false)
   const isSending = ref(false)
   const error = ref<string>()
-  const hasOlderByRoom = ref<Record<string, boolean>>({})
+  const hasOlderByConversation = ref<Record<string, boolean>>({})
   const socket = new GroupEventSocket()
   let generation = 0
   let rebuildPromise: Promise<void> | undefined
@@ -54,10 +59,17 @@ export const useGroupsStore = defineStore('groups', () => {
 
   const rooms = computed(() => protocol.value.rooms)
   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 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'}` }
 
@@ -80,6 +92,36 @@ export const useGroupsStore = defineStore('groups', () => {
     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> {
     await socket.connect({
       epoch: anchor.journalEpoch,
@@ -164,11 +206,16 @@ export const useGroupsStore = defineStore('groups', () => {
           rooms: loadedRooms,
           roomDetails: {},
           messagesByRoom: {},
+          topicsByRoom: {},
+          messagesByTopic: {},
         }
         const restoreId = selectedRoomId.value && loadedRooms.some(room => room.id === selectedRoomId.value)
           ? selectedRoomId.value
           : 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
         availability.value = 'available'
         recoveryAttempt = 0
@@ -222,8 +269,9 @@ export const useGroupsStore = defineStore('groups', () => {
       stop()
       protocol.value = initialProtocol()
       selectedRoomId.value = undefined
+      selectedTopicId.value = undefined
       capabilities.value = undefined
-      hasOlderByRoom.value = {}
+      hasOlderByConversation.value = {}
     }
   })
 
@@ -231,13 +279,24 @@ export const useGroupsStore = defineStore('groups', () => {
     roomId: string,
     expectedGeneration = generation,
     expectedSelection = selectionGeneration,
+    requestedTopicId?: string,
   ): Promise<void> {
     const startingCursor = protocol.value.cursor
     loadingRoomEvents.set(roomId, [])
     let detail: GroupRoomDetail
     let page: GroupMessagePage
+    let loadedTopics: GroupTopicSummary[] | undefined
+    let topicId: string | undefined
     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) {
       loadingRoomEvents.delete(roomId)
       throw cause
@@ -247,17 +306,21 @@ export const useGroupsStore = defineStore('groups', () => {
       return
     }
     selectedRoomId.value = roomId
+    selectedTopicId.value = topicId
     let merged = page.items.sort((a, b) => a.seq - b.seq)
     // 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
     // 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)
     }
     const liveDetail = protocol.value.cursor > startingCursor
       ? protocol.value.roomDetails[roomId]
       : 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) ?? []) {
       const durableCursor = protocol.value.cursor
       const replayBase = envelope.cursor == null
@@ -267,41 +330,121 @@ export const useGroupsStore = defineStore('groups', () => {
       protocol.value = { ...replayed, cursor: Math.max(durableCursor, replayed.cursor) }
     }
     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 previousRoomId = selectedRoomId.value
+    const previousTopicId = selectedTopicId.value
+    const sameRoom = 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
-      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 }
+      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 createRoom(input: { name: string; cwd?: string; agents: AgentSeed[]; maxReplyRounds?: number }): Promise<GroupRoomDetail> {
     const detail = await createApi(input)
-    protocol.value = snapshotGroupRoom(protocol.value, detail, [])
+    protocol.value = snapshotGroupRoom(protocol.value, detail, [], topicProtocol.value ? { topics: [] } : {})
     selectedRoomId.value = detail.id
+    selectedTopicId.value = undefined
+    if (topicProtocol.value) await startNewTopic()
     await saveCache()
     return detail
   }
 
   async function updateRoom(roomId: string, input: { name?: string; cwd?: string; maxReplyRounds?: number }): Promise<GroupRoomDetail> {
     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()
     return detail
   }
 
   async function archiveRoom(roomId: string): Promise<void> {
     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()
   }
 
@@ -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> {
     const agent = await addGroupAgent(roomId, input)
     publishAgent(roomId, agent)
@@ -342,6 +500,11 @@ export const useGroupsStore = defineStore('groups', () => {
   async function sendMessage(content: string, mentionAgentIds: string[] = [], files: File[] = []): Promise<void> {
     const roomId = selectedRoomId.value
     if (!roomId) return
+    let topicId = topicProtocol.value ? selectedTopicId.value : undefined
+    if (topicProtocol.value && !topicId) {
+      topicId = createUuid()
+      selectedTopicId.value = topicId
+    }
     const text = content.trim()
     let uploadIds: string[] = []
     let optimisticText = text
@@ -355,10 +518,12 @@ export const useGroupsStore = defineStore('groups', () => {
     const clientMessageId = createUuid()
     const stableRequestId = createUuid()
     const now = Date.now() / 1000
+    const wasNewTopic = Boolean(topicId && !(protocol.value.topicsByRoom[roomId] ?? []).some(topic => topic.id === topicId))
     const optimistic: GroupMessage = {
       seq: Math.max(0, ...messages.value.map(message => message.seq)) + 1,
       id: clientMessageId,
       roomId,
+      topicId: topicId || null,
       senderKind: 'human',
       senderId: auth.user?.id ?? 'me',
       senderName: auth.user?.displayName ?? auth.user?.username ?? '我',
@@ -372,12 +537,36 @@ export const useGroupsStore = defineStore('groups', () => {
       createdAt: 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
     try {
@@ -385,8 +574,16 @@ export const useGroupsStore = defineStore('groups', () => {
       const resolvedMentions = mentionAgentIds.includes('all')
         ? [...enabled]
         : [...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,
         messagesByRoom: {
           ...protocol.value.messagesByRoom,
@@ -401,7 +598,14 @@ export const useGroupsStore = defineStore('groups', () => {
         error: explicitRejection ? errorMessage(cause) : '送达状态未知;刷新房间后再决定是否重试',
         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,
         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 oldest = messages.value.find(message => message.seq > 0)?.seq
     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)
-    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> {
@@ -446,9 +654,10 @@ export const useGroupsStore = defineStore('groups', () => {
   }
 
   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,
-    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,
   }
 })

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

@@ -1,8 +1,9 @@
 import type {
-  GroupAgent, GroupInteraction, GroupMessage, GroupRoomDetail, GroupRoomSummary, GroupSocketEnvelope, JsonValue,
+  GroupAgent, GroupInteraction, GroupMessage, GroupRoomDetail, GroupRoomSummary, GroupSocketEnvelope, GroupTopicSummary,
 } from '@shared/types'
 import {
-  normalizeGroupAgent, normalizeGroupInteraction, normalizeGroupMessage, normalizeGroupRoom, normalizeGroupRoomDetail, record, string,
+  normalizeGroupAgent, normalizeGroupInteraction, normalizeGroupMessage, normalizeGroupRoom, normalizeGroupRoomDetail,
+  normalizeGroupRun, normalizeGroupTopic, record, string,
 } from './normalize'
 
 export interface GroupProtocolState {
@@ -11,6 +12,8 @@ export interface GroupProtocolState {
   rooms: GroupRoomSummary[]
   roomDetails: Record<string, GroupRoomDetail>
   messagesByRoom: Record<string, GroupMessage[]>
+  topicsByRoom: Record<string, GroupTopicSummary[]>
+  messagesByTopic: Record<string, GroupMessage[]>
 }
 
 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
 }
 
+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[] {
   const index = items.findIndex(item => item.id === incoming.id
     || Boolean(item.clientMessageId && incoming.clientMessageId && item.clientMessageId === incoming.clientMessageId))
@@ -56,6 +63,8 @@ export function applyGroupEnvelope(state: GroupProtocolState, envelope: GroupSoc
     rooms: [...state.rooms],
     roomDetails: { ...state.roomDetails },
     messagesByRoom: { ...state.messagesByRoom },
+    topicsByRoom: { ...state.topicsByRoom },
+    messagesByTopic: { ...state.messagesByTopic },
   }
   switch (envelope.event) {
     case 'room.created':
@@ -97,12 +106,22 @@ export function applyGroupEnvelope(state: GroupProtocolState, envelope: GroupSoc
     case 'message.upsert': {
       const message = normalizeGroupMessage(payload)
       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
         ? { ...room, lastMessage: message, updatedAt: Math.max(room.updatedAt, message.updatedAt) }
         : room)
       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.resolved': {
       const interaction = normalizeGroupInteraction(payload)
@@ -116,10 +135,10 @@ export function applyGroupEnvelope(state: GroupProtocolState, envelope: GroupSoc
       break
     }
     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
     }
@@ -131,13 +150,22 @@ export function snapshotGroupRoom(
   state: GroupProtocolState,
   roomValue: unknown,
   messages: GroupMessage[],
+  options: { topicId?: string; topics?: GroupTopicSummary[] } = {},
 ): GroupProtocolState {
   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 {
     ...state,
     rooms: upsert(state.rooms, normalizeGroupRoom(roomValue)),
     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),
   }
 }

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

@@ -10,6 +10,8 @@ import type {
   GroupMessage,
   GroupRoomDetail,
   GroupRoomSummary,
+  GroupRun,
+  GroupTopicSummary,
   JsonValue,
   ModelOption,
   Profile,
@@ -364,6 +366,7 @@ export function normalizeGroupMessage(value: unknown): GroupMessage {
   const status = string(source.status, 'unknown') as GroupMessage['status']
   return {
     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',
     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,
@@ -377,13 +380,46 @@ export function normalizeGroupMessage(value: unknown): GroupMessage {
 export function normalizeGroupInteraction(value: unknown): GroupInteraction {
   const source = record(value)
   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'],
     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')),
   }
 }
 
+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 {
   const source = record(value)
   const rawAgents = source.agents
@@ -402,7 +438,7 @@ export function normalizeGroupRoomDetail(value: unknown): GroupRoomDetail {
   return {
     id: summary.id, name: summary.name, cwd: summary.cwd, createdAt: summary.createdAt, updatedAt: summary.updatedAt,
     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),
     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 ResourceSidebar from '@/components/app/ResourceSidebar.vue'
 import FloatingResourceSearch from '@/components/app/FloatingResourceSearch.vue'
+import type { SidebarItem } from '@/components/app/types'
 import WorkspaceView from '@/components/workspace/WorkspaceView.vue'
 import { loadComposerFile } from '@/components/workspace/pendingComposer'
 import { readAgentShowThinking, writeAgentShowThinking } from '@/utils/sessionPreferences'
@@ -43,9 +44,48 @@ const modelOptionsLoading = ref<Record<string, boolean>>({})
 const modelOptionsError = ref<Record<string, string>>({})
 const agentUpdateBusy = ref<Record<string, boolean>>({})
 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 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 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))
@@ -63,8 +103,22 @@ const mentionOptions = computed<ComposerOption[]>(() => [
   { id: 'all', label: '所有人', detail: '通知房间内全部 Agent' },
   ...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
-  .filter(agent => agent.status === 'running')
+  .filter(agent => typingAgentIds.value.has(agent.id))
   .map(agent => agent.displayName || agent.profile))])
 const typingActivity = computed(() => {
   if (!connected.value) return ''
@@ -74,6 +128,16 @@ const typingActivity = computed(() => {
   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') {
   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)
 }
 
+function stopActiveTopic() {
+  for (const agentId of activeAgentIds.value) void groups.interruptAgent(agentId).catch(() => undefined)
+}
+
 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
 }
 
+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 }) {
   const detail = await groups.createRoom({
     name: payload.name,
@@ -96,7 +254,7 @@ async function createRoom(payload: { name: string; profiles: string[]; autoReply
     maxReplyRounds: payload.replyRounds,
   })
   createOpen.value = false
-  await router.push(`/groups/${encodeURIComponent(detail.id)}`)
+  await router.push(groupRoute(detail.id, groups.selectedTopicId))
 }
 
 async function send(payload: ComposerSubmit) {
@@ -152,7 +310,7 @@ async function archiveRoom() {
   if (!groups.selectedRoom) return
   await groups.archiveRoom(groups.selectedRoom.id)
   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 }) {
@@ -190,20 +348,43 @@ async function addMediaToComposer(media: PreviewMedia) {
 }
 
 onMounted(async () => {
+  document.addEventListener('pointerdown', handleRoomActionPointer)
+  document.addEventListener('keydown', handleRoomActionKey)
   restoreShowThinking()
   try {
     await groups.start()
     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 */ }
 })
 
-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) })
 </script>
 
@@ -224,8 +405,10 @@ watch(() => auth.activeProfile?.name, profile => { if (profile) restoreShowThink
         search-placeholder="搜索群聊"
         empty-title="还没有群聊"
         empty-description="新建群聊,邀请 1–8 个 Agent 一起协作。"
-        @select="selectRoom"
-        @more="id => { selectRoom(id); managerOpen = true }"
+        @select="selectSidebarItem"
+        @more="openRoomManager"
+        @context-menu="openRoomActions"
+        @toggle="toggleRoomTopics"
       />
     </template>
 
@@ -235,8 +418,8 @@ watch(() => auth.activeProfile?.name, profile => { if (profile) restoreShowThink
     <div v-else class="groups-workspace">
       <MessageTimeline
         :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"
         :has-older="groups.hasMoreBefore"
         :connected="connected"
@@ -244,8 +427,8 @@ watch(() => auth.activeProfile?.name, profile => { if (profile) restoreShowThink
         :show-tools="showThinking"
         :interaction="activeInteraction"
         :mention-names="mentionNames"
-        empty-title="让多个 Agent 一起工作"
-        empty-description="使用 @ 提及指定 Agent,或直接发送消息触发已启用自动回复的成员。"
+        :empty-title="groups.topicProtocol ? '开始一个新话题' : '让多个 Agent 一起工作'"
+        :empty-description="groups.topicProtocol ? '第一条消息会创建独立话题,各话题分别保留 Agent 上下文。' : '使用 @ 提及指定 Agent,或直接发送消息触发已启用自动回复的成员。'"
         @load-older="groups.loadOlder"
         @quote="quoted = $event"
         @preview="openAttachment"
@@ -264,9 +447,9 @@ watch(() => auth.activeProfile?.name, profile => { if (profile) restoreShowThink
       <ComposerShell
         ref="composer"
         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'"
-        :streaming="groups.agents.some(agent => ['queued', 'running'].includes(agent.status))"
+        :streaming="activeAgentIds.size > 0"
         :sending="groups.isSending"
         :activity-text="typingActivity"
         :tool-trace-visible="showThinking"
@@ -275,7 +458,7 @@ watch(() => auth.activeProfile?.name, profile => { if (profile) restoreShowThink
         :attachments-enabled="uploadsEnabled"
         placeholder="发消息给群聊,输入 @ 提及 Agent"
         @send="send"
-        @stop="groups.agents.filter(agent => ['queued', 'running'].includes(agent.status)).forEach(agent => groups.interruptAgent(agent.id))"
+        @stop="stopActiveTopic"
         @tool-trace-toggle="toggleShowThinking"
         @clear-reference="quoted = null"
       />
@@ -304,14 +487,25 @@ watch(() => auth.activeProfile?.name, profile => { if (profile) restoreShowThink
     </template>
   </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" />
   <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" />
+
+  <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>
 
 <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; }
+.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: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); }
@@ -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-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 (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>

+ 6 - 1
src/server/routes.ts

@@ -669,9 +669,14 @@ export function createApiRouter(dependencies: RouteDependencies): Router {
       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) => {
     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) => {

+ 20 - 1
src/shared/types.ts

@@ -241,7 +241,7 @@ export interface GroupLimits {
 }
 
 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 function isSupportedGroupProtocolVersion(value: number): boolean {
@@ -284,6 +284,7 @@ export interface GroupMessage {
   seq: number
   id: string
   roomId: string
+  topicId?: string | null
   senderKind: 'human' | 'agent' | 'system' | 'unknown'
   senderId: string
   senderName: string
@@ -302,6 +303,7 @@ export interface GroupMessage {
 export interface GroupRun {
   id: string
   roomId: string
+  topicId?: string | null
   agentId: string
   triggerMessageId: string
   responseMessageId: string
@@ -318,6 +320,7 @@ export interface GroupRun {
 export interface GroupInteraction {
   id: string
   roomId: string
+  topicId?: string | null
   agentId: string
   runId: string
   kind: 'approval' | 'clarification' | 'unknown'
@@ -352,6 +355,21 @@ export interface GroupRoomPage {
   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 {
   items: GroupMessage[]
 }
@@ -365,6 +383,7 @@ export type GroupEventType =
   | 'agent.deleted'
   | 'agent.status'
   | 'message.upsert'
+  | 'topic.updated'
   | 'interaction.requested'
   | 'interaction.resolved'
   | 'run.updated'

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

@@ -6,9 +6,15 @@ import {
 
 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 {
-    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,
   }
 }
@@ -17,11 +23,11 @@ function initial(): GroupProtocolState {
   return {
     epoch, cursor: 8,
     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', () => {
     expect(classifyGroupEnvelope(epoch, 8, { type: 'group.ready', epoch, cursor: 8 })).toBe('ready')
     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.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.locator('.desktop-sidebar').getByText('已置顶 1', { exact: true })).toBeVisible()
   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 })).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 }) => {
   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()
   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()
 })
 
-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 expect(page.getByRole('heading', { name: '设计与工程协作' })).toBeVisible()
+  await expect(page.getByRole('heading', { name: '设计验收' })).toBeVisible()
   await page.getByRole('button', { name: '管理群聊' }).click()
 
   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 expect(page.getByRole('heading', { name: '群聊服务暂不可用' })).toBeVisible()
   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 }) => {
@@ -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()
 })
 
+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 }) => {
   await page.goto('/chat/session-demo?profile=yaoyao')
   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 roomId = '22222222-2222-4222-8222-222222222222'
 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
 let groupCursor = 0
 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-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-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-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 },
@@ -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 },
 ]
 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 rpcRequests = []
 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' })
   }
   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/${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') {
@@ -143,16 +151,44 @@ const server = createServer(async (request, response) => {
     groupAgent.updatedAt = now()
     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') {
     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)
     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/1/download') {
     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')
   })
 
+  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 () => {
     const runtime = createApplication({ config: makeConfig(), fetchImpl: fakeGateway([]) })
     runtimes.push(runtime)