Explorar o código

Add group topic read tracking and room activity events

samien hai 2 meses
pai
achega
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 {
   profile: string
   displayName: string

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

@@ -7,7 +7,7 @@ import type {
 import {
   addGroupAgent, approveGroupInteraction as approveApi, archiveGroupRoom as archiveApi,
   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,
   updateGroupRoom as updateRoomApi, uploadGroupFiles, type AgentSeed,
 } from '@/api/groups'
@@ -61,6 +61,7 @@ export const useGroupsStore = defineStore('groups', () => {
   const selectedRoom = computed(() => selectedRoomId.value ? protocol.value.roomDetails[selectedRoomId.value] : undefined)
   const topicProtocol = computed(() => (capabilities.value?.protocolVersion ?? 0) >= 4)
   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 selectedTopic = computed(() => selectedTopicId.value ? topics.value.find(topic => topic.id === selectedTopicId.value) : undefined)
   const messages = computed(() => topicProtocol.value
@@ -112,6 +113,13 @@ export const useGroupsStore = defineStore('groups', () => {
     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> {
     if (!topicProtocol.value || Object.prototype.hasOwnProperty.call(protocol.value.topicsByRoom, roomId)) return
     const expectedGeneration = generation
@@ -333,6 +341,7 @@ export const useGroupsStore = defineStore('groups', () => {
     loadingRoomEvents.delete(roomId)
     const key = topicId || roomId
     hasOlderByConversation.value = { ...hasOlderByConversation.value, [key]: page.items.length >= 100 }
+    markTopicRead(roomId, topicId, merged)
   }
 
   async function loadTopicSnapshot(
@@ -360,6 +369,7 @@ export const useGroupsStore = defineStore('groups', () => {
       messagesByTopic: { ...protocol.value.messagesByTopic, [topicId]: merged },
     }
     hasOlderByConversation.value = { ...hasOlderByConversation.value, [topicId]: page.items.length >= 100 }
+    markTopicRead(roomId, topicId, merged)
   }
 
   async function selectRoom(roomId: string, requestedTopicId?: string): Promise<void> {
@@ -662,7 +672,7 @@ export const useGroupsStore = defineStore('groups', () => {
   }
 
   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,
     connectionState, isLoading, isSending, error, hasMoreBefore,
     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'
 import {
   normalizeGroupAgent, normalizeGroupInteraction, normalizeGroupMessage, normalizeGroupRoom, normalizeGroupRoomDetail,
-  normalizeGroupRun, normalizeGroupTopic, record, string,
+  normalizeGroupRoomActivity, normalizeGroupRun, normalizeGroupTopic, record, string,
 } from './normalize'
 
 export interface GroupProtocolState {
@@ -131,6 +131,17 @@ export function applyGroupEnvelope(state: GroupProtocolState, envelope: GroupSoc
       next.topicsByRoom[topic.roomId] = upsertGroupTopic(next.topicsByRoom[topic.roomId] ?? [], topic)
       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.resolved': {
       const interaction = normalizeGroupInteraction(payload)

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

@@ -9,6 +9,7 @@ import type {
   GroupInteraction,
   GroupMessage,
   GroupRoomDetail,
+  GroupRoomActivity,
   GroupRoomSummary,
   GroupRun,
   GroupTopicSummary,
@@ -416,6 +417,8 @@ export function normalizeGroupTopic(value: unknown): GroupTopicSummary {
     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')),
+    unreadCount: number(pick(source, 'unreadCount', 'unread_count')),
+    latestMessageSeq: number(pick(source, 'latestMessageSeq', 'latest_message_seq')),
     createdAt: number(pick(source, 'createdAt', 'created_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')),
     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,
-    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),
     title: topic.title,
     subtitle: topic.preview || `${topic.messageCount} 条消息`,
-    meta: `${topic.messageCount} 条`,
+    meta: topic.unreadCount ? `${topic.unreadCount} 未读` : `${topic.messageCount} 条`,
+    unread: topic.unreadCount,
     section: roomItem.section,
     icon: 'branch' as const,
     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 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 function isSupportedGroupProtocolVersion(value: number): boolean {
@@ -341,6 +341,7 @@ export interface GroupRoomSummary {
   agentCount: number
   lastMessage?: GroupMessage | null
   unreadCount: number
+  activeRunCount?: number
   maxReplyRounds: number
 }
 
@@ -362,10 +363,19 @@ export interface GroupTopicSummary {
   title: string
   preview: string
   messageCount: number
+  unreadCount?: number
+  latestMessageSeq?: number
   createdAt: number
   updatedAt: number
 }
 
+export interface GroupRoomActivity {
+  roomId: string
+  activeRunCount: number
+  unreadCount: number
+  lastMessage?: GroupMessage | null
+}
+
 export interface GroupTopicPage {
   items: GroupTopicSummary[]
   nextCursor?: string | null
@@ -385,6 +395,7 @@ export type GroupEventType =
   | 'agent.status'
   | 'message.upsert'
   | 'topic.updated'
+  | 'room.activity'
   | 'interaction.requested'
   | 'interaction.resolved'
   | 'run.updated'

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

@@ -107,6 +107,19 @@ describe('group protocol continuity', () => {
     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', () => {
     const state = initial()
     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', () => {
     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(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.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 existingTopicRequest
+  expect((await existingTopicRead).postDataJSON()).toMatchObject({ throughSeq: 3 })
   await expect(page).toHaveURL(new RegExp(`/groups/${roomId}/${existingTopicId}$`))
   const timeline = page.locator('.message-stack')
   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 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 },
+  { 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
-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 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' })
   }
   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/${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)
@@ -175,6 +175,16 @@ const server = createServer(async (request, response) => {
   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 })
   }
+  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') {
     const input = await body(request)
     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) {
       topic.preview = sent.content
       topic.messageCount = groupMessages.filter(message => message.topicId === input.topicId).length
+      topic.latestMessageSeq = sent.seq
       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 }
+      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)
     }
     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('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('room.activity', { roomId, activeRunCount: 0, unreadCount: room.unreadCount, lastMessage: room.lastMessage }), 1810)
     return
   }
   if (url.pathname === `/api/plugins/yaoyao/v1/rooms/${roomId}/messages`) {