groups.ts 34 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755
  1. import { computed, ref, shallowRef, watch } from 'vue'
  2. import { defineStore } from 'pinia'
  3. import { isSupportedGroupProtocolVersion, SUPPORTED_GROUP_PROTOCOL_VERSION_LABEL } from '@shared/types'
  4. import type {
  5. GroupAgent, GroupCapabilities, GroupMessage, GroupMessagePage, GroupNode, GroupRoomDetail, GroupRoomSummary, GroupRun, GroupSocketEnvelope, GroupTopicSummary, RealtimeConnectionState,
  6. } from '@shared/types'
  7. import {
  8. addGroupAgent, approveGroupInteraction as approveApi, archiveGroupRoom as archiveApi, archiveGroupTopic as archiveTopicApi,
  9. clarifyGroupInteraction as clarifyApi, createGroupRoom as createApi, getGroupCapabilities,
  10. getGroupMessages, getGroupNodes, getGroupRoom, getGroupRooms, getGroupTopics, getPinnedGroupTopics, interruptGroupAgent as interruptApi, markGroupTopicRead,
  11. removeGroupAgent as removeAgentApi, restoreGroupRoom as restoreRoomApi, restoreGroupTopic as restoreTopicApi, sendGroupMessage as sendApi, updateGroupAgent as updateAgentApi,
  12. updateGroupRoom as updateRoomApi, updateGroupTopic as updateTopicApi, uploadGroupFiles, type AgentSeed,
  13. } from '@/api/groups'
  14. import { GroupEventSocket } from '@/api/realtime'
  15. import { ScopedCache } from '@/utils/cache'
  16. import {
  17. applyGroupEnvelope, convergeGroupAgents, snapshotGroupRoom, upsertGroupMessage, upsertGroupTopic, type GroupProtocolState,
  18. } from '@/utils/groupProtocol'
  19. import { createUuid } from '@/utils/id'
  20. import { ApiError } from '@/api/client'
  21. import { useAuthStore } from './auth'
  22. type GroupAvailability = 'checking' | 'available' | 'unsupported' | 'unavailable'
  23. interface CachedGroups { state: GroupProtocolState; savedAt: number }
  24. const cache = new ScopedCache<CachedGroups>('groups-v3')
  25. function initialProtocol(): GroupProtocolState {
  26. return { epoch: '', cursor: 0, rooms: [], roomDetails: {}, messagesByRoom: {}, topicsByRoom: {}, messagesByTopic: {} }
  27. }
  28. function errorMessage(cause: unknown): string { return cause instanceof Error ? cause.message : '群聊请求失败' }
  29. function topicTitle(content: string): string {
  30. return content.split(/\s+/).filter(Boolean).join(' ').slice(0, 100) || '新话题'
  31. }
  32. export const useGroupsStore = defineStore('groups', () => {
  33. const auth = useAuthStore()
  34. const availability = ref<GroupAvailability>('checking')
  35. const capabilities = ref<GroupCapabilities>()
  36. const protocol = shallowRef<GroupProtocolState>(initialProtocol())
  37. const selectedRoomId = ref<string>()
  38. const selectedTopicId = ref<string>()
  39. const pinnedTopics = ref<GroupTopicSummary[]>([])
  40. const nodes = ref<GroupNode[]>([])
  41. const connectionState = ref<RealtimeConnectionState>('idle')
  42. const isLoading = ref(false)
  43. const isSending = ref(false)
  44. const error = ref<string>()
  45. const hasOlderByConversation = ref<Record<string, boolean>>({})
  46. const socket = new GroupEventSocket()
  47. let generation = 0
  48. let rebuildPromise: Promise<void> | undefined
  49. let reconnectTimer: number | undefined
  50. let reconnectAttempt = 0
  51. let recoveryTimer: number | undefined
  52. let recoveryAttempt = 0
  53. let selectionGeneration = 0
  54. const loadingRoomEvents = new Map<string, GroupSocketEnvelope[]>()
  55. const rooms = computed(() => protocol.value.rooms)
  56. const selectedRoom = computed(() => selectedRoomId.value ? protocol.value.roomDetails[selectedRoomId.value] : undefined)
  57. const topicProtocol = computed(() => (capabilities.value?.protocolVersion ?? 0) >= 4)
  58. const hostProtocol = computed(() => (capabilities.value?.protocolVersion ?? 0) >= 5)
  59. const hostFlowProtocol = computed(() => capabilities.value?.features?.includes('hostFlow') ?? false)
  60. const roomInstructionsProtocol = computed(() => capabilities.value?.features?.includes('roomInstructions') ?? false)
  61. const activityProtocol = computed(() => (capabilities.value?.protocolVersion ?? 0) >= 8)
  62. const topics = computed(() => selectedRoomId.value ? protocol.value.topicsByRoom[selectedRoomId.value] ?? [] : [])
  63. const selectedTopic = computed(() => selectedTopicId.value ? topics.value.find(topic => topic.id === selectedTopicId.value) : undefined)
  64. const messages = computed(() => topicProtocol.value
  65. ? (selectedTopicId.value ? protocol.value.messagesByTopic[selectedTopicId.value] ?? [] : [])
  66. : (selectedRoomId.value ? protocol.value.messagesByRoom[selectedRoomId.value] ?? [] : []))
  67. const agents = computed(() => selectedRoom.value?.agents ?? [])
  68. const pendingInteractions = computed(() => (selectedRoom.value?.pendingInteractions ?? []).filter(interaction =>
  69. !topicProtocol.value || !interaction.topicId || interaction.topicId === selectedTopicId.value))
  70. const conversationKey = computed(() => topicProtocol.value ? selectedTopicId.value : selectedRoomId.value)
  71. const hasMoreBefore = computed(() => conversationKey.value ? hasOlderByConversation.value[conversationKey.value] ?? false : false)
  72. function scope(): string { return `${auth.user?.id ?? 'anonymous'}:${auth.activeProfile?.name ?? 'all'}` }
  73. async function saveCache(): Promise<void> {
  74. await cache.set(scope(), 'snapshot', { state: protocol.value, savedAt: Date.now() })
  75. }
  76. async function loadRooms(): Promise<GroupRoomSummary[]> {
  77. const loaded: GroupRoomSummary[] = []
  78. const seen = new Set<string>()
  79. let cursor: string | undefined
  80. do {
  81. const key = cursor ?? '<first-page>'
  82. if (seen.has(key)) throw new Error('群聊房间分页返回了重复游标')
  83. seen.add(key)
  84. const page = await getGroupRooms(cursor, 50)
  85. loaded.push(...page.items)
  86. cursor = page.nextCursor ?? undefined
  87. } while (cursor && loaded.length < 2_000)
  88. return loaded.sort((a, b) => b.updatedAt - a.updatedAt)
  89. }
  90. async function loadTopics(roomId: string, archived = false): Promise<GroupTopicSummary[]> {
  91. const loaded: GroupTopicSummary[] = []
  92. const seen = new Set<string>()
  93. let cursor: string | undefined
  94. do {
  95. const key = cursor ?? '<first-page>'
  96. if (seen.has(key)) throw new Error('群聊话题分页返回了重复游标')
  97. seen.add(key)
  98. const page = await getGroupTopics(roomId, cursor, 50, archived)
  99. loaded.push(...page.items)
  100. cursor = page.nextCursor ?? undefined
  101. } while (cursor && loaded.length < 2_000)
  102. return loaded.sort((a, b) => b.updatedAt - a.updatedAt || b.id.localeCompare(a.id))
  103. }
  104. function topicsForRoom(roomId: string): GroupTopicSummary[] {
  105. return (protocol.value.topicsByRoom[roomId] ?? []).filter(topic => !topic.archived)
  106. }
  107. function markTopicRead(roomId: string, topicId: string | undefined, messages: GroupMessage[]): void {
  108. if (!activityProtocol.value || !topicId) return
  109. const throughSeq = Math.max(0, ...messages.map(message => message.seq))
  110. if (!throughSeq) return
  111. void markGroupTopicRead(roomId, topicId, throughSeq).catch(() => undefined)
  112. }
  113. async function loadRoomTopics(roomId: string): Promise<void> {
  114. if (!topicProtocol.value || Object.prototype.hasOwnProperty.call(protocol.value.topicsByRoom, roomId)) return
  115. const expectedGeneration = generation
  116. const loaded = await loadTopics(roomId)
  117. if (expectedGeneration !== generation) return
  118. protocol.value = {
  119. ...protocol.value,
  120. topicsByRoom: { ...protocol.value.topicsByRoom, [roomId]: loaded },
  121. }
  122. }
  123. async function loadPinnedTopics(): Promise<void> {
  124. pinnedTopics.value = (await getPinnedGroupTopics()).items
  125. }
  126. async function connectEvents(anchor: GroupCapabilities, expectedGeneration: number): Promise<void> {
  127. await socket.connect({
  128. epoch: anchor.journalEpoch,
  129. cursor: anchor.latestCursor,
  130. onState(state, reason) {
  131. if (expectedGeneration !== generation) return
  132. connectionState.value = state
  133. if (state === 'ready') reconnectAttempt = 0
  134. if (reason && state === 'failed') {
  135. error.value = reason
  136. scheduleReconnect(expectedGeneration)
  137. }
  138. },
  139. onEnvelope(envelope) {
  140. if (expectedGeneration !== generation) return
  141. if (envelope.roomId && loadingRoomEvents.has(envelope.roomId)) {
  142. loadingRoomEvents.get(envelope.roomId)!.push(envelope)
  143. }
  144. protocol.value = applyGroupEnvelope(protocol.value, envelope)
  145. void saveCache()
  146. },
  147. onReset(reason) {
  148. if (expectedGeneration !== generation) return
  149. error.value = `群聊事件需要重新同步:${reason}`
  150. void rebuildSnapshot()
  151. },
  152. })
  153. }
  154. function scheduleReconnect(expectedGeneration: number): void {
  155. if (reconnectTimer !== undefined || expectedGeneration !== generation || !auth.isAuthenticated) return
  156. connectionState.value = 'reconnecting'
  157. const delay = Math.min(15_000, 500 * 2 ** Math.min(reconnectAttempt, 5))
  158. reconnectAttempt += 1
  159. reconnectTimer = window.setTimeout(() => {
  160. reconnectTimer = undefined
  161. if (expectedGeneration !== generation || !capabilities.value) return
  162. const anchor: GroupCapabilities = {
  163. ...capabilities.value,
  164. journalEpoch: protocol.value.epoch,
  165. latestCursor: protocol.value.cursor,
  166. }
  167. void connectEvents(anchor, expectedGeneration).catch(() => scheduleReconnect(expectedGeneration))
  168. }, delay)
  169. }
  170. function scheduleSnapshotRecovery(expectedGeneration: number): void {
  171. if (recoveryTimer !== undefined || expectedGeneration !== generation || !auth.isAuthenticated) return
  172. connectionState.value = 'reconnecting'
  173. const delay = Math.min(15_000, 500 * 2 ** Math.min(recoveryAttempt, 5))
  174. recoveryAttempt += 1
  175. recoveryTimer = window.setTimeout(() => {
  176. recoveryTimer = undefined
  177. if (expectedGeneration !== generation || !auth.isAuthenticated) return
  178. void rebuildSnapshot().catch(() => undefined)
  179. }, delay)
  180. }
  181. async function rebuildSnapshot(): Promise<void> {
  182. if (rebuildPromise) return rebuildPromise
  183. const expectedGeneration = ++generation
  184. const operation = (async () => {
  185. isLoading.value = true
  186. error.value = undefined
  187. socket.close()
  188. if (reconnectTimer !== undefined) window.clearTimeout(reconnectTimer)
  189. reconnectTimer = undefined
  190. if (recoveryTimer !== undefined) window.clearTimeout(recoveryTimer)
  191. recoveryTimer = undefined
  192. try {
  193. const anchor = await getGroupCapabilities()
  194. if (!isSupportedGroupProtocolVersion(anchor.protocolVersion)) {
  195. availability.value = 'unsupported'
  196. throw new Error(`群聊协议版本不兼容:支持 ${SUPPORTED_GROUP_PROTOCOL_VERSION_LABEL},服务器返回 v${anchor.protocolVersion || 'unknown'}`)
  197. }
  198. const loadedRooms = await loadRooms()
  199. nodes.value = anchor.features?.includes('nodeRegistry')
  200. ? await getGroupNodes()
  201. : []
  202. if (expectedGeneration !== generation) return
  203. await loadPinnedTopics()
  204. if (expectedGeneration !== generation) return
  205. capabilities.value = anchor
  206. protocol.value = {
  207. epoch: anchor.journalEpoch,
  208. cursor: anchor.latestCursor,
  209. rooms: loadedRooms,
  210. roomDetails: {},
  211. messagesByRoom: {},
  212. topicsByRoom: {},
  213. messagesByTopic: {},
  214. }
  215. const restoreId = selectedRoomId.value && loadedRooms.some(room => room.id === selectedRoomId.value)
  216. ? selectedRoomId.value
  217. : loadedRooms[0]?.id
  218. if (restoreId) {
  219. const restoreTopicId = restoreId === selectedRoomId.value ? selectedTopicId.value : undefined
  220. await loadRoomSnapshot(restoreId, expectedGeneration, selectionGeneration, restoreTopicId)
  221. }
  222. if (expectedGeneration !== generation) return
  223. availability.value = 'available'
  224. recoveryAttempt = 0
  225. await saveCache()
  226. await connectEvents(anchor, expectedGeneration)
  227. } catch (cause) {
  228. if (expectedGeneration !== generation) return
  229. if (availability.value !== 'unsupported') availability.value = 'unavailable'
  230. error.value = errorMessage(cause)
  231. if (availability.value !== 'unsupported') scheduleSnapshotRecovery(expectedGeneration)
  232. throw cause
  233. } finally {
  234. if (expectedGeneration === generation) isLoading.value = false
  235. }
  236. })()
  237. let wrapped: Promise<void>
  238. wrapped = operation.finally(() => {
  239. if (rebuildPromise === wrapped) rebuildPromise = undefined
  240. })
  241. rebuildPromise = wrapped
  242. return wrapped
  243. }
  244. async function start(): Promise<void> {
  245. availability.value = 'checking'
  246. recoveryAttempt = 0
  247. protocol.value = initialProtocol()
  248. const cached = await cache.get(scope(), 'snapshot')
  249. if (cached?.state.epoch) {
  250. protocol.value = cached.state
  251. selectedRoomId.value ??= cached.state.rooms[0]?.id
  252. }
  253. await rebuildSnapshot()
  254. }
  255. function stop(): void {
  256. generation += 1
  257. rebuildPromise = undefined
  258. socket.close()
  259. if (reconnectTimer !== undefined) window.clearTimeout(reconnectTimer)
  260. reconnectTimer = undefined
  261. if (recoveryTimer !== undefined) window.clearTimeout(recoveryTimer)
  262. recoveryTimer = undefined
  263. reconnectAttempt = 0
  264. recoveryAttempt = 0
  265. connectionState.value = 'disconnected'
  266. }
  267. watch(() => auth.status, status => {
  268. if (status !== 'authenticated') {
  269. stop()
  270. protocol.value = initialProtocol()
  271. selectedRoomId.value = undefined
  272. selectedTopicId.value = undefined
  273. capabilities.value = undefined
  274. hasOlderByConversation.value = {}
  275. }
  276. })
  277. async function loadRoomSnapshot(
  278. roomId: string,
  279. expectedGeneration = generation,
  280. expectedSelection = selectionGeneration,
  281. requestedTopicId?: string,
  282. ): Promise<void> {
  283. const startingCursor = protocol.value.cursor
  284. loadingRoomEvents.set(roomId, [])
  285. let detail: GroupRoomDetail
  286. let page: GroupMessagePage
  287. let loadedTopics: GroupTopicSummary[] | undefined
  288. let topicId: string | undefined
  289. try {
  290. if (topicProtocol.value) {
  291. ;[detail, loadedTopics] = await Promise.all([getGroupRoom(roomId), loadTopics(roomId)])
  292. topicId = requestedTopicId || loadedTopics[0]?.id || createUuid()
  293. page = loadedTopics.some(topic => topic.id === topicId)
  294. ? await getGroupMessages(roomId, { topicId, limit: 100 })
  295. : { items: [] }
  296. } else {
  297. ;[detail, page] = await Promise.all([getGroupRoom(roomId), getGroupMessages(roomId, { limit: 100 })])
  298. }
  299. } catch (cause) {
  300. loadingRoomEvents.delete(roomId)
  301. throw cause
  302. }
  303. if (expectedGeneration !== generation || expectedSelection !== selectionGeneration) {
  304. loadingRoomEvents.delete(roomId)
  305. return
  306. }
  307. selectedRoomId.value = roomId
  308. selectedTopicId.value = topicId
  309. let merged = page.items.sort((a, b) => a.seq - b.seq)
  310. // Socket events can land while the REST baseline is in flight. Replay the
  311. // already-published live rows last so a stale snapshot never erases or
  312. // rolls back a streaming upsert.
  313. const liveMessages = topicId
  314. ? protocol.value.messagesByTopic[topicId] ?? []
  315. : protocol.value.messagesByRoom[roomId] ?? []
  316. for (const live of liveMessages) {
  317. merged = upsertGroupMessage(merged, live)
  318. }
  319. const liveDetail = protocol.value.cursor > startingCursor
  320. ? protocol.value.roomDetails[roomId]
  321. : undefined
  322. protocol.value = snapshotGroupRoom(protocol.value, liveDetail ?? detail, merged, { topicId, topics: loadedTopics })
  323. for (const envelope of loadingRoomEvents.get(roomId) ?? []) {
  324. const durableCursor = protocol.value.cursor
  325. const replayBase = envelope.cursor == null
  326. ? protocol.value
  327. : { ...protocol.value, cursor: Math.max(0, envelope.cursor - 1) }
  328. const replayed = applyGroupEnvelope(replayBase, envelope)
  329. protocol.value = { ...replayed, cursor: Math.max(durableCursor, replayed.cursor) }
  330. }
  331. loadingRoomEvents.delete(roomId)
  332. const key = topicId || roomId
  333. hasOlderByConversation.value = { ...hasOlderByConversation.value, [key]: page.items.length >= 100 }
  334. markTopicRead(roomId, topicId, merged)
  335. }
  336. async function loadTopicSnapshot(
  337. roomId: string,
  338. topicId: string,
  339. expectedGeneration = generation,
  340. expectedSelection = selectionGeneration,
  341. ): Promise<void> {
  342. selectedTopicId.value = topicId
  343. const persisted = (protocol.value.topicsByRoom[roomId] ?? []).some(topic => topic.id === topicId)
  344. if (!persisted) {
  345. protocol.value = {
  346. ...protocol.value,
  347. messagesByTopic: { ...protocol.value.messagesByTopic, [topicId]: protocol.value.messagesByTopic[topicId] ?? [] },
  348. }
  349. hasOlderByConversation.value = { ...hasOlderByConversation.value, [topicId]: false }
  350. return
  351. }
  352. const page = await getGroupMessages(roomId, { topicId, limit: 100 })
  353. if (expectedGeneration !== generation || expectedSelection !== selectionGeneration) return
  354. let merged = page.items.sort((a, b) => a.seq - b.seq)
  355. for (const live of protocol.value.messagesByTopic[topicId] ?? []) merged = upsertGroupMessage(merged, live)
  356. protocol.value = {
  357. ...protocol.value,
  358. messagesByTopic: { ...protocol.value.messagesByTopic, [topicId]: merged },
  359. }
  360. hasOlderByConversation.value = { ...hasOlderByConversation.value, [topicId]: page.items.length >= 100 }
  361. markTopicRead(roomId, topicId, merged)
  362. }
  363. async function selectRoom(roomId: string, requestedTopicId?: string): Promise<void> {
  364. const expectedSelection = ++selectionGeneration
  365. const previousRoomId = selectedRoomId.value
  366. const previousTopicId = selectedTopicId.value
  367. const sameRoom = selectedRoomId.value === roomId
  368. selectedRoomId.value = roomId
  369. const rollback = () => {
  370. if (expectedSelection !== selectionGeneration) return
  371. selectedRoomId.value = previousRoomId
  372. selectedTopicId.value = previousTopicId
  373. }
  374. const topicsLoaded = Object.prototype.hasOwnProperty.call(protocol.value.topicsByRoom, roomId)
  375. if (!protocol.value.roomDetails[roomId] || (topicProtocol.value && !topicsLoaded)) {
  376. isLoading.value = true
  377. const desiredTopicId = requestedTopicId || (sameRoom ? selectedTopicId.value : undefined)
  378. try { await loadRoomSnapshot(roomId, generation, expectedSelection, desiredTopicId) }
  379. catch (cause) { rollback(); error.value = errorMessage(cause); throw cause }
  380. finally { isLoading.value = false }
  381. return
  382. }
  383. if (!topicProtocol.value) {
  384. selectedTopicId.value = undefined
  385. return
  386. }
  387. const topicId = requestedTopicId || (sameRoom ? selectedTopicId.value : undefined)
  388. || protocol.value.topicsByRoom[roomId]?.[0]?.id || createUuid()
  389. isLoading.value = true
  390. try { await loadTopicSnapshot(roomId, topicId, generation, expectedSelection) }
  391. catch (cause) { rollback(); error.value = errorMessage(cause); throw cause }
  392. finally { isLoading.value = false }
  393. }
  394. async function selectTopic(topicId: string): Promise<void> {
  395. const roomId = selectedRoomId.value
  396. if (!roomId || !topicProtocol.value || topicId === selectedTopicId.value) return
  397. const expectedSelection = ++selectionGeneration
  398. const previousTopicId = selectedTopicId.value
  399. isLoading.value = true
  400. try { await loadTopicSnapshot(roomId, topicId, generation, expectedSelection) }
  401. catch (cause) {
  402. if (expectedSelection === selectionGeneration) selectedTopicId.value = previousTopicId
  403. error.value = errorMessage(cause)
  404. throw cause
  405. }
  406. finally { isLoading.value = false }
  407. }
  408. async function startNewTopic(): Promise<string | undefined> {
  409. if (!selectedRoomId.value || !topicProtocol.value) return undefined
  410. const topicId = createUuid()
  411. await selectTopic(topicId)
  412. return topicId
  413. }
  414. async function refresh(): Promise<void> { await rebuildSnapshot() }
  415. async function createRoom(input: { name: string; cwd?: string; instructions?: string; agents: AgentSeed[]; maxReplyRounds?: number; orchestrationMode?: 'free' | 'host' }): Promise<GroupRoomDetail> {
  416. const detail = await createApi(input)
  417. protocol.value = snapshotGroupRoom(protocol.value, detail, [], topicProtocol.value ? { topics: [] } : {})
  418. selectedRoomId.value = detail.id
  419. selectedTopicId.value = undefined
  420. if (topicProtocol.value) await startNewTopic()
  421. await saveCache()
  422. return detail
  423. }
  424. async function updateRoom(roomId: string, input: { name?: string; cwd?: string; instructions?: string; maxReplyRounds?: number; orchestrationMode?: 'free' | 'host' }): Promise<GroupRoomDetail> {
  425. const detail = await updateRoomApi(roomId, input)
  426. const topicId = topicProtocol.value && selectedRoomId.value === roomId ? selectedTopicId.value : undefined
  427. const roomMessages = topicId ? protocol.value.messagesByTopic[topicId] ?? [] : protocol.value.messagesByRoom[roomId] ?? []
  428. protocol.value = snapshotGroupRoom(protocol.value, detail, roomMessages, { topicId })
  429. await saveCache()
  430. return detail
  431. }
  432. async function archiveRoom(roomId: string): Promise<void> {
  433. const detail = await archiveApi(roomId)
  434. const topicId = topicProtocol.value && selectedRoomId.value === roomId ? selectedTopicId.value : undefined
  435. const roomMessages = topicId ? protocol.value.messagesByTopic[topicId] ?? [] : protocol.value.messagesByRoom[roomId] ?? []
  436. protocol.value = snapshotGroupRoom(protocol.value, detail, roomMessages, { topicId })
  437. if (selectedRoomId.value === roomId) {
  438. selectedRoomId.value = protocol.value.rooms.find(room => !room.archived && room.id !== roomId)?.id
  439. selectedTopicId.value = undefined
  440. }
  441. await saveCache()
  442. }
  443. async function restoreRoom(roomId: string): Promise<GroupRoomDetail> {
  444. const detail = await restoreRoomApi(roomId)
  445. protocol.value = snapshotGroupRoom(protocol.value, detail, [], topicProtocol.value ? { topics: [] } : {})
  446. await saveCache()
  447. return detail
  448. }
  449. async function archiveTopic(roomId: string, topicId: string): Promise<void> {
  450. const topic = await archiveTopicApi(roomId, topicId)
  451. const current = protocol.value.topicsByRoom[roomId] ?? []
  452. protocol.value = { ...protocol.value, topicsByRoom: { ...protocol.value.topicsByRoom, [roomId]: current.map(item => item.id === topic.id ? topic : item) } }
  453. pinnedTopics.value = pinnedTopics.value.filter(item => item.id !== topic.id)
  454. if (selectedRoomId.value === roomId && selectedTopicId.value === topicId) {
  455. selectedTopicId.value = current.find(item => item.id !== topicId && !item.archived)?.id
  456. }
  457. await saveCache()
  458. }
  459. async function setTopicPinned(roomId: string, topicId: string, pinned: boolean): Promise<GroupTopicSummary> {
  460. const topic = await updateTopicApi(roomId, topicId, { pinned })
  461. const current = protocol.value.topicsByRoom[roomId] ?? []
  462. protocol.value = { ...protocol.value, topicsByRoom: { ...protocol.value.topicsByRoom, [roomId]: upsertGroupTopic(current, topic) } }
  463. pinnedTopics.value = pinned
  464. ? upsertGroupTopic(pinnedTopics.value, topic)
  465. : pinnedTopics.value.filter(item => item.id !== topic.id)
  466. await saveCache()
  467. return topic
  468. }
  469. async function renameTopic(roomId: string, topicId: string, title: string): Promise<GroupTopicSummary> {
  470. const topic = await updateTopicApi(roomId, topicId, { title })
  471. const current = protocol.value.topicsByRoom[roomId] ?? []
  472. protocol.value = { ...protocol.value, topicsByRoom: { ...protocol.value.topicsByRoom, [roomId]: upsertGroupTopic(current, topic) } }
  473. if (topic.pinned) pinnedTopics.value = upsertGroupTopic(pinnedTopics.value, topic)
  474. await saveCache()
  475. return topic
  476. }
  477. async function restoreTopic(roomId: string, topicId: string): Promise<GroupTopicSummary> {
  478. const topic = await restoreTopicApi(roomId, topicId)
  479. const current = protocol.value.topicsByRoom[roomId] ?? []
  480. protocol.value = { ...protocol.value, topicsByRoom: { ...protocol.value.topicsByRoom, [roomId]: upsertGroupTopic(current, topic) } }
  481. await saveCache()
  482. return topic
  483. }
  484. async function archivedRooms(): Promise<GroupRoomSummary[]> {
  485. const loaded: GroupRoomSummary[] = []
  486. let cursor: string | undefined
  487. do {
  488. const page = await getGroupRooms(cursor, 50, true)
  489. loaded.push(...page.items)
  490. cursor = page.nextCursor ?? undefined
  491. } while (cursor && loaded.length < 2_000)
  492. return loaded.sort((a, b) => b.updatedAt - a.updatedAt)
  493. }
  494. async function archivedTopics(roomId: string): Promise<GroupTopicSummary[]> {
  495. return loadTopics(roomId, true)
  496. }
  497. function publishAgent(roomId: string, agent: GroupAgent): void {
  498. const detail = protocol.value.roomDetails[roomId]
  499. if (!detail) return
  500. protocol.value = {
  501. ...protocol.value,
  502. roomDetails: { ...protocol.value.roomDetails, [roomId]: { ...detail, agents: convergeGroupAgents(detail.agents, agent) } },
  503. }
  504. }
  505. function publishRuns(roomId: string, incoming: GroupRun[]): void {
  506. const detail = protocol.value.roomDetails[roomId]
  507. if (!detail || !incoming.length) return
  508. const runs = [...detail.runs]
  509. for (const run of incoming) {
  510. const index = runs.findIndex(item => item.id === run.id)
  511. if (index < 0) runs.push(run)
  512. else if (runs[index]!.updatedAt <= run.updatedAt) runs[index] = { ...runs[index]!, ...run }
  513. }
  514. protocol.value = {
  515. ...protocol.value,
  516. roomDetails: { ...protocol.value.roomDetails, [roomId]: { ...detail, runs } },
  517. }
  518. }
  519. async function addAgent(roomId: string, input: AgentSeed): Promise<GroupAgent> {
  520. const agent = await addGroupAgent(roomId, input)
  521. publishAgent(roomId, agent)
  522. return agent
  523. }
  524. async function updateAgent(roomId: string, agentId: string, input: Partial<Omit<AgentSeed, 'profile'>> & { enabled?: boolean }): Promise<GroupAgent> {
  525. const disabledHost = input.enabled === false
  526. && protocol.value.roomDetails[roomId]?.agents.some(agent => agent.id === agentId && agent.isHost) === true
  527. const agent = await updateAgentApi(roomId, agentId, input)
  528. publishAgent(roomId, agent)
  529. if (disabledHost && selectedRoomId.value === roomId) {
  530. await loadRoomSnapshot(roomId, generation, selectionGeneration, selectedTopicId.value)
  531. await saveCache()
  532. }
  533. return agent
  534. }
  535. async function removeAgent(roomId: string, agentId: string): Promise<void> {
  536. const removedHost = protocol.value.roomDetails[roomId]?.agents.some(agent => agent.id === agentId && agent.isHost) === true
  537. await removeAgentApi(roomId, agentId)
  538. const detail = protocol.value.roomDetails[roomId]
  539. if (detail) protocol.value = {
  540. ...protocol.value,
  541. roomDetails: { ...protocol.value.roomDetails, [roomId]: { ...detail, agents: detail.agents.filter(agent => agent.id !== agentId) } },
  542. }
  543. if (removedHost && selectedRoomId.value === roomId) {
  544. await loadRoomSnapshot(roomId, generation, selectionGeneration, selectedTopicId.value)
  545. await saveCache()
  546. }
  547. }
  548. async function sendMessage(content: string, mentionAgentIds: string[] = [], files: File[] = []): Promise<void> {
  549. const roomId = selectedRoomId.value
  550. if (!roomId) return
  551. let topicId = topicProtocol.value ? selectedTopicId.value : undefined
  552. if (topicProtocol.value && !topicId) {
  553. topicId = createUuid()
  554. selectedTopicId.value = topicId
  555. }
  556. const text = content.trim()
  557. let uploadIds: string[] = []
  558. let optimisticText = text
  559. if (files.length) {
  560. if (!auth.groupUploadsEnabled) throw new Error('当前上游不支持群聊附件')
  561. const uploaded = await uploadGroupFiles(roomId, files)
  562. uploadIds = uploaded.map(file => file.id)
  563. optimisticText = [text, ...uploaded.map(file => `📎 ${file.name}`)].filter(Boolean).join('\n\n')
  564. }
  565. if (!text && !uploadIds.length) return
  566. const clientMessageId = createUuid()
  567. const stableRequestId = createUuid()
  568. const now = Date.now() / 1000
  569. const wasNewTopic = Boolean(topicId && !(protocol.value.topicsByRoom[roomId] ?? []).some(topic => topic.id === topicId))
  570. const optimistic: GroupMessage = {
  571. seq: Math.max(0, ...messages.value.map(message => message.seq)) + 1,
  572. id: clientMessageId,
  573. roomId,
  574. topicId: topicId || null,
  575. senderKind: 'human',
  576. senderId: auth.user?.id ?? 'me',
  577. senderName: auth.user?.displayName ?? auth.user?.username ?? '我',
  578. rootMessageId: clientMessageId,
  579. clientMessageId,
  580. content: optimisticText,
  581. reasoning: '',
  582. toolState: [],
  583. status: 'queued',
  584. error: '',
  585. createdAt: now,
  586. updatedAt: now,
  587. }
  588. if (topicId) {
  589. const existing = (protocol.value.topicsByRoom[roomId] ?? []).find(topic => topic.id === topicId)
  590. const optimisticTopic: GroupTopicSummary = {
  591. id: topicId,
  592. roomId,
  593. title: existing?.title || topicTitle(text || optimisticText),
  594. preview: optimisticText,
  595. messageCount: (existing?.messageCount ?? 0) + 1,
  596. createdAt: existing?.createdAt ?? now,
  597. updatedAt: now,
  598. }
  599. protocol.value = {
  600. ...protocol.value,
  601. topicsByRoom: {
  602. ...protocol.value.topicsByRoom,
  603. [roomId]: upsertGroupTopic(protocol.value.topicsByRoom[roomId] ?? [], optimisticTopic),
  604. },
  605. messagesByTopic: {
  606. ...protocol.value.messagesByTopic,
  607. [topicId]: upsertGroupMessage(protocol.value.messagesByTopic[topicId] ?? [], optimistic),
  608. },
  609. }
  610. } else {
  611. protocol.value = {
  612. ...protocol.value,
  613. messagesByRoom: {
  614. ...protocol.value.messagesByRoom,
  615. [roomId]: upsertGroupMessage(protocol.value.messagesByRoom[roomId] ?? [], optimistic),
  616. },
  617. }
  618. }
  619. isSending.value = true
  620. try {
  621. const enabled = new Set((protocol.value.roomDetails[roomId]?.agents ?? []).filter(agent => agent.enabled).map(agent => agent.id))
  622. const resolvedMentions = mentionAgentIds.includes('all')
  623. ? [...enabled]
  624. : [...new Set(mentionAgentIds.filter(id => enabled.has(id)))]
  625. const response = await sendApi(roomId, text, resolvedMentions, clientMessageId, topicId, uploadIds, stableRequestId)
  626. publishRuns(roomId, response.runs)
  627. const responseTopicId = response.message.topicId || topicId
  628. protocol.value = responseTopicId ? {
  629. ...protocol.value,
  630. messagesByTopic: {
  631. ...protocol.value.messagesByTopic,
  632. [responseTopicId]: upsertGroupMessage(protocol.value.messagesByTopic[responseTopicId] ?? [], response.message),
  633. },
  634. } : {
  635. ...protocol.value,
  636. messagesByRoom: {
  637. ...protocol.value.messagesByRoom,
  638. [roomId]: upsertGroupMessage(protocol.value.messagesByRoom[roomId] ?? [], response.message),
  639. },
  640. }
  641. } catch (cause) {
  642. const explicitRejection = cause instanceof ApiError && cause.status >= 400 && cause.status < 500
  643. const failed = {
  644. ...optimistic,
  645. status: explicitRejection ? 'failed' as const : 'unknown' as const,
  646. error: explicitRejection ? errorMessage(cause) : '送达状态未知;刷新房间后再决定是否重试',
  647. updatedAt: Date.now() / 1000,
  648. }
  649. protocol.value = topicId ? {
  650. ...protocol.value,
  651. topicsByRoom: explicitRejection && wasNewTopic ? {
  652. ...protocol.value.topicsByRoom,
  653. [roomId]: (protocol.value.topicsByRoom[roomId] ?? []).filter(topic => topic.id !== topicId),
  654. } : protocol.value.topicsByRoom,
  655. messagesByTopic: { ...protocol.value.messagesByTopic, [topicId]: upsertGroupMessage(protocol.value.messagesByTopic[topicId] ?? [], failed) },
  656. } : {
  657. ...protocol.value,
  658. messagesByRoom: { ...protocol.value.messagesByRoom, [roomId]: upsertGroupMessage(protocol.value.messagesByRoom[roomId] ?? [], failed) },
  659. }
  660. throw cause
  661. } finally { isSending.value = false }
  662. }
  663. async function loadOlder(): Promise<void> {
  664. const roomId = selectedRoomId.value
  665. const oldest = messages.value.find(message => message.seq > 0)?.seq
  666. if (!roomId || !oldest) return
  667. const topicId = topicProtocol.value ? selectedTopicId.value : undefined
  668. const page = await getGroupMessages(roomId, { topicId, beforeSeq: oldest, limit: 100 })
  669. let merged = topicId ? protocol.value.messagesByTopic[topicId] ?? [] : protocol.value.messagesByRoom[roomId] ?? []
  670. for (const message of page.items) merged = upsertGroupMessage(merged, message)
  671. protocol.value = topicId
  672. ? { ...protocol.value, messagesByTopic: { ...protocol.value.messagesByTopic, [topicId]: merged } }
  673. : { ...protocol.value, messagesByRoom: { ...protocol.value.messagesByRoom, [roomId]: merged } }
  674. const key = topicId || roomId
  675. hasOlderByConversation.value = { ...hasOlderByConversation.value, [key]: page.items.length >= 100 }
  676. }
  677. async function interruptAgent(agentId: string, roomId = selectedRoomId.value): Promise<void> {
  678. if (!roomId) return
  679. await interruptApi(roomId, agentId)
  680. }
  681. async function approveInteraction(interactionId: string, choice: 'once' | 'session' | 'always' | 'deny', roomId = selectedRoomId.value): Promise<void> {
  682. if (!roomId) return
  683. await approveApi(roomId, interactionId, choice)
  684. const detail = protocol.value.roomDetails[roomId]
  685. if (detail) protocol.value = {
  686. ...protocol.value,
  687. roomDetails: { ...protocol.value.roomDetails, [roomId]: { ...detail, pendingInteractions: detail.pendingInteractions.filter(item => item.id !== interactionId) } },
  688. }
  689. }
  690. async function clarifyInteraction(interactionId: string, response: string, roomId = selectedRoomId.value): Promise<void> {
  691. if (!roomId) return
  692. await clarifyApi(roomId, interactionId, response)
  693. const detail = protocol.value.roomDetails[roomId]
  694. if (detail) protocol.value = {
  695. ...protocol.value,
  696. roomDetails: { ...protocol.value.roomDetails, [roomId]: { ...detail, pendingInteractions: detail.pendingInteractions.filter(item => item.id !== interactionId) } },
  697. }
  698. }
  699. return {
  700. availability, capabilities, topicProtocol, hostProtocol, hostFlowProtocol, roomInstructionsProtocol, activityProtocol, rooms, selectedRoomId, selectedRoom, topics, topicsForRoom, loadRoomTopics, selectedTopicId, selectedTopic,
  701. messages, agents, pendingInteractions,
  702. connectionState, isLoading, isSending, error, hasMoreBefore,
  703. pinnedTopics, nodes, start, stop, refresh, selectRoom, selectTopic, startNewTopic, createRoom, updateRoom, archiveRoom, restoreRoom, archiveTopic, restoreTopic, setTopicPinned, renameTopic, loadPinnedTopics, archivedRooms, archivedTopics, addAgent, updateAgent,
  704. removeAgent, sendMessage, loadOlder, interruptAgent, approveInteraction, clarifyInteraction,
  705. }
  706. })