groups.ts 34 KB

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