workspaceStore.ts 72 KB

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