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

Add group topic read tracking and room activity events

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

+ 6 - 0
src/client/api/groups.ts

@@ -46,6 +46,12 @@ export async function updateGroupTopic(roomId: string, topicId: string, input: {
   })))
   })))
 }
 }
 
 
+export async function markGroupTopicRead(roomId: string, topicId: string, throughSeq: number): Promise<void> {
+  await apiRequest(`${BASE}/rooms/${encodeURIComponent(roomId)}/topics/${encodeURIComponent(topicId)}/read`, {
+    method: 'PATCH', body: { requestId: requestId(), throughSeq } as JsonValue,
+  })
+}
+
 export interface AgentSeed {
 export interface AgentSeed {
   profile: string
   profile: string
   displayName: string
   displayName: string

+ 12 - 2
src/client/stores/groups.ts

@@ -7,7 +7,7 @@ import type {
 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, getGroupTopics, interruptGroupAgent as interruptApi,
+  getGroupMessages, getGroupRoom, getGroupRooms, getGroupTopics, interruptGroupAgent as interruptApi, markGroupTopicRead,
   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'
@@ -61,6 +61,7 @@ export const useGroupsStore = defineStore('groups', () => {
   const selectedRoom = computed(() => selectedRoomId.value ? protocol.value.roomDetails[selectedRoomId.value] : undefined)
   const selectedRoom = computed(() => selectedRoomId.value ? protocol.value.roomDetails[selectedRoomId.value] : undefined)
   const topicProtocol = computed(() => (capabilities.value?.protocolVersion ?? 0) >= 4)
   const topicProtocol = computed(() => (capabilities.value?.protocolVersion ?? 0) >= 4)
   const hostProtocol = computed(() => (capabilities.value?.protocolVersion ?? 0) >= 5)
   const hostProtocol = computed(() => (capabilities.value?.protocolVersion ?? 0) >= 5)
+  const activityProtocol = computed(() => (capabilities.value?.protocolVersion ?? 0) >= 8)
   const topics = computed(() => selectedRoomId.value ? protocol.value.topicsByRoom[selectedRoomId.value] ?? [] : [])
   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 selectedTopic = computed(() => selectedTopicId.value ? topics.value.find(topic => topic.id === selectedTopicId.value) : undefined)
   const messages = computed(() => topicProtocol.value
   const messages = computed(() => topicProtocol.value
@@ -112,6 +113,13 @@ export const useGroupsStore = defineStore('groups', () => {
     return protocol.value.topicsByRoom[roomId] ?? []
     return protocol.value.topicsByRoom[roomId] ?? []
   }
   }
 
 
+  function markTopicRead(roomId: string, topicId: string | undefined, messages: GroupMessage[]): void {
+    if (!activityProtocol.value || !topicId) return
+    const throughSeq = Math.max(0, ...messages.map(message => message.seq))
+    if (!throughSeq) return
+    void markGroupTopicRead(roomId, topicId, throughSeq).catch(() => undefined)
+  }
+
   async function loadRoomTopics(roomId: string): Promise<void> {
   async function loadRoomTopics(roomId: string): Promise<void> {
     if (!topicProtocol.value || Object.prototype.hasOwnProperty.call(protocol.value.topicsByRoom, roomId)) return
     if (!topicProtocol.value || Object.prototype.hasOwnProperty.call(protocol.value.topicsByRoom, roomId)) return
     const expectedGeneration = generation
     const expectedGeneration = generation
@@ -333,6 +341,7 @@ export const useGroupsStore = defineStore('groups', () => {
     loadingRoomEvents.delete(roomId)
     loadingRoomEvents.delete(roomId)
     const key = topicId || roomId
     const key = topicId || roomId
     hasOlderByConversation.value = { ...hasOlderByConversation.value, [key]: page.items.length >= 100 }
     hasOlderByConversation.value = { ...hasOlderByConversation.value, [key]: page.items.length >= 100 }
+    markTopicRead(roomId, topicId, merged)
   }
   }
 
 
   async function loadTopicSnapshot(
   async function loadTopicSnapshot(
@@ -360,6 +369,7 @@ export const useGroupsStore = defineStore('groups', () => {
       messagesByTopic: { ...protocol.value.messagesByTopic, [topicId]: merged },
       messagesByTopic: { ...protocol.value.messagesByTopic, [topicId]: merged },
     }
     }
     hasOlderByConversation.value = { ...hasOlderByConversation.value, [topicId]: page.items.length >= 100 }
     hasOlderByConversation.value = { ...hasOlderByConversation.value, [topicId]: page.items.length >= 100 }
+    markTopicRead(roomId, topicId, merged)
   }
   }
 
 
   async function selectRoom(roomId: string, requestedTopicId?: string): Promise<void> {
   async function selectRoom(roomId: string, requestedTopicId?: string): Promise<void> {
@@ -662,7 +672,7 @@ export const useGroupsStore = defineStore('groups', () => {
   }
   }
 
 
   return {
   return {
-    availability, capabilities, topicProtocol, hostProtocol, rooms, selectedRoomId, selectedRoom, topics, topicsForRoom, loadRoomTopics, selectedTopicId, selectedTopic,
+    availability, capabilities, topicProtocol, hostProtocol, activityProtocol, rooms, selectedRoomId, selectedRoom, topics, topicsForRoom, loadRoomTopics, selectedTopicId, selectedTopic,
     messages, agents, pendingInteractions,
     messages, agents, pendingInteractions,
     connectionState, isLoading, isSending, error, hasMoreBefore,
     connectionState, isLoading, isSending, error, hasMoreBefore,
     start, stop, refresh, selectRoom, selectTopic, startNewTopic, createRoom, updateRoom, archiveRoom, addAgent, updateAgent,
     start, stop, refresh, selectRoom, selectTopic, startNewTopic, createRoom, updateRoom, archiveRoom, addAgent, updateAgent,

+ 12 - 1
src/client/utils/groupProtocol.ts

@@ -3,7 +3,7 @@ import type {
 } from '@shared/types'
 } from '@shared/types'
 import {
 import {
   normalizeGroupAgent, normalizeGroupInteraction, normalizeGroupMessage, normalizeGroupRoom, normalizeGroupRoomDetail,
   normalizeGroupAgent, normalizeGroupInteraction, normalizeGroupMessage, normalizeGroupRoom, normalizeGroupRoomDetail,
-  normalizeGroupRun, normalizeGroupTopic, record, string,
+  normalizeGroupRoomActivity, normalizeGroupRun, normalizeGroupTopic, record, string,
 } from './normalize'
 } from './normalize'
 
 
 export interface GroupProtocolState {
 export interface GroupProtocolState {
@@ -131,6 +131,17 @@ export function applyGroupEnvelope(state: GroupProtocolState, envelope: GroupSoc
       next.topicsByRoom[topic.roomId] = upsertGroupTopic(next.topicsByRoom[topic.roomId] ?? [], topic)
       next.topicsByRoom[topic.roomId] = upsertGroupTopic(next.topicsByRoom[topic.roomId] ?? [], topic)
       break
       break
     }
     }
+    case 'room.activity': {
+      const activity = normalizeGroupRoomActivity(payload)
+      if (!activity.roomId) break
+      next.rooms = next.rooms.map(room => room.id === activity.roomId ? {
+        ...room,
+        activeRunCount: activity.activeRunCount,
+        unreadCount: activity.unreadCount,
+        lastMessage: activity.lastMessage ?? room.lastMessage,
+      } : room)
+      break
+    }
     case 'interaction.requested':
     case 'interaction.requested':
     case 'interaction.resolved': {
     case 'interaction.resolved': {
       const interaction = normalizeGroupInteraction(payload)
       const interaction = normalizeGroupInteraction(payload)

+ 17 - 1
src/client/utils/normalize.ts

@@ -9,6 +9,7 @@ import type {
   GroupInteraction,
   GroupInteraction,
   GroupMessage,
   GroupMessage,
   GroupRoomDetail,
   GroupRoomDetail,
+  GroupRoomActivity,
   GroupRoomSummary,
   GroupRoomSummary,
   GroupRun,
   GroupRun,
   GroupTopicSummary,
   GroupTopicSummary,
@@ -416,6 +417,8 @@ export function normalizeGroupTopic(value: unknown): GroupTopicSummary {
     id: string(source.id), roomId: string(pick(source, 'roomId', 'room_id')),
     id: string(source.id), roomId: string(pick(source, 'roomId', 'room_id')),
     title: string(source.title, '新话题'), preview: string(source.preview),
     title: string(source.title, '新话题'), preview: string(source.preview),
     messageCount: number(pick(source, 'messageCount', 'message_count')),
     messageCount: number(pick(source, 'messageCount', 'message_count')),
+    unreadCount: number(pick(source, 'unreadCount', 'unread_count')),
+    latestMessageSeq: number(pick(source, 'latestMessageSeq', 'latest_message_seq')),
     createdAt: number(pick(source, 'createdAt', 'created_at')),
     createdAt: number(pick(source, 'createdAt', 'created_at')),
     updatedAt: number(pick(source, 'updatedAt', 'updated_at')),
     updatedAt: number(pick(source, 'updatedAt', 'updated_at')),
   }
   }
@@ -429,7 +432,20 @@ export function normalizeGroupRoom(value: unknown): GroupRoomSummary {
     createdAt: number(pick(source, 'createdAt', 'created_at')), updatedAt: number(pick(source, 'updatedAt', 'updated_at')),
     createdAt: number(pick(source, 'createdAt', 'created_at')), updatedAt: number(pick(source, 'updatedAt', 'updated_at')),
     archived: bool(source.archived), agentCount: Array.isArray(rawAgents) ? rawAgents.length : number(pick(source, 'agentCount', 'agent_count')),
     archived: bool(source.archived), agentCount: Array.isArray(rawAgents) ? rawAgents.length : number(pick(source, 'agentCount', 'agent_count')),
     lastMessage: source.lastMessage || source.last_message ? normalizeGroupMessage(pick(source, 'lastMessage', 'last_message')) : null,
     lastMessage: source.lastMessage || source.last_message ? normalizeGroupMessage(pick(source, 'lastMessage', 'last_message')) : null,
-    unreadCount: number(pick(source, 'unreadCount', 'unread_count')), maxReplyRounds: number(pick(source, 'maxReplyRounds', 'max_reply_rounds'), 3),
+    unreadCount: number(pick(source, 'unreadCount', 'unread_count')),
+    activeRunCount: number(pick(source, 'activeRunCount', 'active_run_count')),
+    maxReplyRounds: number(pick(source, 'maxReplyRounds', 'max_reply_rounds'), 3),
+  }
+}
+
+export function normalizeGroupRoomActivity(value: unknown): GroupRoomActivity {
+  const source = record(value)
+  const lastMessage = pick(source, 'lastMessage', 'last_message')
+  return {
+    roomId: string(pick(source, 'roomId', 'room_id')),
+    activeRunCount: number(pick(source, 'activeRunCount', 'active_run_count')),
+    unreadCount: number(pick(source, 'unreadCount', 'unread_count')),
+    lastMessage: lastMessage == null ? null : normalizeGroupMessage(lastMessage),
   }
   }
 }
 }
 
 

+ 2 - 1
src/client/views/GroupsView.vue

@@ -66,7 +66,8 @@ const sidebarItems = computed<SidebarItem[]>(() => activeRooms.value.flatMap(roo
     id: topicSidebarItemId(room.id, topic.id),
     id: topicSidebarItemId(room.id, topic.id),
     title: topic.title,
     title: topic.title,
     subtitle: topic.preview || `${topic.messageCount} 条消息`,
     subtitle: topic.preview || `${topic.messageCount} 条消息`,
-    meta: `${topic.messageCount} 条`,
+    meta: topic.unreadCount ? `${topic.unreadCount} 未读` : `${topic.messageCount} 条`,
+    unread: topic.unreadCount,
     section: roomItem.section,
     section: roomItem.section,
     icon: 'branch' as const,
     icon: 'branch' as const,
     nested: true,
     nested: true,

+ 12 - 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 = 7
+export const MAX_SUPPORTED_GROUP_PROTOCOL_VERSION = 8
 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 {
@@ -341,6 +341,7 @@ export interface GroupRoomSummary {
   agentCount: number
   agentCount: number
   lastMessage?: GroupMessage | null
   lastMessage?: GroupMessage | null
   unreadCount: number
   unreadCount: number
+  activeRunCount?: number
   maxReplyRounds: number
   maxReplyRounds: number
 }
 }
 
 
@@ -362,10 +363,19 @@ export interface GroupTopicSummary {
   title: string
   title: string
   preview: string
   preview: string
   messageCount: number
   messageCount: number
+  unreadCount?: number
+  latestMessageSeq?: number
   createdAt: number
   createdAt: number
   updatedAt: number
   updatedAt: number
 }
 }
 
 
+export interface GroupRoomActivity {
+  roomId: string
+  activeRunCount: number
+  unreadCount: number
+  lastMessage?: GroupMessage | null
+}
+
 export interface GroupTopicPage {
 export interface GroupTopicPage {
   items: GroupTopicSummary[]
   items: GroupTopicSummary[]
   nextCursor?: string | null
   nextCursor?: string | null
@@ -385,6 +395,7 @@ export type GroupEventType =
   | 'agent.status'
   | 'agent.status'
   | 'message.upsert'
   | 'message.upsert'
   | 'topic.updated'
   | 'topic.updated'
+  | 'room.activity'
   | 'interaction.requested'
   | 'interaction.requested'
   | 'interaction.resolved'
   | 'interaction.resolved'
   | 'run.updated'
   | 'run.updated'

+ 16 - 3
tests/client/groupProtocol.test.ts

@@ -107,6 +107,19 @@ describe('group protocol continuity', () => {
     expect(reduced.messagesByRoom['room-1']).toEqual([])
     expect(reduced.messagesByRoom['room-1']).toEqual([])
   })
   })
 
 
+  it('applies v8 room activity without replacing the room identity', () => {
+    const activityMessage = groupMessage('activity-message', 'activity-client', '新的回复', 'completed', 'topic-1')
+    const reduced = applyGroupEnvelope(initial(), {
+      type: 'group.event', epoch, cursor: 9, roomId: 'room-1', event: 'room.activity',
+      payload: { roomId: 'room-1', activeRunCount: 2, unreadCount: 3, lastMessage: activityMessage as never },
+    })
+
+    expect(reduced.rooms[0]).toMatchObject({
+      id: 'room-1', name: '群聊', activeRunCount: 2, unreadCount: 3,
+      lastMessage: { id: 'activity-message' },
+    })
+  })
+
   it('converges immediately to one host when agent.updated promotes a new host', () => {
   it('converges immediately to one host when agent.updated promotes a new host', () => {
     const state = initial()
     const state = initial()
     const first = groupAgent('agent-1', '夭夭', true)
     const first = groupAgent('agent-1', '夭夭', true)
@@ -152,8 +165,8 @@ describe('group protocol continuity', () => {
   it('normalizes v5 host fields while keeping v4 agents non-host by default', () => {
   it('normalizes v5 host fields while keeping v4 agents non-host by default', () => {
     expect(normalizeGroupAgent({ id: 'legacy', room_id: 'room-1', profile: 'legacy' }).isHost).toBe(false)
     expect(normalizeGroupAgent({ id: 'legacy', room_id: 'room-1', profile: 'legacy' }).isHost).toBe(false)
     expect(normalizeGroupAgent({ id: 'host', room_id: 'room-1', profile: 'host', is_host: true }).isHost).toBe(true)
     expect(normalizeGroupAgent({ id: 'host', room_id: 'room-1', profile: 'host', is_host: true }).isHost).toBe(true)
-    expect(isSupportedGroupProtocolVersion(7)).toBe(true)
-    expect(isSupportedGroupProtocolVersion(8)).toBe(false)
-    expect(SUPPORTED_GROUP_PROTOCOL_VERSION_LABEL).toBe('v2–v7')
+    expect(isSupportedGroupProtocolVersion(8)).toBe(true)
+    expect(isSupportedGroupProtocolVersion(9)).toBe(false)
+    expect(SUPPORTED_GROUP_PROTOCOL_VERSION_LABEL).toBe('v2–v8')
   })
   })
 })
 })

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

@@ -556,8 +556,14 @@ test('selects existing group topics and creates a new protocol v4 topic on first
       && url.pathname === `/api/app/groups/rooms/${roomId}/messages`
       && url.pathname === `/api/app/groups/rooms/${roomId}/messages`
       && url.searchParams.get('topicId') === existingTopicId
       && url.searchParams.get('topicId') === existingTopicId
   })
   })
+  const existingTopicRead = page.waitForRequest(request => {
+    const url = new URL(request.url())
+    return request.method() === 'PATCH'
+      && url.pathname === `/api/app/groups/rooms/${roomId}/topics/${existingTopicId}/read`
+  })
   await existingTopic.click()
   await existingTopic.click()
   await existingTopicRequest
   await existingTopicRequest
+  expect((await existingTopicRead).postDataJSON()).toMatchObject({ throughSeq: 3 })
   await expect(page).toHaveURL(new RegExp(`/groups/${roomId}/${existingTopicId}$`))
   await expect(page).toHaveURL(new RegExp(`/groups/${roomId}/${existingTopicId}$`))
   const timeline = page.locator('.message-stack')
   const timeline = page.locator('.message-stack')
   await expect(timeline.getByText('请核对发布话题的独立历史。', { exact: true })).toBeVisible()
   await expect(timeline.getByText('请核对发布话题的独立历史。', { exact: true })).toBeVisible()

+ 17 - 5
tests/fixtures/fake-hermes.mjs

@@ -77,11 +77,11 @@ const groupAssistantMessage = { seq: 2, id: '55555555-5555-4555-8555-55555555555
 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 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 groupMessages = [groupMessage, groupAssistantMessage, releaseTopicMessage]
 const groupTopics = [
 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 },
+  { id: topicId, roomId, title: '设计验收', preview: groupAssistantMessage.content, messageCount: 2, unreadCount: 1, latestMessageSeq: 2, createdAt: now() - 120, updatedAt: now() - 100 },
+  { id: releaseTopicId, roomId, title: '发布检查', preview: releaseTopicMessage.content, messageCount: 1, unreadCount: 0, latestMessageSeq: 3, createdAt: now() - 500, updatedAt: now() - 500 },
 ]
 ]
 let nextGroupSeq = 4
 let nextGroupSeq = 4
-const room = { id: roomId, name: '设计与工程协作', cwd: '/tmp', createdAt: now() - 2000, updatedAt: now(), archived: false, agentCount: groupAgents.length, maxReplyRounds: -1, lastMessage: groupAssistantMessage, unreadCount: 0 }
+const room = { id: roomId, name: '设计与工程协作', cwd: '/tmp', createdAt: now() - 2000, updatedAt: now(), archived: false, agentCount: groupAgents.length, maxReplyRounds: -1, lastMessage: groupAssistantMessage, unreadCount: 1, activeRunCount: 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')
 
 
@@ -142,7 +142,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: 7, 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', 'agent.updated'] })
+  if (url.pathname === '/api/plugins/yaoyao/v1/capabilities') return json(response, 200, { protocolVersion: 8, journalEpoch: epoch, latestCursor: groupCursor, limits: { maxAgentsPerRoom: 8, maxMessageBytes: 65536, maxMessagePageSize: 100, defaultMaxReplyRounds: 3, unlimitedReplyRoundsValue: -1, maxAgentDisplayNameLength: 100 }, eventTypes: ['message.upsert', 'topic.updated', 'room.activity', 'run.updated', 'agent.status', 'agent.updated'] })
   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: groupAgents, runs: [], pendingInteractions: [], latestCursor: groupCursor })
   if (url.pathname === `/api/plugins/yaoyao/v1/rooms/${roomId}`) return json(response, 200, { ...room, agents: groupAgents, runs: [], pendingInteractions: [], latestCursor: groupCursor })
   const agentPatchMatch = new RegExp(`^/api/plugins/yaoyao/v1/rooms/${roomId}/agents/([^/]+)$`).exec(url.pathname)
   const agentPatchMatch = new RegExp(`^/api/plugins/yaoyao/v1/rooms/${roomId}/agents/([^/]+)$`).exec(url.pathname)
@@ -175,6 +175,16 @@ const server = createServer(async (request, response) => {
   if (url.pathname === `/api/plugins/yaoyao/v1/rooms/${roomId}/topics`) {
   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 })
     return json(response, 200, { items: [...groupTopics].sort((a, b) => b.updatedAt - a.updatedAt), nextCursor: null })
   }
   }
+  const topicReadMatch = new RegExp(`^/api/plugins/yaoyao/v1/rooms/${roomId}/topics/([^/]+)/read$`).exec(url.pathname)
+  if (topicReadMatch && request.method === 'PATCH') {
+    const topic = groupTopics.find(item => item.id === decodeURIComponent(topicReadMatch[1]))
+    if (!topic) return json(response, 404, { detail: 'Topic not found' })
+    const input = await body(request)
+    topic.unreadCount = 0
+    topic.latestMessageSeq = Math.max(topic.latestMessageSeq, Number(input.throughSeq) || 0)
+    room.unreadCount = groupTopics.reduce((count, item) => count + item.unreadCount, 0)
+    return json(response, 200, { topic, room: { roomId, activeRunCount: room.activeRunCount, unreadCount: room.unreadCount, lastMessage: room.lastMessage } })
+  }
   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)
     if (typeof input.topicId !== 'string' || !input.topicId) return json(response, 400, { detail: 'topicId is required for group protocol v4' })
     if (typeof input.topicId !== 'string' || !input.topicId) return json(response, 400, { detail: 'topicId is required for group protocol v4' })
@@ -185,9 +195,10 @@ const server = createServer(async (request, response) => {
     if (topic) {
     if (topic) {
       topic.preview = sent.content
       topic.preview = sent.content
       topic.messageCount = groupMessages.filter(message => message.topicId === input.topicId).length
       topic.messageCount = groupMessages.filter(message => message.topicId === input.topicId).length
+      topic.latestMessageSeq = sent.seq
       topic.updatedAt = sentAt
       topic.updatedAt = sentAt
     } else {
     } 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 }
+      topic = { id: input.topicId, roomId, title: String(input.content || '').trim().split('\n')[0].slice(0, 40) || '新话题', preview: sent.content, messageCount: 1, unreadCount: 0, latestMessageSeq: sent.seq, createdAt: sentAt, updatedAt: sentAt }
       groupTopics.push(topic)
       groupTopics.push(topic)
     }
     }
     room.lastMessage = sent
     room.lastMessage = sent
@@ -203,6 +214,7 @@ const server = createServer(async (request, response) => {
     setTimeout(() => broadcastGroup('agent.status', { roomId, agentId, status: 'running', runId }), 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('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)
+    setTimeout(() => broadcastGroup('room.activity', { roomId, activeRunCount: 0, unreadCount: room.unreadCount, lastMessage: room.lastMessage }), 1810)
     return
     return
   }
   }
   if (url.pathname === `/api/plugins/yaoyao/v1/rooms/${roomId}/messages`) {
   if (url.pathname === `/api/plugins/yaoyao/v1/rooms/${roomId}/messages`) {