workspaceStore.ts 64 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107
  1. import { chmodSync, mkdirSync } from 'node:fs'
  2. import { join } from 'node:path'
  3. import { createHash, randomUUID } from 'node:crypto'
  4. import { DatabaseSync } from 'node:sqlite'
  5. import { EventEmitter } from 'node:events'
  6. import { messageAppendPatch } from '../shared/workspaceMessagePatch.js'
  7. import { z } from 'zod'
  8. import { HttpError } from './errors.js'
  9. import { isVisibleMessageFile } from '../shared/messageFiles.js'
  10. import type { StoredWorkspaceFile } from './workspaceAssets.js'
  11. import { notificationPlainText } from './notificationText.js'
  12. import {supportsHostEnvironment} from '../shared/workspace.js'
  13. import type { WorkspaceLifecycleAction, WorkspaceLifecyclePreview } from '../shared/workspaceLifecycle.js'
  14. import { decodeAgentMascotAvatar, isAgentImageAvatar, defaultAgentIdentity, encodeAgentAvatar, normalizeAvatar, randomAgentIdentity, MAX_AVATAR_DESCRIPTOR_LENGTH } from '../shared/agentIdentity.js'
  15. import type {
  16. WorkspaceAgent as Agent,
  17. WorkspaceConversation as Conversation,
  18. WorkspaceTask as Task,
  19. WorkspaceMessage as Message,
  20. WorkspaceRun as Run,
  21. WorkspaceEvent,
  22. } from '../shared/workspace.js'
  23. const name = z
  24. .string()
  25. .trim()
  26. .min(1)
  27. .max(100)
  28. .refine((v) => !/[\u0000-\u001f@]/.test(v), '名称不能包含 @ 或控制字符')
  29. const avatar = z
  30. .string()
  31. .max(MAX_AVATAR_DESCRIPTOR_LENGTH)
  32. .refine(
  33. (v) => !v || isAgentImageAvatar(v) || !!decodeAgentMascotAvatar(v) || /^builtin:team-animal:(fox|whale|owl|rabbit|bear)$/.test(v),
  34. '请选择有效的内置头像或 PNG、JPEG、WebP 图片',
  35. )
  36. const taskTitle = z.string().trim().min(1).max(100).refine((v) => !/[\u0000-\u001f]/.test(v), '标题不能包含控制字符')
  37. export const agentInput = z
  38. .object({
  39. name,
  40. avatar: avatar.default(''),
  41. instructions: z.string().max(24_000).default(''),
  42. execution:z.enum(['profile','computer']).default('profile'),
  43. computer:z.enum(['auto','cloud','vm','local','browser','off']).optional(),
  44. allowHostEnvironment:z.boolean().default(false),
  45. vmExecution:z.enum(['worker','profile']).optional(),
  46. browserProfile:z.enum(['persistent','temporary']).optional(),
  47. canManageTeam: z.boolean().default(false),
  48. canCollaborate: z.boolean().default(true),
  49. memoryEnabled: z.boolean().default(true),
  50. nodeId: z.string().default('local'),
  51. profile: z.string().min(1).max(256),
  52. })
  53. .strict()
  54. export const agentPatch = z
  55. .object({
  56. name: name.optional(),
  57. avatar: avatar.optional(),
  58. instructions: z.string().max(24_000).optional(),
  59. execution:z.enum(['profile','computer']).optional(),
  60. computer:z.enum(['auto','cloud','vm','local','browser','off']).optional(),
  61. allowHostEnvironment:z.boolean().optional(),
  62. vmExecution:z.enum(['worker','profile']).optional(),
  63. browserProfile:z.enum(['persistent','temporary']).optional(),
  64. nodeId:z.string().min(1).max(256).optional(),
  65. profile:z.string().min(1).max(256).optional(),
  66. canManageTeam: z.boolean().optional(),
  67. canCollaborate: z.boolean().optional(),
  68. memoryEnabled: z.boolean().optional(),
  69. archived: z.boolean().optional(),
  70. })
  71. .strict()
  72. const memberRoles = z.record(z.string().uuid(), z.object({
  73. name: z.string().trim().min(1).max(100),
  74. description: z.string().max(500),
  75. }).strict()).refine(value => Object.keys(value).length <= 8, '最多 8 个群内角色')
  76. export const groupInput = z
  77. .object({
  78. name,
  79. avatar: avatar.default(''),
  80. memberIds: z.array(z.string().uuid()).min(2).max(8),
  81. memberRoles: memberRoles.default({}),
  82. instructions: z.string().max(24_000).default(''),
  83. administratorId: z.string().uuid().optional(),
  84. collaborationMode: z.enum(['discussion', 'host', 'free']).optional(),
  85. mode: z.enum(['host', 'free']).default('host'),
  86. autoReplyIds: z.array(z.string().uuid()).max(8).default([]),
  87. maxReplyRounds: z.union([z.literal(-1), z.number().int().min(1).max(100)]).default(3),
  88. })
  89. .strict()
  90. export const conversationPatch = z
  91. .object({
  92. name: name.optional(),
  93. avatar: avatar.optional(),
  94. instructions: z.string().max(24_000).optional(),
  95. memberIds: z.array(z.string().uuid()).min(1).max(8).optional(),
  96. memberRoles: memberRoles.optional(),
  97. administratorId: z.string().uuid().optional(),
  98. collaborationMode: z.enum(['discussion', 'host', 'free']).optional(),
  99. mode: z.enum(['host', 'free']).optional(),
  100. autoReplyIds: z.array(z.string().uuid()).max(8).optional(),
  101. maxReplyRounds: z.union([z.literal(-1), z.number().int().min(1).max(100)]).optional(),
  102. archived: z.boolean().optional(),
  103. pinned: z.boolean().optional(),
  104. })
  105. .strict()
  106. export const taskInput = z
  107. .object({ title: z.string().trim().max(100).refine((v) => !/[\u0000-\u001f]/.test(v), '标题不能包含控制字符').optional() })
  108. .strict()
  109. export const taskPatch = z
  110. .object({ title: taskTitle })
  111. .strict()
  112. export function parse<T>(schema: z.ZodType<T>, value: unknown): T {
  113. const parsed = schema.safeParse(value)
  114. if (!parsed.success)
  115. throw new HttpError(
  116. 400,
  117. parsed.error.issues.map((i) => `${i.path.join('.')}: ${i.message}`).join('; '),
  118. 'invalid_workspace_request',
  119. )
  120. return parsed.data
  121. }
  122. export class WorkspaceStore {
  123. readonly messagePatchesEnabled: boolean
  124. private summaryTimers = new Map<string, ReturnType<typeof setTimeout>>()
  125. private scheduleSummary(owner: string, conversation: Conversation): void {
  126. const key = `${owner}\0${conversation.id}`
  127. if (this.summaryTimers.has(key)) return
  128. const timer = setTimeout(() => {
  129. this.summaryTimers.delete(key)
  130. try {
  131. const current = this.get<Conversation>(owner, 'conversation', conversation.id)
  132. if (!current || current.archived) return
  133. const latest = this.messages(owner, current.id, Number.MAX_SAFE_INTEGER, 1)[0]
  134. if (!latest || !latest.content.trim()) return
  135. this.atomic(() => {
  136. current.updatedAt = Date.now()
  137. current.lastMessageAt = Math.max(current.lastMessageAt ?? 0, latest.createdAt)
  138. current.preview = notificationPlainText(latest.content, { maximum: 160, fallback: '' })
  139. current.previewAgentId = latest.role === 'assistant' ? latest.agentId : undefined
  140. this.put(owner, 'conversation', current.id, current)
  141. this.event(owner, 'conversation.changed', current, current.id)
  142. })
  143. } catch { /* The message is already durable; a later update can retry its list summary. */ }
  144. }, Math.max(1, 250 - (Date.now() - conversation.updatedAt)))
  145. timer.unref(); this.summaryTimers.set(key, timer)
  146. }
  147. private wireEvents = new WeakMap<WorkspaceEvent, WorkspaceEvent>()
  148. wireEvent(event: WorkspaceEvent): WorkspaceEvent { return this.wireEvents.get(event) ?? event }
  149. hasPatchEvents(owner: string, after: number, through: number): boolean {
  150. return !!this.db.prepare("SELECT 1 FROM workspace_events WHERE owner=? AND seq>? AND seq<=? AND type='message.patch' LIMIT 1").get(owner, after, through)
  151. }
  152. prepareAgent?: (owner:string,agent:Agent)=>void
  153. private observers = new Set<(owner: string, event: WorkspaceEvent) => void>()
  154. observe(listener: (owner: string, event: WorkspaceEvent) => void): () => void {
  155. this.observers.add(listener)
  156. return () => { this.observers.delete(listener) }
  157. }
  158. private publish(owner: string, event: WorkspaceEvent): void {
  159. this.changes.emit(owner, event)
  160. for (const listener of this.observers) { try { listener(owner, event) } catch { /* Observers cannot undo a committed transaction. */ } }
  161. }
  162. readonly db: DatabaseSync
  163. readonly changes = new EventEmitter()
  164. private transactionEvents: Array<{ owner: string; event: WorkspaceEvent }> | undefined
  165. constructor(readonly home: string, options: { messagePatches?: boolean } = {}) {
  166. this.messagePatchesEnabled = options.messagePatches ?? process.env.HERMES_YAOYAO_WORKSPACE_MESSAGE_PATCHES !== '0'
  167. mkdirSync(home, { recursive: true, mode: 0o700 })
  168. const path = join(home, 'workspace.sqlite3')
  169. this.db = new DatabaseSync(path)
  170. chmodSync(path, 0o600)
  171. this.db.exec(`PRAGMA journal_mode=WAL; PRAGMA foreign_keys=ON; PRAGMA busy_timeout=5000;
  172. CREATE TABLE IF NOT EXISTS workspace_entities(owner TEXT NOT NULL,kind TEXT NOT NULL,id TEXT NOT NULL,data TEXT NOT NULL,PRIMARY KEY(owner,kind,id));
  173. CREATE TABLE IF NOT EXISTS workspace_events(seq INTEGER PRIMARY KEY AUTOINCREMENT,owner TEXT NOT NULL,type TEXT NOT NULL,conversation_id TEXT,data TEXT NOT NULL);
  174. CREATE INDEX IF NOT EXISTS workspace_events_owner ON workspace_events(owner,seq);
  175. CREATE TABLE IF NOT EXISTS workspace_commands(owner TEXT NOT NULL,id TEXT NOT NULL,fingerprint TEXT NOT NULL,result TEXT NOT NULL,PRIMARY KEY(owner,id));`)
  176. this.db.exec(`CREATE INDEX IF NOT EXISTS workspace_turn_run ON workspace_entities(owner, json_extract(data,'$.runId')) WHERE kind='turn';
  177. CREATE INDEX IF NOT EXISTS workspace_turn_status ON workspace_entities(owner, json_extract(data,'$.status'), json_extract(data,'$.planned')) WHERE kind='turn';
  178. CREATE INDEX IF NOT EXISTS workspace_turn_conversation ON workspace_entities(owner, json_extract(data,'$.conversationId')) WHERE kind='turn';
  179. CREATE INDEX IF NOT EXISTS workspace_message_task ON workspace_entities(owner, json_extract(data,'$.conversationTaskId'), json_extract(data,'$.seq')) WHERE kind='message';
  180. CREATE INDEX IF NOT EXISTS workspace_message_conversation ON workspace_entities(owner, json_extract(data,'$.conversationId'), json_extract(data,'$.seq')) WHERE kind='message';
  181. CREATE INDEX IF NOT EXISTS workspace_message_hidden ON workspace_entities(owner, json_extract(data,'$.conversationId'), json_extract(data,'$.conversationTaskId'), json_extract(data,'$.seq')) WHERE kind='message' AND json_extract(data,'$.visible')=0;
  182. CREATE INDEX IF NOT EXISTS workspace_task_unread ON workspace_entities(owner, json_extract(data,'$.conversationId'), json_extract(data,'$.unread')) WHERE kind='conversation-task';
  183. CREATE INDEX IF NOT EXISTS workspace_run_task ON workspace_entities(owner, json_extract(data,'$.conversationTaskId'), json_extract(data,'$.status')) WHERE kind='run';
  184. CREATE INDEX IF NOT EXISTS workspace_turn_task ON workspace_entities(owner, json_extract(data,'$.conversationTaskId'), json_extract(data,'$.status')) WHERE kind='turn';`)
  185. this.db.exec('CREATE TABLE IF NOT EXISTS server_identity(singleton INTEGER PRIMARY KEY CHECK(singleton=1), server_id TEXT NOT NULL, name TEXT NOT NULL, revision INTEGER NOT NULL)')
  186. this.db.prepare('INSERT OR IGNORE INTO server_identity VALUES(1, ?, ?, 0)').run(randomUUID(), '')
  187. this.changes.setMaxListeners(0)
  188. this.atomic(() => {
  189. this.db.exec('CREATE TABLE IF NOT EXISTS workspace_migrations(id TEXT PRIMARY KEY)')
  190. if (this.db.prepare('SELECT id FROM workspace_migrations WHERE id=?').get('avatar-v2')) return
  191. for (const owner of this.owners()) {
  192. for (const agent of this.list<Agent>(owner, 'agent')) {
  193. this.put(owner, 'agent', agent.id, { ...agent, avatar: normalizeAvatar(agent.avatar) })
  194. }
  195. for (const conversation of this.list<Conversation>(owner, 'conversation')) {
  196. if (conversation.kind !== 'direct') continue
  197. const agent = this.get<Agent>(owner, 'agent', conversation.memberIds[0] || '')
  198. this.put(owner, 'conversation', conversation.id, { ...conversation, avatar: agent?.avatar ?? normalizeAvatar(conversation.avatar) })
  199. }
  200. }
  201. this.db.prepare('INSERT INTO workspace_migrations VALUES(?)').run('avatar-v2')
  202. })
  203. this.atomic(() => {
  204. if (this.db.prepare('SELECT id FROM workspace_migrations WHERE id=?').get('avatar-random-default-v1')) return
  205. for (const owner of this.owners()) {
  206. for (const agent of this.list<Agent>(owner, 'agent')) {
  207. const identity = decodeAgentMascotAvatar(agent.avatar)
  208. const isSystemDefault = identity && !identity.imageDataURL && identity.shape === 'circle'
  209. && identity.color === '#00c875' && identity.expression === 'idle' && !identity.bodyId
  210. if (!isSystemDefault) continue
  211. const avatar = encodeAgentAvatar(randomAgentIdentity(agent.id, agent.name))
  212. this.put(owner, 'agent', agent.id, { ...agent, avatar })
  213. for (const conversation of this.list<Conversation>(owner, 'conversation')) {
  214. if (conversation.kind === 'direct' && conversation.memberIds[0] === agent.id) {
  215. this.put(owner, 'conversation', conversation.id, { ...conversation, avatar })
  216. }
  217. }
  218. }
  219. }
  220. this.db.prepare('INSERT INTO workspace_migrations VALUES(?)').run('avatar-random-default-v1')
  221. })
  222. this.atomic(() => {
  223. const migration = 'conversation-task-history-v1'
  224. if (this.db.prepare('SELECT id FROM workspace_migrations WHERE id=?').get(migration)) return
  225. for (const owner of this.owners()) {
  226. for (const conversation of this.list<Conversation>(owner, 'conversation')) {
  227. if (conversation.kind === 'group') this.backfillConversationTask(owner, conversation)
  228. }
  229. }
  230. this.db.prepare('INSERT INTO workspace_migrations VALUES(?)').run(migration)
  231. })
  232. this.atomic(() => {
  233. const migration = 'workspace-unread-flag-v1'
  234. if (this.db.prepare('SELECT id FROM workspace_migrations WHERE id=?').get(migration)) return
  235. for (const owner of this.owners()) {
  236. for (const task of this.list<Task>(owner, 'conversation-task')) {
  237. task.unread ??= task.unreadCount > 0
  238. task.unreadVersion ??= task.unread ? 1 : 0
  239. task.unreadCount = task.unread ? 1 : 0
  240. this.put(owner, 'conversation-task', task.id, task)
  241. }
  242. for (const conversation of this.list<Conversation>(owner, 'conversation')) {
  243. conversation.unread ??= conversation.kind === 'group'
  244. ? this.hasUnreadTask(owner, conversation.id)
  245. : !!this.db.prepare("SELECT 1 FROM workspace_entities WHERE owner=? AND kind='message' AND json_extract(data,'$.conversationId')=? AND json_extract(data,'$.seq')>? AND coalesce(json_extract(data,'$.visible'),1)!=0 LIMIT 1").get(owner, conversation.id, conversation.readSeq)
  246. conversation.unreadVersion ??= conversation.unread ? 1 : 0
  247. conversation.unreadCount = conversation.unread ? 1 : 0
  248. this.put(owner, 'conversation', conversation.id, conversation)
  249. }
  250. }
  251. this.db.prepare('INSERT INTO workspace_migrations VALUES(?)').run(migration)
  252. })
  253. this.atomic(() => {
  254. const migration='host-environment-option-v1'
  255. if(this.db.prepare('SELECT id FROM workspace_migrations WHERE id=?').get(migration))return
  256. for(const row of this.db.prepare("SELECT owner,id,data FROM workspace_entities WHERE kind='agent' AND json_extract(data,'$.computer')='hybrid'").all()){
  257. const agent=JSON.parse(String(row.data))
  258. this.put(String(row.owner),'agent',String(row.id),{...agent,computer:'vm',execution:'computer',allowHostEnvironment:!agent.temporaryGoalId&&!agent.remoteAgentId,revision:(agent.revision??0)+1})
  259. }
  260. this.db.prepare('INSERT INTO workspace_migrations VALUES(?)').run(migration)
  261. })
  262. }
  263. private hasUnreadTask(owner: string, conversationId: string): boolean {
  264. return !!this.db.prepare("SELECT 1 FROM workspace_entities WHERE owner=? AND kind='conversation-task' AND json_extract(data,'$.conversationId')=? AND json_extract(data,'$.unread')=1 LIMIT 1").get(owner, conversationId)
  265. }
  266. get<T>(owner: string, kind: string, id: string): T | undefined {
  267. const row = this.db
  268. .prepare('SELECT data FROM workspace_entities WHERE owner=? AND kind=? AND id=?')
  269. .get(owner, kind, id)
  270. return row ? (JSON.parse(String(row.data)) as T) : undefined
  271. }
  272. require<T>(owner: string, kind: string, id: string): T {
  273. const value = this.get<T>(owner, kind, id)
  274. if (!value) throw new HttpError(404, '记录不存在', 'workspace_not_found')
  275. return value
  276. }
  277. list<T>(owner: string, kind: string): T[] {
  278. return this.db
  279. .prepare('SELECT data FROM workspace_entities WHERE owner=? AND kind=?')
  280. .all(owner, kind)
  281. .map((r) => JSON.parse(String(r.data)) as T)
  282. }
  283. owners(): string[] {
  284. return this.db
  285. .prepare('SELECT DISTINCT owner FROM workspace_entities')
  286. .all()
  287. .map((r) => String(r.owner))
  288. }
  289. put(owner: string, kind: string, id: string, value: unknown): void {
  290. this.db
  291. .prepare(
  292. 'INSERT INTO workspace_entities VALUES(?,?,?,?) ON CONFLICT(owner,kind,id) DO UPDATE SET data=excluded.data',
  293. )
  294. .run(owner, kind, id, JSON.stringify(value))
  295. }
  296. remove(owner: string, kind: string, id: string): void {
  297. this.db
  298. .prepare('DELETE FROM workspace_entities WHERE owner=? AND kind=? AND id=?')
  299. .run(owner, kind, id)
  300. }
  301. atomic<T>(fn: () => T): T {
  302. if (this.transactionEvents) return fn()
  303. this.db.exec('BEGIN IMMEDIATE')
  304. this.transactionEvents = []
  305. try {
  306. const result = fn()
  307. this.db.exec('COMMIT')
  308. const events = this.transactionEvents
  309. this.transactionEvents = undefined
  310. for (const entry of events) this.publish(entry.owner, entry.event)
  311. return result
  312. } catch (error) {
  313. this.db.exec('ROLLBACK')
  314. this.transactionEvents = undefined
  315. throw error
  316. }
  317. }
  318. event(owner: string, type: string, data: unknown, conversationId?: string, projection?: { type: string; data: unknown }): number {
  319. const seq = Number(
  320. this.db
  321. .prepare('INSERT INTO workspace_events(owner,type,conversation_id,data) VALUES(?,?,?,?)')
  322. .run(owner, type, conversationId ?? null, JSON.stringify(data)).lastInsertRowid,
  323. )
  324. const wire = { seq, type, conversationId, data: this.eventDataForDisplay(owner, type, data) }
  325. const event = projection ? { seq, conversationId, type: projection.type, data: this.eventDataForDisplay(owner, projection.type, projection.data) } : wire
  326. if (projection) this.wireEvents.set(event, wire)
  327. if (this.transactionEvents) this.transactionEvents.push({ owner, event })
  328. else this.publish(owner, event)
  329. return seq
  330. }
  331. events(owner: string, after: number): WorkspaceEvent[] {
  332. return this.db
  333. .prepare('SELECT * FROM workspace_events WHERE owner=? AND seq>? ORDER BY seq LIMIT 250')
  334. .all(owner, after)
  335. .map((r) => ({
  336. seq: Number(r.seq),
  337. type: String(r.type),
  338. conversationId: r.conversation_id ? String(r.conversation_id) : undefined,
  339. data: this.eventDataForDisplay(owner, String(r.type), JSON.parse(String(r.data))),
  340. }))
  341. }
  342. cursor(owner: string): number {
  343. return Number(
  344. this.db
  345. .prepare('SELECT coalesce(max(seq),0) AS n FROM workspace_events WHERE owner=?')
  346. .get(owner)!.n,
  347. )
  348. }
  349. command<T>(owner: string, id: string, payload: unknown, fn: () => T): T {
  350. parse(z.string().uuid(), id)
  351. const fingerprint = createHash('sha256').update(JSON.stringify(payload)).digest('hex')
  352. return this.atomic(() => {
  353. const old = this.db
  354. .prepare('SELECT fingerprint,result FROM workspace_commands WHERE owner=? AND id=?')
  355. .get(owner, id)
  356. if (old) {
  357. if (old.fingerprint !== fingerprint)
  358. throw new HttpError(409, '请求编号已用于其他内容', 'idempotency_conflict')
  359. return JSON.parse(String(old.result)) as T
  360. }
  361. const result = fn()
  362. this.db
  363. .prepare('INSERT INTO workspace_commands VALUES(?,?,?,?)')
  364. .run(owner, id, fingerprint, JSON.stringify(result))
  365. return result
  366. })
  367. }
  368. taskMemberIds(owner:string,conversation:Conversation,taskId?:string):string[]{
  369. const base=conversation.memberIds.filter(id=>!this.get<Agent>(owner,'agent',id)?.temporaryGoalId)
  370. if(!taskId)return base
  371. const goal=this.get<import('../shared/agentTasks.js').AgentGoal>(owner,'goal',taskId)
  372. if(!goal||goal.conversationId!==conversation.id||['complete','blocked','cancelled','cancelling'].includes(goal.status))return base
  373. return [...new Set([...base,...this.list<Agent>(owner,'agent').filter(agent=>!agent.archived&&agent.temporaryGoalId===taskId&&agent.helperActivation===(goal.activation??1)).map(agent=>agent.id)])]
  374. }
  375. requireAgentTask(owner:string,agent:Agent,conversationId:string,taskId?:string):void {
  376. if(!agent.temporaryGoalId)return
  377. const conversation=this.require<Conversation>(owner,'conversation',conversationId)
  378. if(agent.temporaryGoalId!==taskId||!this.taskMemberIds(owner,conversation,taskId).includes(agent.id))throw new HttpError(403,'临时助手只能执行所属任务','helper_task_bound')
  379. }
  380. createAgent(owner: string, input: unknown, origin?: { createdByAgentId: string; createdFromRunId: string;temporaryGoalId?:string;helperActivation?:number;helperRunnerId?:string }): Agent {
  381. const body = parse(agentInput, input)
  382. if(!supportsHostEnvironment(body)||origin)body.allowHostEnvironment=false
  383. if(body.computer!=='vm'||origin)body.vmExecution='worker'
  384. if(body.vmExecution==='profile')body.allowHostEnvironment=true
  385. if (origin) this.require<Agent>(owner, 'agent', origin.createdByAgentId)
  386. return this.atomic(() => {
  387. if (
  388. this.list<Agent>(owner, 'agent').some(
  389. (a) => a.name.toLocaleLowerCase() === body.name.toLocaleLowerCase(),
  390. )
  391. )
  392. throw new HttpError(409, '机器人名称已存在', 'duplicate_agent_name')
  393. const now = Date.now(),
  394. id = randomUUID()
  395. const agent: Agent = {
  396. ...body,
  397. ...origin,
  398. avatar: body.avatar ? normalizeAvatar(body.avatar) : encodeAgentAvatar(randomAgentIdentity(id, body.name)),
  399. id,
  400. archived: false,
  401. revision: 1,
  402. teamAuthorizationVersion: body.canManageTeam ? 1 : 0,
  403. createdAt: now,
  404. updatedAt: now,
  405. }
  406. this.prepareAgent?.(owner,agent)
  407. this.put(owner, 'agent', id, agent)
  408. if(origin?.temporaryGoalId){this.event(owner,'agent.changed',agent);return agent}
  409. const conversation: Conversation = {
  410. id: randomUUID(),
  411. kind: 'direct',
  412. name: agent.name,
  413. avatar: agent.avatar,
  414. memberIds: [id],
  415. instructions: '',
  416. administratorId: id,
  417. mode: 'host',
  418. autoReplyIds: [],
  419. maxReplyRounds: 1,
  420. archived: false,
  421. pinned: false,
  422. readSeq: 0,
  423. unread: false,
  424. unreadVersion: 0,
  425. lastSeq: 0,
  426. preview: '',
  427. createdAt: now,
  428. updatedAt: now,
  429. }
  430. this.put(owner, 'conversation', conversation.id, conversation)
  431. this.event(owner, 'agent.changed', agent)
  432. this.event(owner, 'conversation.changed', conversation, conversation.id)
  433. return agent
  434. })
  435. }
  436. updateAgent(owner: string, id: string, input: unknown): Agent {
  437. const patch = parse(agentPatch, input)
  438. if (patch.avatar !== undefined) patch.avatar = normalizeAvatar(patch.avatar)
  439. return this.atomic(() => {
  440. const agent = this.require<Agent>(owner, 'agent', id)
  441. if(patch.vmExecution==='profile'&&patch.allowHostEnvironment===false)throw new HttpError(400,'本机协作模式需要本机环境,请选择虚拟机模式','computer_profile_host_required')
  442. if(agent.vmExecution==='profile'){
  443. if(patch.allowHostEnvironment===false&&patch.vmExecution===undefined)patch.vmExecution='worker'
  444. if(patch.vmExecution==='worker'&&patch.allowHostEnvironment===undefined)patch.allowHostEnvironment=false
  445. }
  446. if((patch.computer??agent.computer)!=='vm'&&(patch.vmExecution!==undefined||agent.vmExecution==='profile'))patch.vmExecution='worker'
  447. if((patch.vmExecution??agent.vmExecution)==='profile'&&(patch.computer??agent.computer)==='vm')patch.allowHostEnvironment=true
  448. if(!supportsHostEnvironment({...agent,...patch})&&(agent.allowHostEnvironment===true||patch.allowHostEnvironment!==undefined))patch.allowHostEnvironment=false
  449. const sourceChanged=(patch.nodeId!==undefined&&patch.nodeId!==agent.nodeId)||(patch.profile!==undefined&&patch.profile!==agent.profile)
  450. const hostPermissionChanged=patch.allowHostEnvironment!==undefined&&patch.allowHostEnvironment!==(agent.allowHostEnvironment===true)
  451. const destinationChanged=hostPermissionChanged||(patch.vmExecution!==undefined&&patch.vmExecution!==(agent.vmExecution??'worker'))||(patch.computer!==undefined&&patch.computer!==agent.computer)||(patch.browserProfile!==undefined&&patch.browserProfile!==(agent.browserProfile??'persistent'))
  452. if(sourceChanged||destinationChanged){
  453. if(this.list<{agentId:string;status:string}>(owner,'turn').some(work=>work.agentId===id&&['queued','running','waiting','uncertain','cancelling'].includes(work.status)))throw new HttpError(409,'请先停止当前任务,再修改机器人来源或电脑','agent_execution_busy')
  454. if(this.list<{owner:string;agentId:string;environmentId?:string;expiresAt:number}>('_system','computer-control').some(grant=>grant.owner===owner&&grant.expiresAt>Date.now()&&(grant.agentId===id||grant.environmentId===(agent.computerEnvironmentId??id))))throw new HttpError(409,'请先交还电脑控制权','agent_execution_busy')
  455. if(sourceChanged&&agent.computerEnvironmentId){const shared=this.require<{nodeId:string;profile:string;managedLocalVm?:boolean;managedCompose?:boolean}>(owner,'shared-computer',agent.computerEnvironmentId);if((patch.nodeId??agent.nodeId)!==shared.nodeId||(!shared.managedLocalVm&&!shared.managedCompose&&shared.profile!=='*'&&(patch.profile??agent.profile)!==shared.profile))throw new HttpError(409,'请先解除电脑共享,再切换到其他来源','shared_computer_active')}
  456. }
  457. if(agent.temporaryGoalId&&(Object.keys(patch).some(key=>key!=='archived')||patch.archived===false))throw new HttpError(409,'临时助手由所属任务管理,不能修改权限或恢复为持久成员','helper_task_bound')
  458. if(agent.computerEnvironmentId&&patch.execution==='profile')throw new HttpError(409,'请先解除电脑共享,再切换执行环境','shared_computer_active')
  459. if(patch.execution&&patch.execution!==(agent.execution??'profile')&&this.list<{agentId:string;status:string}>(owner,'turn').some(work=>work.agentId===id&&['running','waiting','uncertain'].includes(work.status)))throw new HttpError(409,'请先停止当前任务,再切换执行环境','agent_execution_busy')
  460. if (agent.remoteAgentId && Object.keys(patch).some(key => key !== 'archived'))
  461. throw new HttpError(409, '引用机器人的配置由远端管理', 'remote_agent_read_only')
  462. if (
  463. patch.name &&
  464. this.list<Agent>(owner, 'agent').some(
  465. (a) => a.id !== id && a.name.toLocaleLowerCase() === patch.name!.toLocaleLowerCase(),
  466. )
  467. )
  468. throw new HttpError(409, '机器人名称已存在', 'duplicate_agent_name')
  469. const next = { ...agent, ...patch, revision: agent.revision + 1, updatedAt: Date.now() }
  470. if (patch.canManageTeam !== undefined && patch.canManageTeam !== (agent.canManageTeam === true))
  471. next.teamAuthorizationVersion = (agent.teamAuthorizationVersion ?? 0) + 1
  472. this.put(owner, 'agent', id, next)
  473. if(sourceChanged)for(const binding of this.list<{id:string}>(owner,'binding').filter(binding=>binding.id.endsWith(`:${id}`))){this.put(owner,'binding-reset',binding.id,{id:binding.id});this.remove(owner,'binding',binding.id)}
  474. this.event(owner, 'agent.changed', next)
  475. for (const c of this.list<Conversation>(owner, 'conversation').filter(
  476. (c) => c.kind === 'direct' && c.memberIds[0] === id,
  477. )) {
  478. Object.assign(c, { name: next.name, avatar: next.avatar, archived: next.archived })
  479. this.put(owner, 'conversation', c.id, c)
  480. this.event(owner, 'conversation.changed', c, c.id)
  481. }
  482. return next
  483. })
  484. }
  485. conversationLifecycle(owner: string, id: string): WorkspaceLifecyclePreview {
  486. const conversation = this.require<Conversation>(owner, 'conversation', id)
  487. const agent = conversation.kind === 'direct' ? this.require<Agent>(owner, 'agent', conversation.memberIds[0]!) : undefined
  488. const groups = agent ? this.list<Conversation>(owner, 'conversation')
  489. .filter(group => group.kind === 'group' && group.memberIds.includes(agent.id))
  490. .sort((a, b) => a.id.localeCompare(b.id)) : []
  491. return {
  492. name: conversation.name,
  493. kind: conversation.kind,
  494. groups: groups.map(group => ({ id: group.id, name: group.name, administrator: group.administratorId === agent?.id })),
  495. confirmationToken: createHash('sha256').update(JSON.stringify([
  496. conversation.id, conversation.name, conversation.archived, agent?.revision,
  497. groups.map(group => [group.id, group.name, group.administratorId, group.memberIds]),
  498. ])).digest('hex'),
  499. }
  500. }
  501. private requireConversationIdle(owner: string, id: string): void {
  502. if (['run', 'turn'].some(kind => this.list<{ conversationId: string; status: string }>(owner, kind).some(run => run.conversationId === id && !['complete', 'failed', 'interrupted', 'skipped'].includes(run.status))))
  503. throw new HttpError(409, '聊天仍有任务未结束,请停止任务后重试', 'conversation_running')
  504. if (this.list<{ conversationId: string; origin?: { conversationId: string }; status: string }>(owner, 'goal').some(goal =>
  505. (goal.conversationId === id || goal.origin?.conversationId === id) && !['complete', 'blocked', 'cancelled'].includes(goal.status)))
  506. throw new HttpError(409, '聊天仍有关联任务未结束,请停止任务后重试', 'conversation_running')
  507. }
  508. changeConversationLifecycle(owner: string, id: string, action: WorkspaceLifecycleAction, confirmationToken?: string): void {
  509. this.atomic(() => {
  510. const conversation = this.require<Conversation>(owner, 'conversation', id)
  511. const agent = conversation.kind === 'direct' ? this.require<Agent>(owner, 'agent', conversation.memberIds[0]!) : undefined
  512. if (action === 'restore') {
  513. if (agent) this.updateAgent(owner, agent.id, { archived: false })
  514. else this.updateConversation(owner, id, { archived: false })
  515. return
  516. }
  517. const preview = this.conversationLifecycle(owner, id)
  518. const managed = preview.groups.filter(group => group.administrator)
  519. if (managed.length) throw new HttpError(409, `此 Bot 是群聊${managed.map(group => `「${group.name}」`).join('、')}的管理者,请先转交管理权,再归档或删除`, 'agent_group_administrator')
  520. if (confirmationToken !== undefined && confirmationToken !== preview.confirmationToken)
  521. throw new HttpError(409, '聊天或群聊成员已变化,请重新确认后操作', 'lifecycle_confirmation_stale')
  522. if (preview.groups.length && !confirmationToken)
  523. throw new HttpError(409, '此 Bot 仍是群聊成员,请确认退出群聊后再归档或删除', 'agent_in_group')
  524. if (agent?.temporaryGoalId) throw new HttpError(409, '临时助手由所属任务管理', 'helper_task_bound')
  525. if (agent?.computerEnvironmentId) throw new HttpError(409, '请先解除电脑共享,再归档或删除 Bot', 'shared_computer_active')
  526. if (agent && this.list<{ agentId: string; status: string }>(owner, 'turn').some(turn => turn.agentId === agent.id && !['complete', 'failed', 'interrupted', 'skipped'].includes(turn.status)))
  527. throw new HttpError(409, 'Bot 仍有任务未结束,请停止任务后重试', 'agent_running')
  528. for (const conversationId of [id, ...preview.groups.map(group => group.id)]) this.requireConversationIdle(owner, conversationId)
  529. for (const group of preview.groups) {
  530. const current = this.require<Conversation>(owner, 'conversation', group.id)
  531. this.updateConversation(owner, group.id, { memberIds: current.memberIds.filter(memberId => memberId !== agent!.id) })
  532. }
  533. if (agent) {
  534. this.updateAgent(owner, agent.id, { archived: true })
  535. if (action === 'delete') this.deleteAgent(owner, agent.id)
  536. } else {
  537. this.updateConversation(owner, id, { archived: true })
  538. if (action === 'delete') this.deleteConversation(owner, id)
  539. }
  540. })
  541. }
  542. deleteAgent(owner: string, id: string): void {
  543. this.atomic(() => {
  544. const agent = this.require<Agent>(owner, 'agent', id)
  545. if (!agent.archived) throw new HttpError(409, '请先归档 Bot,再删除', 'agent_not_archived')
  546. if (agent.temporaryGoalId) throw new HttpError(409, '临时助手由所属任务管理', 'helper_task_bound')
  547. if (agent.computerEnvironmentId) throw new HttpError(409, '请先解除电脑共享,再删除 Bot', 'shared_computer_active')
  548. const conversations = this.list<Conversation>(owner, 'conversation')
  549. if (conversations.some(c => c.kind === 'group' && c.memberIds.includes(id)))
  550. throw new HttpError(409, '此 Bot 仍是群聊成员,请先从群聊移除或删除相关群聊', 'agent_in_group')
  551. if (this.list<{ agentId: string; status: string }>(owner, 'turn').some(work => work.agentId === id && !['complete', 'failed', 'interrupted', 'skipped'].includes(work.status)))
  552. throw new HttpError(409, 'Bot 仍有任务未结束,请停止任务后重试', 'agent_running')
  553. for (const conversation of conversations.filter(c => c.kind === 'direct' && c.memberIds[0] === id))
  554. this.deleteConversation(owner, conversation.id, true)
  555. for(const kind of ['routine','routine-run'])for(const item of this.list<{id:string;agentId:string}>(owner,kind).filter(item=>item.agentId===id))this.remove(owner,kind,item.id)
  556. this.remove(owner, 'agent', id)
  557. this.event(owner, 'agent.deleted', { id })
  558. })
  559. }
  560. deleteConversation(owner: string, id: string, deletingAgent = false): void {
  561. this.atomic(() => {
  562. const conversation = this.require<Conversation>(owner, 'conversation', id)
  563. if (conversation.kind === 'direct' && !deletingAgent)
  564. throw new HttpError(400, '请删除对应的 Bot', 'delete_agent_instead')
  565. if (!conversation.archived) throw new HttpError(409, '请先归档聊天,再删除', 'conversation_not_archived')
  566. this.requireConversationIdle(owner, id)
  567. const taskIds = new Set(this.list<Task>(owner, 'conversation-task').filter(task => task.conversationId === id).map(task => task.id))
  568. for (const taskId of taskIds) {
  569. this.remove(owner, 'context', taskId)
  570. this.db.prepare("DELETE FROM workspace_entities WHERE owner=? AND kind='task-delivery' AND json_extract(data,'$.goalId')=?").run(owner, taskId)
  571. }
  572. // Keep files in the file library, matching task deletion; detach their chat references.
  573. for (const file of this.list<{ id: string; conversationId?: string; conversationTaskId?: string }>(owner, 'file')) {
  574. if (file.conversationId === id || (file.conversationTaskId && taskIds.has(file.conversationTaskId)))
  575. this.put(owner, 'file', file.id, { ...file, conversationId: undefined, conversationTaskId: undefined, messageId: undefined })
  576. }
  577. const bindingPrefix = `${id}:`
  578. this.db.prepare("DELETE FROM workspace_entities WHERE owner=? AND ((kind='binding' AND substr(id,1,?)=?) OR (kind='interaction-binding' AND substr(json_extract(data,'$.key'),1,?)=?))").run(owner, bindingPrefix.length, bindingPrefix, bindingPrefix.length, bindingPrefix)
  579. this.db.prepare("DELETE FROM workspace_entities WHERE owner=? AND kind IN ('message','run','turn','interaction','interaction-binding','binding','conversation-task','goal','assignment') AND json_extract(data,'$.conversationId')=?").run(owner, id)
  580. this.db.prepare("DELETE FROM workspace_commands WHERE owner=? AND json_extract(result,'$.conversationId')=?").run(owner, id)
  581. this.remove(owner, 'context', id)
  582. if(this.db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='workspace_inspector'").get())this.db.prepare('DELETE FROM workspace_inspector WHERE owner=? AND conversation_id=?').run(owner,id)
  583. this.remove(owner, 'conversation', id)
  584. this.event(owner, 'conversation.deleted', { id }, id)
  585. })
  586. }
  587. createGroup(owner: string, input: unknown): Conversation {
  588. const parsed = parse(groupInput, input)
  589. const body = { ...parsed, administratorId: parsed.administratorId ?? parsed.memberIds[0]! }
  590. if (body.collaborationMode) body.mode = body.collaborationMode === 'host' ? 'host' : 'free'
  591. const ids = new Set(body.memberIds)
  592. if (
  593. ids.size !== body.memberIds.length ||
  594. !ids.has(body.administratorId) ||
  595. body.autoReplyIds.some((id) => !ids.has(id)) ||
  596. Object.keys(body.memberRoles).some(id => !ids.has(id))
  597. )
  598. throw new HttpError(400, '群成员或管理员无效', 'invalid_members')
  599. for (const id of ids){
  600. const agent=this.require<Agent>(owner,'agent',id)
  601. if(agent.temporaryGoalId)throw new HttpError(409,'临时助手不能加入持久团队','helper_task_bound')
  602. if(agent.archived)throw new HttpError(409,'已归档机器人不能加入新群','agent_archived')
  603. }
  604. const now = Date.now()
  605. const c: Conversation = {
  606. ...body,
  607. id: randomUUID(),
  608. kind: 'group',
  609. archived: false,
  610. pinned: false,
  611. readSeq: 0,
  612. unread: false,
  613. unreadVersion: 0,
  614. lastSeq: 0,
  615. preview: '',
  616. createdAt: now,
  617. updatedAt: now,
  618. }
  619. this.atomic(() => {
  620. this.put(owner, 'conversation', c.id, c)
  621. this.event(owner, 'conversation.changed', c, c.id)
  622. this.createTask(owner, c.id, {})
  623. })
  624. return c
  625. }
  626. private backfillConversationTask(owner: string, conversation: Conversation): void {
  627. const legacyMessages = this.list<Message>(owner, 'message')
  628. .filter(message => message.conversationId === conversation.id && !message.conversationTaskId)
  629. .sort((a, b) => a.seq - b.seq || a.createdAt - b.createdAt || a.id.localeCompare(b.id))
  630. if (!legacyMessages.length) return
  631. const existingTasks = this.tasks(owner, conversation.id)
  632. const assignedTaskIds = new Set(
  633. this.list<Message>(owner, 'message')
  634. .filter(message => message.conversationId === conversation.id && message.conversationTaskId)
  635. .map(message => message.conversationTaskId!),
  636. )
  637. const reusable = existingTasks.length === 1
  638. && existingTasks[0]!.titleSource === 'automatic'
  639. && existingTasks[0]!.title === '新任务'
  640. && existingTasks[0]!.messageCount === 0
  641. && !assignedTaskIds.has(existingTasks[0]!.id)
  642. ? existingTasks[0]
  643. : undefined
  644. const visibleMessages = legacyMessages.filter(message => message.visible !== false)
  645. const meaningfulMessages = visibleMessages.filter(
  646. message => message.content.trim() || message.attachments.length,
  647. )
  648. const firstMessage = legacyMessages[0]!,
  649. lastMessage = meaningfulMessages.at(-1) ?? visibleMessages.at(-1) ?? legacyMessages.at(-1)!,
  650. firstUserMessage = visibleMessages.find(
  651. message => message.role === 'user' && (message.content.trim() || message.attachments.length),
  652. ),
  653. title = notificationPlainText(firstUserMessage?.content ?? '', {
  654. maximum: 48,
  655. fallback: firstUserMessage?.attachments[0]?.name ?? '',
  656. }).trim()
  657. || notificationPlainText(conversation.preview, { maximum: 48, fallback: '' }).trim()
  658. || '历史任务',
  659. lastSeq = legacyMessages.reduce((value, message) => Math.max(value, message.seq), 0),
  660. readSeq = Math.min(conversation.readSeq, lastSeq),
  661. task: Task = {
  662. ...(reusable ?? {
  663. id: randomUUID(),
  664. conversationId: conversation.id,
  665. title: '历史任务',
  666. titleSource: 'automatic' as const,
  667. messageCount: 0,
  668. readSeq: 0,
  669. lastSeq: 0,
  670. unreadCount: 0,
  671. createdAt: firstMessage.createdAt,
  672. updatedAt: lastMessage.createdAt,
  673. }),
  674. title,
  675. titleSource: 'automatic',
  676. messageCount: visibleMessages.length,
  677. readSeq,
  678. lastSeq,
  679. unreadCount: visibleMessages.some(message => message.seq > readSeq) ? 1 : 0,
  680. lastMessageAt: lastMessage.createdAt,
  681. createdAt: firstMessage.createdAt,
  682. updatedAt: lastMessage.createdAt,
  683. }
  684. for (const message of legacyMessages) {
  685. this.put(owner, 'message', message.id, {
  686. ...message,
  687. conversationTaskId: task.id,
  688. })
  689. }
  690. const migratedInteractions = new Map<string, string>()
  691. for (const kind of ['run', 'turn', 'interaction'] as const) {
  692. for (const entity of this.list<Record<string, unknown> & {
  693. id: string
  694. conversationId?: string
  695. conversationTaskId?: string
  696. agentId?: string
  697. }>(owner, kind)) {
  698. if (entity.conversationId !== conversation.id || entity.conversationTaskId) continue
  699. this.put(owner, kind, entity.id, { ...entity, conversationTaskId: task.id })
  700. if (kind === 'interaction' && entity.agentId) {
  701. migratedInteractions.set(entity.id, entity.agentId)
  702. }
  703. }
  704. }
  705. for (const [interactionId, agentId] of migratedInteractions) {
  706. const binding = this.get<Record<string, unknown> & {
  707. conversationTaskId?: string
  708. }>(owner, 'interaction-binding', interactionId)
  709. if (!binding || binding.conversationTaskId) continue
  710. this.put(owner, 'interaction-binding', interactionId, {
  711. ...binding,
  712. key: `${conversation.id}:${task.id}:${agentId}`,
  713. conversationTaskId: task.id,
  714. })
  715. }
  716. for (const agentId of conversation.memberIds) {
  717. const legacyKey = `${conversation.id}:${agentId}`,
  718. taskKey = `${conversation.id}:${task.id}:${agentId}`,
  719. binding = this.get<Record<string, unknown> & {
  720. conversationTaskId?: string
  721. }>(owner, 'binding', legacyKey)
  722. if (!binding || binding.conversationTaskId) continue
  723. this.put(owner, 'binding', taskKey, { ...binding, conversationTaskId: task.id })
  724. this.remove(owner, 'binding', legacyKey)
  725. }
  726. const context = this.get<Record<string, unknown> & {
  727. conversationTaskId?: string
  728. }>(owner, 'context', conversation.id)
  729. if (context && !context.conversationTaskId) {
  730. this.put(owner, 'context', task.id, { ...context, conversationTaskId: task.id })
  731. this.remove(owner, 'context', conversation.id)
  732. }
  733. this.db.prepare(
  734. `UPDATE workspace_commands
  735. SET result=json_set(result,'$.conversationTaskId',?)
  736. WHERE owner=? AND json_extract(result,'$.conversationId')=?
  737. AND json_extract(result,'$.conversationTaskId') IS NULL`,
  738. ).run(task.id, owner, conversation.id)
  739. const activeRuns = this.list<Run>(owner, 'run')
  740. .filter(run => run.conversationTaskId === task.id && !['complete', 'failed', 'interrupted'].includes(run.status))
  741. .sort((a, b) => a.createdAt - b.createdAt || a.id.localeCompare(b.id))
  742. task.activeRunId = activeRuns[0]?.id
  743. task.activeRunStatus = activeRuns[0]?.status
  744. this.put(owner, 'conversation-task', task.id, task)
  745. }
  746. tasks(owner: string, conversationId: string): Task[] {
  747. const conversation = this.require<Conversation>(owner, 'conversation', conversationId)
  748. if (conversation.kind !== 'group')
  749. throw new HttpError(400, '单聊不支持任务', 'conversation_tasks_unsupported')
  750. return this.list<Task>(owner, 'conversation-task')
  751. .filter((task) => task.conversationId === conversationId)
  752. .sort((a, b) => b.updatedAt - a.updatedAt || b.createdAt - a.createdAt || a.id.localeCompare(b.id))
  753. }
  754. createTask(owner: string, conversationId: string, input: unknown): Task {
  755. const body = parse(taskInput, input),
  756. conversation = this.require<Conversation>(owner, 'conversation', conversationId)
  757. if (conversation.kind !== 'group')
  758. throw new HttpError(400, '单聊不支持任务', 'conversation_tasks_unsupported')
  759. const now = Date.now(),
  760. explicitTitle = body.title?.trim(),
  761. task: Task = {
  762. id: randomUUID(),
  763. conversationId,
  764. title: explicitTitle || '新任务',
  765. titleSource: explicitTitle ? 'user' : 'automatic',
  766. messageCount: 0,
  767. readSeq: 0,
  768. lastSeq: 0,
  769. unread: false,
  770. unreadVersion: 0,
  771. unreadCount: 0,
  772. createdAt: now,
  773. updatedAt: now,
  774. }
  775. this.atomic(() => {
  776. this.put(owner, 'conversation-task', task.id, task)
  777. this.event(owner, 'task.changed', task, conversationId)
  778. })
  779. return task
  780. }
  781. requireTask(owner: string, conversationId: string, taskId: string): Task {
  782. const task = this.require<Task>(owner, 'conversation-task', taskId)
  783. if (task.conversationId !== conversationId)
  784. throw new HttpError(404, '任务不存在', 'workspace_task_not_found')
  785. return task
  786. }
  787. resolveTask(owner: string, conversationId: string, taskId?: string): Task | undefined {
  788. const conversation = this.require<Conversation>(owner, 'conversation', conversationId)
  789. if (conversation.kind === 'direct') {
  790. if (taskId) throw new HttpError(400, '单聊不支持任务', 'conversation_tasks_unsupported')
  791. return undefined
  792. }
  793. if (taskId) return this.requireTask(owner, conversationId, taskId)
  794. return this.tasks(owner, conversationId)[0] ?? this.createTask(owner, conversationId, {})
  795. }
  796. updateTask(owner: string, conversationId: string, taskId: string, input: unknown): Task {
  797. const patch = parse(taskPatch, input)
  798. return this.atomic(() => {
  799. const task = this.requireTask(owner, conversationId, taskId),
  800. next: Task = { ...task, title: patch.title, titleSource: 'user', updatedAt: Date.now() }
  801. this.put(owner, 'conversation-task', task.id, next)
  802. this.event(owner, 'task.changed', next, conversationId)
  803. return next
  804. })
  805. }
  806. markTaskRead(owner: string, conversationId: string, taskId: string, seq: number, unreadVersion?: number): Task {
  807. return this.atomic(() => {
  808. const task = this.requireTask(owner, conversationId, taskId)
  809. task.readSeq = Math.max(task.readSeq, Math.min(seq, task.lastSeq))
  810. if (unreadVersion === undefined ? seq >= task.lastSeq : unreadVersion === (task.unreadVersion ?? 0)) task.unread = false
  811. task.unreadCount = task.unread ? 1 : 0
  812. this.put(owner, 'conversation-task', task.id, task)
  813. const conversation = this.require<Conversation>(owner, 'conversation', conversationId)
  814. conversation.unread = this.hasUnreadTask(owner, conversationId)
  815. conversation.unreadCount = conversation.unread ? 1 : 0
  816. this.put(owner, 'conversation', conversationId, conversation)
  817. this.event(owner, 'task.changed', task, conversationId)
  818. this.event(owner, 'conversation.changed', conversation, conversationId)
  819. return task
  820. })
  821. }
  822. markConversationRead(owner: string, conversationId: string, seq: number, unreadVersion?: number): Conversation {
  823. return this.atomic(() => {
  824. const conversation = this.require<Conversation>(owner, 'conversation', conversationId)
  825. conversation.readSeq = Math.max(conversation.readSeq, Math.min(seq, conversation.lastSeq))
  826. if (conversation.kind === 'direct' && (unreadVersion === undefined ? seq >= conversation.lastSeq : unreadVersion === (conversation.unreadVersion ?? 0))) conversation.unread = false
  827. conversation.unreadCount = conversation.unread ? 1 : 0
  828. this.put(owner, 'conversation', conversationId, conversation)
  829. this.event(owner, 'conversation.changed', conversation, conversationId)
  830. return this.conversationSummary(owner, conversation)
  831. })
  832. }
  833. deleteTask(owner: string, conversationId: string, taskId: string): void {
  834. this.atomic(() => {
  835. const task = this.requireTask(owner, conversationId, taskId)
  836. if (this.list<Run>(owner, 'run').some(run => run.conversationTaskId === taskId && !['complete', 'failed', 'interrupted'].includes(run.status)))
  837. throw new HttpError(409, '任务仍在运行,暂时无法移除', 'workspace_task_running')
  838. const messageIds = new Set(this.list<Message>(owner, 'message').filter(message => message.conversationTaskId === taskId).map(message => message.id))
  839. this.db.prepare("DELETE FROM workspace_entities WHERE owner=? AND kind IN ('message','run','turn','interaction','interaction-binding','binding') AND json_extract(data,'$.conversationTaskId')=?").run(owner, taskId)
  840. this.remove(owner, 'context', taskId)
  841. this.remove(owner, 'conversation-task', taskId)
  842. this.db.prepare("DELETE FROM workspace_commands WHERE owner=? AND json_extract(result,'$.conversationTaskId')=?").run(owner, taskId)
  843. for (const file of this.list<Record<string, unknown> & { id: string; messageId?: string }>(owner, 'file')) {
  844. if (!file.messageId || !messageIds.has(file.messageId)) continue
  845. this.put(owner, 'file', String(file.id), { ...file, conversationId: undefined, messageId: undefined })
  846. }
  847. this.event(owner, 'task.deleted', { id: taskId, conversationId }, conversationId)
  848. const remaining = this.tasks(owner, conversationId)
  849. if (!remaining.length) this.createTask(owner, conversationId, {})
  850. const conversation = this.require<Conversation>(owner, 'conversation', conversationId),
  851. history = this.messages(owner, conversationId, Number.MAX_SAFE_INTEGER, Number.MAX_SAFE_INTEGER)
  852. const last = history.at(-1)
  853. conversation.preview = last ? notificationPlainText(last.content, { maximum: 160, fallback: '' }) : ''
  854. conversation.previewAgentId = last?.role === 'assistant' ? last.agentId : undefined
  855. conversation.lastMessageAt = last?.createdAt
  856. conversation.updatedAt = Date.now()
  857. conversation.unread = this.hasUnreadTask(owner, conversationId)
  858. conversation.unreadCount = conversation.unread ? 1 : 0
  859. this.projectConversationRuns(owner, conversation)
  860. this.put(owner, 'conversation', conversationId, conversation)
  861. this.event(owner, 'conversation.changed', conversation, conversationId)
  862. })
  863. }
  864. updateConversation(owner: string, id: string, input: unknown): Conversation {
  865. const patch = parse(conversationPatch, input),
  866. c = this.require<Conversation>(owner, 'conversation', id)
  867. if (c.kind === 'direct' && Object.keys(patch).some((k) => k !== 'pinned'))
  868. throw new HttpError(400, '请编辑机器人资料', 'edit_agent_instead')
  869. if (patch.collaborationMode !== undefined && patch.collaborationMode !== c.collaborationMode) {
  870. this.requireConversationIdle(owner, id)
  871. patch.mode = patch.collaborationMode === 'host' ? 'host' : 'free'
  872. } else if (patch.mode !== undefined && patch.mode !== c.mode && c.collaborationMode) patch.collaborationMode = patch.mode
  873. const memberIds = patch.memberIds ?? c.memberIds
  874. const members = new Set(memberIds)
  875. const discussion = (patch.collaborationMode ?? c.collaborationMode) === 'discussion'
  876. if (discussion && !members.has(patch.administratorId ?? c.administratorId)) patch.administratorId = memberIds[0]
  877. if (!discussion && !members.has(c.administratorId))
  878. throw new HttpError(400, '当前管理员不能移除,请先更换管理员并保存', 'administrator_required')
  879. if (
  880. members.size !== memberIds.length ||
  881. !members.has(patch.administratorId ?? c.administratorId) ||
  882. patch.autoReplyIds?.some((a) => !members.has(a) && !c.memberIds.includes(a)) ||
  883. Object.keys(patch.memberRoles ?? {}).some(a => !members.has(a) && !c.memberIds.includes(a))
  884. )
  885. throw new HttpError(400, '群成员或管理员无效', 'invalid_members')
  886. for (const memberId of memberIds) {
  887. const agent = this.require<Agent>(owner, 'agent', memberId)
  888. if(agent.temporaryGoalId)throw new HttpError(409,'临时助手不能加入其他群聊','helper_task_bound')
  889. if (!c.memberIds.includes(memberId) && agent.archived)
  890. throw new HttpError(409, '已归档机器人不能加入群聊', 'agent_archived')
  891. }
  892. const next = {
  893. ...c, ...patch, memberIds,
  894. autoReplyIds: (patch.autoReplyIds ?? c.autoReplyIds).filter(a => members.has(a)),
  895. memberRoles: Object.fromEntries(Object.entries(patch.memberRoles ?? c.memberRoles ?? {}).filter(([id]) => members.has(id))),
  896. updatedAt: Date.now(),
  897. }
  898. this.atomic(() => {
  899. this.put(owner, 'conversation', id, next)
  900. this.event(owner, 'conversation.changed', next, id)
  901. })
  902. return next
  903. }
  904. messages(
  905. owner: string,
  906. conversationId: string,
  907. before = Number.MAX_SAFE_INTEGER,
  908. limit = 100,
  909. includeHidden = false,
  910. conversationTaskId?: string,
  911. ): Message[] {
  912. this.require(owner, 'conversation', conversationId)
  913. if (conversationTaskId) this.requireTask(owner, conversationId, conversationTaskId)
  914. const taskClause = conversationTaskId ? "AND json_extract(data,'$.conversationTaskId')=?" : ''
  915. return this.db
  916. .prepare(
  917. `SELECT data FROM workspace_entities WHERE owner=? AND kind='message' AND json_extract(data,'$.conversationId')=? ${taskClause} AND json_extract(data,'$.seq')<? ${includeHidden ? '' : "AND coalesce(json_extract(data,'$.visible'),1) != 0"} ORDER BY json_extract(data,'$.seq') DESC LIMIT ?`,
  918. )
  919. .all(...(conversationTaskId ? [owner, conversationId, conversationTaskId, before, limit] : [owner, conversationId, before, limit]))
  920. .map((r) => this.messageForDisplay(owner, JSON.parse(String(r.data)) as Message))
  921. .reverse()
  922. }
  923. nativeMessageForFile?: (owner: string, file: StoredWorkspaceFile) => Message | undefined
  924. visibleFiles(owner: string): StoredWorkspaceFile[] {
  925. const messages = new Map(this.list<Message>(owner, 'message').map(message => [message.id, message]))
  926. return this.list<StoredWorkspaceFile>(owner, 'file').filter(file =>
  927. isVisibleMessageFile(file, messages.get(file.messageId ?? '') ?? this.nativeMessageForFile?.(owner, file)))
  928. }
  929. messageForDisplay(owner: string, message: Message): Message {
  930. const peer = message.peerMessageId && message.role === 'system'
  931. ? this.get<import('../shared/workspaceKnowledge.js').WorkspacePeerMessage>(owner, 'peer-message', message.peerMessageId) : undefined
  932. const incoming = !!peer && !!message.runId && message.conversationId === peer.conversationId && message.conversationTaskId === peer.taskId
  933. const outgoing = !!peer && !message.runId && message.conversationId === peer.originConversationId && message.conversationTaskId === peer.originTaskId
  934. if (peer && (incoming || outgoing)) {
  935. const peerId = incoming ? peer.fromAgentId : peer.toAgentId ?? peer.targetGroupId!
  936. const peerKind = outgoing && peer.targetGroupId ? 'group' : 'agent'
  937. const entity = peerKind === 'group' ? this.get<Conversation>(owner, 'conversation', peerId) : this.get<Agent>(owner, 'agent', peerId)
  938. const peerName = entity?.name || (incoming ? peer.fromName ?? message.agentName : peer.targetName) || (peerKind === 'group' ? '群聊' : 'Bot')
  939. const peerAvatar = entity?.avatar || (incoming ? peer.fromAvatar : peer.targetAvatar) || ''
  940. const files = peer.fileIds.flatMap(id => {
  941. const file = this.get<StoredWorkspaceFile>(owner, 'file', id) ?? message.attachments.find(file => file.id === id)
  942. return file ? [{ id: file.id, name: file.name, mimeType: file.mimeType, size: file.size, sender: file.sender, createdAt: file.createdAt }] : []
  943. })
  944. return { ...message, reasoning: '', tools: [], attachments: message.visible === false ? [] : files,
  945. communication: { direction: incoming ? 'incoming' : 'outgoing', peerId, peerName, peerAvatar, peerKind, content: peer.content } }
  946. }
  947. const attachments = message.role === 'user' || message.role === 'assistant'
  948. ? message.attachments.filter(file => isVisibleMessageFile(
  949. this.get<StoredWorkspaceFile>(owner, 'file', file.id) ?? file, message))
  950. : []
  951. return { ...message, attachments }
  952. }
  953. private eventDataForDisplay(owner: string, type: string, data: unknown): unknown {
  954. if (type === 'message.changed') return this.messageForDisplay(owner, data as Message)
  955. return data
  956. }
  957. saveMessage(owner: string, message: Message): void {
  958. this.atomic(() => {
  959. const c = this.require<Conversation>(owner, 'conversation', message.conversationId)
  960. const previous = this.get<Message>(owner, 'message', message.id)
  961. // Do not emit revisions for gateway events which did not change the message.
  962. if (previous?.status === 'streaming' && message.status === 'streaming' && JSON.stringify({ ...previous, revision: undefined }) === JSON.stringify({ ...message, revision: undefined })) return
  963. message.revision = (previous?.revision ?? 0) + 1
  964. const patch = this.messagePatchesEnabled ? messageAppendPatch(previous, message) : undefined
  965. const updateSummary = !patch || Date.now() - c.updatedAt >= 250
  966. if (previous && previous.conversationTaskId !== message.conversationTaskId)
  967. throw new HttpError(409, '消息不能移入其他任务', 'message_task_mismatch')
  968. const task = c.kind === 'group'
  969. ? message.conversationTaskId
  970. ? this.requireTask(owner, c.id, message.conversationTaskId)
  971. : (() => { throw new HttpError(400, '群聊消息必须指定任务', 'workspace_task_required') })()
  972. : undefined
  973. if (previous?.visible === false && message.visible !== false) message.seq = 0
  974. if (!message.seq) message.seq = c.lastSeq + 1
  975. c.lastSeq = Math.max(c.lastSeq, message.seq)
  976. const needsAttention = (value: Message | undefined) => !!value && value.role !== 'user' && value.visible !== false
  977. && ['complete', 'failed', 'interrupted'].includes(value.status)
  978. && !!(value.content.trim() || value.error || this.messageForDisplay(owner, value).attachments.length)
  979. if (needsAttention(message) && !needsAttention(previous)) {
  980. c.unread = true
  981. c.unreadVersion = (c.unreadVersion ?? 0) + 1
  982. if (task) { task.unread = true; task.unreadVersion = (task.unreadVersion ?? 0) + 1 }
  983. }
  984. c.unreadCount = c.unread ? 1 : 0
  985. if (updateSummary && message.visible !== false && (message.content.trim() || message.attachments.length)) {
  986. c.updatedAt = Date.now()
  987. c.lastMessageAt = Math.max(c.lastMessageAt ?? 0, message.createdAt)
  988. c.preview = notificationPlainText(message.content, { maximum: 160, fallback: '' })
  989. c.previewAgentId = message.role === 'assistant' ? message.agentId : undefined
  990. }
  991. this.put(owner, 'message', message.id, message)
  992. if (updateSummary) this.put(owner, 'conversation', c.id, c)
  993. if (task && updateSummary) {
  994. const becameVisible = message.visible !== false && (!previous || previous.visible === false),
  995. becameHidden = Boolean(previous && previous.visible !== false && message.visible === false)
  996. if (becameVisible) task.messageCount += 1
  997. if (becameHidden) task.messageCount = Math.max(0, task.messageCount - 1)
  998. task.lastSeq = Math.max(task.lastSeq, message.seq)
  999. task.unreadCount = task.unread ? 1 : 0
  1000. if (message.visible !== false && (message.content.trim() || message.attachments.length)) {
  1001. if (message.role === 'user' && task.titleSource === 'automatic' && task.messageCount <= 1) {
  1002. const source = notificationPlainText(message.content, { maximum: 48, fallback: message.attachments[0]?.name ?? '' }).trim()
  1003. if (source) task.title = source
  1004. }
  1005. task.lastMessageAt = Math.max(task.lastMessageAt ?? 0, message.createdAt)
  1006. task.updatedAt = Date.now()
  1007. }
  1008. this.put(owner, 'conversation-task', task.id, task)
  1009. this.event(owner, 'task.changed', task, c.id)
  1010. }
  1011. if (patch) this.event(owner, 'message.patch', patch, c.id, { type: 'message.changed', data: message })
  1012. else this.event(owner, 'message.changed', message, c.id)
  1013. if (updateSummary) {
  1014. const key = `${owner}\0${c.id}`
  1015. clearTimeout(this.summaryTimers.get(key)); this.summaryTimers.delete(key)
  1016. this.event(owner, 'conversation.changed', c, c.id)
  1017. } else this.scheduleSummary(owner, c)
  1018. })
  1019. }
  1020. agentSummary(agent: Agent): Agent {
  1021. return { ...agent, avatar: agent.avatar || encodeAgentAvatar(defaultAgentIdentity(agent.id, agent.name)) }
  1022. }
  1023. conversationSummary(owner: string, conversation: Conversation): Conversation {
  1024. const member = conversation.kind === 'direct' ? this.get<Agent>(owner, 'agent', conversation.memberIds[0] ?? '') : undefined
  1025. return {
  1026. ...conversation,
  1027. unread: conversation.unread ?? false,
  1028. unreadVersion: conversation.unreadVersion ?? 0,
  1029. unreadCount: conversation.unread ? 1 : 0,
  1030. ...(member ? { avatar: this.agentSummary(member).avatar } : {}),
  1031. lastMessageAt: conversation.lastMessageAt
  1032. ?? (conversation.lastSeq > 0 ? this.messages(owner, conversation.id, Number.MAX_SAFE_INTEGER, 1).at(-1)?.createdAt : undefined)
  1033. ?? conversation.createdAt,
  1034. }
  1035. }
  1036. hiddenMessageIds(owner: string, conversationId: string, conversationTaskId?: string): string[] {
  1037. this.require(owner, 'conversation', conversationId)
  1038. if (conversationTaskId) this.requireTask(owner, conversationId, conversationTaskId)
  1039. const taskClause = conversationTaskId ? "AND json_extract(data,'$.conversationTaskId')=?" : ''
  1040. return this.db.prepare(`SELECT id FROM workspace_entities WHERE owner=? AND kind='message' AND json_extract(data,'$.conversationId')=? AND json_extract(data,'$.visible')=0 ${taskClause} ORDER BY json_extract(data,'$.seq') ASC`)
  1041. .all(...(conversationTaskId ? [owner, conversationId, conversationTaskId] : [owner, conversationId])).map(row => String(row.id))
  1042. }
  1043. saveRun(owner: string, run: Run): void {
  1044. this.atomic(() => {
  1045. const c = this.require<Conversation>(owner, 'conversation', run.conversationId)
  1046. run.updatedAt = Date.now()
  1047. this.put(owner, 'run', run.id, run)
  1048. if (run.conversationTaskId) {
  1049. const task = this.requireTask(owner, c.id, run.conversationTaskId),
  1050. activeRuns = this.list<Run>(owner, 'run')
  1051. .filter(root => root.conversationTaskId === task.id && !['complete', 'failed', 'interrupted'].includes(root.status))
  1052. .sort((a, b) => a.createdAt - b.createdAt || a.id.localeCompare(b.id)),
  1053. active = activeRuns[0]
  1054. task.activeRunId = active?.id
  1055. task.activeRunStatus = active?.status
  1056. this.put(owner, 'conversation-task', task.id, task)
  1057. this.event(owner, 'task.changed', task, c.id)
  1058. }
  1059. this.projectConversationRuns(owner, c)
  1060. this.put(owner, 'conversation', c.id, c)
  1061. this.event(owner, 'run.changed', run, c.id)
  1062. this.event(owner, 'conversation.changed', c, c.id)
  1063. })
  1064. }
  1065. activeTaskCount(owner: string, conversationId: string): number {
  1066. return new Set(
  1067. this.list<Run>(owner, 'run')
  1068. .filter(run => run.conversationId === conversationId && run.conversationTaskId && !['complete', 'failed', 'interrupted'].includes(run.status))
  1069. .map(run => run.conversationTaskId!),
  1070. ).size
  1071. }
  1072. private projectConversationRuns(owner: string, conversation: Conversation): void {
  1073. const roots = this.list<Run>(owner, 'run').filter(run => run.conversationId === conversation.id && !['complete', 'failed', 'interrupted'].includes(run.status))
  1074. .sort((a, b) => this.require<Message>(owner, 'message', a.messageId).seq - this.require<Message>(owner, 'message', b.messageId).seq),
  1075. current = roots[0]
  1076. conversation.activeRunId = current?.id
  1077. conversation.activeAgentId = current?.activeAgentId
  1078. conversation.activeRunStatus = current?.status
  1079. const turns = this.db.prepare("SELECT data FROM workspace_entities WHERE owner=? AND kind='turn' AND json_extract(data,'$.conversationId')=? AND json_extract(data,'$.status') IN ('running','waiting','uncertain','queued') AND (json_extract(data,'$.status') != 'queued' OR json_extract(data,'$.planned')=1) ORDER BY CASE json_extract(data,'$.status') WHEN 'queued' THEN 0 WHEN 'running' THEN 1 ELSE 2 END").all(owner, conversation.id)
  1080. .map(row => JSON.parse(String(row.data)) as { agentId: string; status: string })
  1081. conversation.activeAgentStates = Object.fromEntries(turns.map(turn => [turn.agentId, turn.status as 'running' | 'waiting' | 'uncertain' | 'queued']))
  1082. const outcomes = this.db.prepare("SELECT data FROM workspace_entities WHERE owner=? AND kind='turn' AND json_extract(data,'$.conversationId')=? AND json_extract(data,'$.status') IN ('complete','failed') AND json_extract(data,'$.updatedAt')>=? ORDER BY json_extract(data,'$.updatedAt') ASC").all(owner, conversation.id, Date.now() - 2000)
  1083. .map(row => JSON.parse(String(row.data)) as {id:string;agentId:string;status:string;updatedAt:number})
  1084. conversation.avatarSignals = Object.fromEntries(outcomes.filter(turn => Date.now() - turn.updatedAt < 2000).map(turn => [turn.agentId, { id:turn.id, state:turn.status === 'failed' ? 'failure' as const : 'success' as const, at:turn.updatedAt }]))
  1085. conversation.queuedMessageCount = roots.filter(run => run.status === 'queued').length
  1086. }
  1087. ownsUpstream(id: string): boolean {
  1088. return Boolean(
  1089. this.db
  1090. .prepare(
  1091. `SELECT 1 FROM workspace_entities WHERE kind='binding' AND (json_extract(data,'$.storedId')=? OR json_extract(data,'$.runtimeId')=? OR EXISTS(SELECT 1 FROM json_each(json_extract(data,'$.aliases')) WHERE value=?)) LIMIT 1`,
  1092. )
  1093. .get(id, id, id),
  1094. )
  1095. }
  1096. close(): void {
  1097. for (const timer of this.summaryTimers.values()) clearTimeout(timer)
  1098. this.summaryTimers.clear()
  1099. this.changes.emit('workspace.close')
  1100. this.changes.removeAllListeners()
  1101. this.db.close()
  1102. }
  1103. }