workspaceStore.ts 46 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872
  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 { z } from 'zod'
  7. import { HttpError } from './errors.js'
  8. import { isVisibleMessageFile } from '../shared/messageFiles.js'
  9. import type { StoredWorkspaceFile } from './workspaceAssets.js'
  10. import { notificationPlainText } from './notificationText.js'
  11. import { decodeAgentMascotAvatar, isAgentImageAvatar, defaultAgentIdentity, encodeAgentAvatar, normalizeAvatar, randomAgentIdentity, MAX_AVATAR_DESCRIPTOR_LENGTH } from '../shared/agentIdentity.js'
  12. import type {
  13. WorkspaceAgent as Agent,
  14. WorkspaceConversation as Conversation,
  15. WorkspaceTask as Task,
  16. WorkspaceMessage as Message,
  17. WorkspaceRun as Run,
  18. WorkspaceEvent,
  19. } from '../shared/workspace.js'
  20. const name = z
  21. .string()
  22. .trim()
  23. .min(1)
  24. .max(100)
  25. .refine((v) => !/[\u0000-\u001f@]/.test(v), '名称不能包含 @ 或控制字符')
  26. const avatar = z
  27. .string()
  28. .max(MAX_AVATAR_DESCRIPTOR_LENGTH)
  29. .refine(
  30. (v) => !v || isAgentImageAvatar(v) || !!decodeAgentMascotAvatar(v) || /^builtin:team-animal:(fox|whale|owl|rabbit|bear)$/.test(v),
  31. '请选择有效的内置头像或 PNG、JPEG、WebP 图片',
  32. )
  33. const taskTitle = z.string().trim().min(1).max(100).refine((v) => !/[\u0000-\u001f]/.test(v), '标题不能包含控制字符')
  34. export const agentInput = z
  35. .object({
  36. name,
  37. avatar: avatar.default(''),
  38. instructions: z.string().max(24_000).default(''),
  39. execution:z.enum(['profile','computer']).default('profile'),
  40. canManageTeam: z.boolean().default(false),
  41. nodeId: z.string().default('local'),
  42. profile: z.string().min(1).max(256),
  43. })
  44. .strict()
  45. export const agentPatch = z
  46. .object({
  47. name: name.optional(),
  48. avatar: avatar.optional(),
  49. instructions: z.string().max(24_000).optional(),
  50. execution:z.enum(['profile','computer']).optional(),
  51. canManageTeam: z.boolean().optional(),
  52. archived: z.boolean().optional(),
  53. })
  54. .strict()
  55. const memberRoles = z.record(z.string().uuid(), z.object({
  56. name: z.string().trim().min(1).max(100),
  57. description: z.string().max(500),
  58. }).strict()).refine(value => Object.keys(value).length <= 8, '最多 8 个群内角色')
  59. export const groupInput = z
  60. .object({
  61. name,
  62. avatar: avatar.default(''),
  63. memberIds: z.array(z.string().uuid()).min(2).max(8),
  64. memberRoles: memberRoles.default({}),
  65. instructions: z.string().max(24_000).default(''),
  66. administratorId: z.string().uuid(),
  67. mode: z.enum(['host', 'free']).default('host'),
  68. autoReplyIds: z.array(z.string().uuid()).max(8).default([]),
  69. maxReplyRounds: z.union([z.literal(-1), z.number().int().min(1).max(100)]).default(3),
  70. })
  71. .strict()
  72. export const conversationPatch = z
  73. .object({
  74. name: name.optional(),
  75. avatar: avatar.optional(),
  76. instructions: z.string().max(24_000).optional(),
  77. memberIds: z.array(z.string().uuid()).min(1).max(8).optional(),
  78. memberRoles: memberRoles.optional(),
  79. administratorId: z.string().uuid().optional(),
  80. mode: z.enum(['host', 'free']).optional(),
  81. autoReplyIds: z.array(z.string().uuid()).max(8).optional(),
  82. maxReplyRounds: z.union([z.literal(-1), z.number().int().min(1).max(100)]).optional(),
  83. archived: z.boolean().optional(),
  84. pinned: z.boolean().optional(),
  85. })
  86. .strict()
  87. export const taskInput = z
  88. .object({ title: z.string().trim().max(100).refine((v) => !/[\u0000-\u001f]/.test(v), '标题不能包含控制字符').optional() })
  89. .strict()
  90. export const taskPatch = z
  91. .object({ title: taskTitle })
  92. .strict()
  93. export function parse<T>(schema: z.ZodType<T>, value: unknown): T {
  94. const parsed = schema.safeParse(value)
  95. if (!parsed.success)
  96. throw new HttpError(
  97. 400,
  98. parsed.error.issues.map((i) => `${i.path.join('.')}: ${i.message}`).join('; '),
  99. 'invalid_workspace_request',
  100. )
  101. return parsed.data
  102. }
  103. export class WorkspaceStore {
  104. prepareAgent?: (owner:string,agent:Agent)=>void
  105. private observers = new Set<(owner: string, event: WorkspaceEvent) => void>()
  106. observe(listener: (owner: string, event: WorkspaceEvent) => void): () => void {
  107. this.observers.add(listener)
  108. return () => { this.observers.delete(listener) }
  109. }
  110. private publish(owner: string, event: WorkspaceEvent): void {
  111. this.changes.emit(owner, event)
  112. for (const listener of this.observers) { try { listener(owner, event) } catch { /* Observers cannot undo a committed transaction. */ } }
  113. }
  114. readonly db: DatabaseSync
  115. readonly changes = new EventEmitter()
  116. private transactionEvents: Array<{ owner: string; event: WorkspaceEvent }> | undefined
  117. constructor(home: string) {
  118. mkdirSync(home, { recursive: true, mode: 0o700 })
  119. const path = join(home, 'workspace.sqlite3')
  120. this.db = new DatabaseSync(path)
  121. chmodSync(path, 0o600)
  122. this.db.exec(`PRAGMA journal_mode=WAL; PRAGMA foreign_keys=ON; PRAGMA busy_timeout=5000;
  123. 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));
  124. 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);
  125. CREATE INDEX IF NOT EXISTS workspace_events_owner ON workspace_events(owner,seq);
  126. 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));`)
  127. this.db.exec(`CREATE INDEX IF NOT EXISTS workspace_turn_run ON workspace_entities(owner, json_extract(data,'$.runId')) WHERE kind='turn';
  128. CREATE INDEX IF NOT EXISTS workspace_turn_status ON workspace_entities(owner, json_extract(data,'$.status'), json_extract(data,'$.planned')) WHERE kind='turn';
  129. CREATE INDEX IF NOT EXISTS workspace_turn_conversation ON workspace_entities(owner, json_extract(data,'$.conversationId')) WHERE kind='turn';
  130. CREATE INDEX IF NOT EXISTS workspace_message_task ON workspace_entities(owner, json_extract(data,'$.conversationTaskId'), json_extract(data,'$.seq')) WHERE kind='message';
  131. CREATE INDEX IF NOT EXISTS workspace_run_task ON workspace_entities(owner, json_extract(data,'$.conversationTaskId'), json_extract(data,'$.status')) WHERE kind='run';
  132. CREATE INDEX IF NOT EXISTS workspace_turn_task ON workspace_entities(owner, json_extract(data,'$.conversationTaskId'), json_extract(data,'$.status')) WHERE kind='turn';`)
  133. 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)')
  134. this.db.prepare('INSERT OR IGNORE INTO server_identity VALUES(1, ?, ?, 0)').run(randomUUID(), '')
  135. this.changes.setMaxListeners(0)
  136. this.atomic(() => {
  137. this.db.exec('CREATE TABLE IF NOT EXISTS workspace_migrations(id TEXT PRIMARY KEY)')
  138. if (this.db.prepare('SELECT id FROM workspace_migrations WHERE id=?').get('avatar-v2')) return
  139. for (const owner of this.owners()) {
  140. for (const agent of this.list<Agent>(owner, 'agent')) {
  141. this.put(owner, 'agent', agent.id, { ...agent, avatar: normalizeAvatar(agent.avatar) })
  142. }
  143. for (const conversation of this.list<Conversation>(owner, 'conversation')) {
  144. if (conversation.kind !== 'direct') continue
  145. const agent = this.get<Agent>(owner, 'agent', conversation.memberIds[0] || '')
  146. this.put(owner, 'conversation', conversation.id, { ...conversation, avatar: agent?.avatar ?? normalizeAvatar(conversation.avatar) })
  147. }
  148. }
  149. this.db.prepare('INSERT INTO workspace_migrations VALUES(?)').run('avatar-v2')
  150. })
  151. this.atomic(() => {
  152. if (this.db.prepare('SELECT id FROM workspace_migrations WHERE id=?').get('avatar-random-default-v1')) return
  153. for (const owner of this.owners()) {
  154. for (const agent of this.list<Agent>(owner, 'agent')) {
  155. const identity = decodeAgentMascotAvatar(agent.avatar)
  156. const isSystemDefault = identity && !identity.imageDataURL && identity.shape === 'circle'
  157. && identity.color === '#00c875' && identity.expression === 'idle' && !identity.bodyId
  158. if (!isSystemDefault) continue
  159. const avatar = encodeAgentAvatar(randomAgentIdentity(agent.id, agent.name))
  160. this.put(owner, 'agent', agent.id, { ...agent, avatar })
  161. for (const conversation of this.list<Conversation>(owner, 'conversation')) {
  162. if (conversation.kind === 'direct' && conversation.memberIds[0] === agent.id) {
  163. this.put(owner, 'conversation', conversation.id, { ...conversation, avatar })
  164. }
  165. }
  166. }
  167. }
  168. this.db.prepare('INSERT INTO workspace_migrations VALUES(?)').run('avatar-random-default-v1')
  169. })
  170. this.atomic(() => {
  171. const migration = 'conversation-task-history-v1'
  172. if (this.db.prepare('SELECT id FROM workspace_migrations WHERE id=?').get(migration)) return
  173. for (const owner of this.owners()) {
  174. for (const conversation of this.list<Conversation>(owner, 'conversation')) {
  175. if (conversation.kind === 'group') this.backfillConversationTask(owner, conversation)
  176. }
  177. }
  178. this.db.prepare('INSERT INTO workspace_migrations VALUES(?)').run(migration)
  179. })
  180. }
  181. get<T>(owner: string, kind: string, id: string): T | undefined {
  182. const row = this.db
  183. .prepare('SELECT data FROM workspace_entities WHERE owner=? AND kind=? AND id=?')
  184. .get(owner, kind, id)
  185. return row ? (JSON.parse(String(row.data)) as T) : undefined
  186. }
  187. require<T>(owner: string, kind: string, id: string): T {
  188. const value = this.get<T>(owner, kind, id)
  189. if (!value) throw new HttpError(404, '记录不存在', 'workspace_not_found')
  190. return value
  191. }
  192. list<T>(owner: string, kind: string): T[] {
  193. return this.db
  194. .prepare('SELECT data FROM workspace_entities WHERE owner=? AND kind=?')
  195. .all(owner, kind)
  196. .map((r) => JSON.parse(String(r.data)) as T)
  197. }
  198. owners(): string[] {
  199. return this.db
  200. .prepare('SELECT DISTINCT owner FROM workspace_entities')
  201. .all()
  202. .map((r) => String(r.owner))
  203. }
  204. put(owner: string, kind: string, id: string, value: unknown): void {
  205. this.db
  206. .prepare(
  207. 'INSERT INTO workspace_entities VALUES(?,?,?,?) ON CONFLICT(owner,kind,id) DO UPDATE SET data=excluded.data',
  208. )
  209. .run(owner, kind, id, JSON.stringify(value))
  210. }
  211. remove(owner: string, kind: string, id: string): void {
  212. this.db
  213. .prepare('DELETE FROM workspace_entities WHERE owner=? AND kind=? AND id=?')
  214. .run(owner, kind, id)
  215. }
  216. atomic<T>(fn: () => T): T {
  217. if (this.transactionEvents) return fn()
  218. this.db.exec('BEGIN IMMEDIATE')
  219. this.transactionEvents = []
  220. try {
  221. const result = fn()
  222. this.db.exec('COMMIT')
  223. const events = this.transactionEvents
  224. this.transactionEvents = undefined
  225. for (const entry of events) this.publish(entry.owner, entry.event)
  226. return result
  227. } catch (error) {
  228. this.db.exec('ROLLBACK')
  229. this.transactionEvents = undefined
  230. throw error
  231. }
  232. }
  233. event(owner: string, type: string, data: unknown, conversationId?: string): number {
  234. const seq = Number(
  235. this.db
  236. .prepare('INSERT INTO workspace_events(owner,type,conversation_id,data) VALUES(?,?,?,?)')
  237. .run(owner, type, conversationId ?? null, JSON.stringify(data)).lastInsertRowid,
  238. )
  239. const event = { seq, type, conversationId, data: this.eventDataForDisplay(owner, type, data) }
  240. if (this.transactionEvents) this.transactionEvents.push({ owner, event })
  241. else this.publish(owner, event)
  242. return seq
  243. }
  244. events(owner: string, after: number): WorkspaceEvent[] {
  245. return this.db
  246. .prepare('SELECT * FROM workspace_events WHERE owner=? AND seq>? ORDER BY seq LIMIT 250')
  247. .all(owner, after)
  248. .map((r) => ({
  249. seq: Number(r.seq),
  250. type: String(r.type),
  251. conversationId: r.conversation_id ? String(r.conversation_id) : undefined,
  252. data: this.eventDataForDisplay(owner, String(r.type), JSON.parse(String(r.data))),
  253. }))
  254. }
  255. cursor(owner: string): number {
  256. return Number(
  257. this.db
  258. .prepare('SELECT coalesce(max(seq),0) AS n FROM workspace_events WHERE owner=?')
  259. .get(owner)!.n,
  260. )
  261. }
  262. command<T>(owner: string, id: string, payload: unknown, fn: () => T): T {
  263. parse(z.string().uuid(), id)
  264. const fingerprint = createHash('sha256').update(JSON.stringify(payload)).digest('hex')
  265. return this.atomic(() => {
  266. const old = this.db
  267. .prepare('SELECT fingerprint,result FROM workspace_commands WHERE owner=? AND id=?')
  268. .get(owner, id)
  269. if (old) {
  270. if (old.fingerprint !== fingerprint)
  271. throw new HttpError(409, '请求编号已用于其他内容', 'idempotency_conflict')
  272. return JSON.parse(String(old.result)) as T
  273. }
  274. const result = fn()
  275. this.db
  276. .prepare('INSERT INTO workspace_commands VALUES(?,?,?,?)')
  277. .run(owner, id, fingerprint, JSON.stringify(result))
  278. return result
  279. })
  280. }
  281. taskMemberIds(owner:string,conversation:Conversation,taskId?:string):string[]{
  282. const base=conversation.memberIds.filter(id=>!this.get<Agent>(owner,'agent',id)?.temporaryGoalId)
  283. if(!taskId)return base
  284. const goal=this.get<import('../shared/agentTasks.js').AgentGoal>(owner,'goal',taskId)
  285. if(!goal||goal.conversationId!==conversation.id||['complete','blocked','cancelled','cancelling'].includes(goal.status))return base
  286. 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)])]
  287. }
  288. requireAgentTask(owner:string,agent:Agent,conversationId:string,taskId?:string):void {
  289. if(!agent.temporaryGoalId)return
  290. const conversation=this.require<Conversation>(owner,'conversation',conversationId)
  291. if(agent.temporaryGoalId!==taskId||!this.taskMemberIds(owner,conversation,taskId).includes(agent.id))throw new HttpError(403,'临时助手只能执行所属任务','helper_task_bound')
  292. }
  293. createAgent(owner: string, input: unknown, origin?: { createdByAgentId: string; createdFromRunId: string;temporaryGoalId?:string;helperActivation?:number;helperRunnerId?:string }): Agent {
  294. const body = parse(agentInput, input)
  295. if (origin) this.require<Agent>(owner, 'agent', origin.createdByAgentId)
  296. return this.atomic(() => {
  297. if (
  298. this.list<Agent>(owner, 'agent').some(
  299. (a) => a.name.toLocaleLowerCase() === body.name.toLocaleLowerCase(),
  300. )
  301. )
  302. throw new HttpError(409, 'Agent 名称已存在', 'duplicate_agent_name')
  303. const now = Date.now(),
  304. id = randomUUID()
  305. const agent: Agent = {
  306. ...body,
  307. ...origin,
  308. avatar: body.avatar ? normalizeAvatar(body.avatar) : encodeAgentAvatar(randomAgentIdentity(id, body.name)),
  309. id,
  310. archived: false,
  311. revision: 1,
  312. teamAuthorizationVersion: body.canManageTeam ? 1 : 0,
  313. createdAt: now,
  314. updatedAt: now,
  315. }
  316. this.prepareAgent?.(owner,agent)
  317. this.put(owner, 'agent', id, agent)
  318. if(origin?.temporaryGoalId){this.event(owner,'agent.changed',agent);return agent}
  319. const conversation: Conversation = {
  320. id: randomUUID(),
  321. kind: 'direct',
  322. name: agent.name,
  323. avatar: agent.avatar,
  324. memberIds: [id],
  325. instructions: '',
  326. administratorId: id,
  327. mode: 'host',
  328. autoReplyIds: [],
  329. maxReplyRounds: 1,
  330. archived: false,
  331. pinned: false,
  332. readSeq: 0,
  333. lastSeq: 0,
  334. preview: '',
  335. createdAt: now,
  336. updatedAt: now,
  337. }
  338. this.put(owner, 'conversation', conversation.id, conversation)
  339. this.event(owner, 'agent.changed', agent)
  340. this.event(owner, 'conversation.changed', conversation, conversation.id)
  341. return agent
  342. })
  343. }
  344. updateAgent(owner: string, id: string, input: unknown): Agent {
  345. const patch = parse(agentPatch, input)
  346. if (patch.avatar !== undefined) patch.avatar = normalizeAvatar(patch.avatar)
  347. return this.atomic(() => {
  348. const agent = this.require<Agent>(owner, 'agent', id)
  349. if(agent.temporaryGoalId&&(Object.keys(patch).some(key=>key!=='archived')||patch.archived===false))throw new HttpError(409,'临时助手由所属任务管理,不能修改权限或恢复为持久成员','helper_task_bound')
  350. if(agent.computerEnvironmentId&&patch.execution==='profile')throw new HttpError(409,'请先解除电脑共享,再切换执行环境','shared_computer_active')
  351. 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')
  352. if (agent.remoteAgentId && Object.keys(patch).some(key => key !== 'archived'))
  353. throw new HttpError(409, '引用 Agent 的配置由远端管理', 'remote_agent_read_only')
  354. if (
  355. patch.name &&
  356. this.list<Agent>(owner, 'agent').some(
  357. (a) => a.id !== id && a.name.toLocaleLowerCase() === patch.name!.toLocaleLowerCase(),
  358. )
  359. )
  360. throw new HttpError(409, 'Agent 名称已存在', 'duplicate_agent_name')
  361. const next = { ...agent, ...patch, revision: agent.revision + 1, updatedAt: Date.now() }
  362. if (patch.canManageTeam !== undefined && patch.canManageTeam !== (agent.canManageTeam === true))
  363. next.teamAuthorizationVersion = (agent.teamAuthorizationVersion ?? 0) + 1
  364. this.put(owner, 'agent', id, next)
  365. this.event(owner, 'agent.changed', next)
  366. for (const c of this.list<Conversation>(owner, 'conversation').filter(
  367. (c) => c.kind === 'direct' && c.memberIds[0] === id,
  368. )) {
  369. Object.assign(c, { name: next.name, avatar: next.avatar, archived: next.archived })
  370. this.put(owner, 'conversation', c.id, c)
  371. this.event(owner, 'conversation.changed', c, c.id)
  372. }
  373. return next
  374. })
  375. }
  376. deleteAgent(owner: string, id: string): void {
  377. this.atomic(() => {
  378. const agent = this.require<Agent>(owner, 'agent', id)
  379. if (!agent.archived) throw new HttpError(409, '请先归档 Bot,再删除', 'agent_not_archived')
  380. if (agent.temporaryGoalId) throw new HttpError(409, '临时助手由所属任务管理', 'helper_task_bound')
  381. if (agent.computerEnvironmentId) throw new HttpError(409, '请先解除电脑共享,再删除 Bot', 'shared_computer_active')
  382. const conversations = this.list<Conversation>(owner, 'conversation')
  383. if (conversations.some(c => c.kind === 'group' && c.memberIds.includes(id)))
  384. throw new HttpError(409, '此 Bot 仍是群聊成员,请先从群聊移除或删除相关群聊', 'agent_in_group')
  385. if (this.list<{ agentId: string; status: string }>(owner, 'turn').some(work => work.agentId === id && !['complete', 'failed', 'interrupted', 'skipped'].includes(work.status)))
  386. throw new HttpError(409, 'Bot 仍有任务未结束,请停止任务后重试', 'agent_running')
  387. for (const conversation of conversations.filter(c => c.kind === 'direct' && c.memberIds[0] === id))
  388. this.deleteConversation(owner, conversation.id, true)
  389. this.remove(owner, 'agent', id)
  390. this.event(owner, 'agent.deleted', { id })
  391. })
  392. }
  393. deleteConversation(owner: string, id: string, deletingAgent = false): void {
  394. this.atomic(() => {
  395. const conversation = this.require<Conversation>(owner, 'conversation', id)
  396. if (conversation.kind === 'direct' && !deletingAgent)
  397. throw new HttpError(400, '请删除对应的 Bot', 'delete_agent_instead')
  398. if (!conversation.archived) throw new HttpError(409, '请先归档聊天,再删除', 'conversation_not_archived')
  399. if (['run', 'turn'].some(kind => this.list<{ conversationId: string; status: string }>(owner, kind).some(run => run.conversationId === id && !['complete', 'failed', 'interrupted'].includes(run.status))))
  400. throw new HttpError(409, '聊天仍有任务未结束,请停止任务后重试', 'conversation_running')
  401. if (this.list<{ conversationId: string; origin?: { conversationId: string }; status: string }>(owner, 'goal').some(goal =>
  402. (goal.conversationId === id || goal.origin?.conversationId === id) && !['complete', 'blocked', 'cancelled'].includes(goal.status)))
  403. throw new HttpError(409, '聊天仍有关联任务未结束,请停止任务后重试', 'conversation_running')
  404. const taskIds = new Set(this.list<Task>(owner, 'conversation-task').filter(task => task.conversationId === id).map(task => task.id))
  405. for (const taskId of taskIds) {
  406. this.remove(owner, 'context', taskId)
  407. this.db.prepare("DELETE FROM workspace_entities WHERE owner=? AND kind='task-delivery' AND json_extract(data,'$.goalId')=?").run(owner, taskId)
  408. }
  409. // Keep files in the file library, matching task deletion; detach their chat references.
  410. for (const file of this.list<{ id: string; conversationId?: string; conversationTaskId?: string }>(owner, 'file')) {
  411. if (file.conversationId === id || (file.conversationTaskId && taskIds.has(file.conversationTaskId)))
  412. this.put(owner, 'file', file.id, { ...file, conversationId: undefined, conversationTaskId: undefined, messageId: undefined })
  413. }
  414. const bindingPrefix = `${id}:`
  415. 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)
  416. 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)
  417. this.db.prepare("DELETE FROM workspace_commands WHERE owner=? AND json_extract(result,'$.conversationId')=?").run(owner, id)
  418. this.remove(owner, 'context', id)
  419. this.remove(owner, 'conversation', id)
  420. this.event(owner, 'conversation.deleted', { id }, id)
  421. })
  422. }
  423. createGroup(owner: string, input: unknown): Conversation {
  424. const body = parse(groupInput, input)
  425. const ids = new Set(body.memberIds)
  426. if (
  427. ids.size !== body.memberIds.length ||
  428. !ids.has(body.administratorId) ||
  429. body.autoReplyIds.some((id) => !ids.has(id)) ||
  430. Object.keys(body.memberRoles).some(id => !ids.has(id))
  431. )
  432. throw new HttpError(400, '群成员或管理员无效', 'invalid_members')
  433. for (const id of ids){
  434. const agent=this.require<Agent>(owner,'agent',id)
  435. if(agent.temporaryGoalId)throw new HttpError(409,'临时助手不能加入持久团队','helper_task_bound')
  436. if(agent.archived)throw new HttpError(409,'已归档 Agent 不能加入新群','agent_archived')
  437. }
  438. const now = Date.now()
  439. const c: Conversation = {
  440. ...body,
  441. id: randomUUID(),
  442. kind: 'group',
  443. archived: false,
  444. pinned: false,
  445. readSeq: 0,
  446. lastSeq: 0,
  447. preview: '',
  448. createdAt: now,
  449. updatedAt: now,
  450. }
  451. this.atomic(() => {
  452. this.put(owner, 'conversation', c.id, c)
  453. this.event(owner, 'conversation.changed', c, c.id)
  454. this.createTask(owner, c.id, {})
  455. })
  456. return c
  457. }
  458. private backfillConversationTask(owner: string, conversation: Conversation): void {
  459. const legacyMessages = this.list<Message>(owner, 'message')
  460. .filter(message => message.conversationId === conversation.id && !message.conversationTaskId)
  461. .sort((a, b) => a.seq - b.seq || a.createdAt - b.createdAt || a.id.localeCompare(b.id))
  462. if (!legacyMessages.length) return
  463. const existingTasks = this.tasks(owner, conversation.id)
  464. const assignedTaskIds = new Set(
  465. this.list<Message>(owner, 'message')
  466. .filter(message => message.conversationId === conversation.id && message.conversationTaskId)
  467. .map(message => message.conversationTaskId!),
  468. )
  469. const reusable = existingTasks.length === 1
  470. && existingTasks[0]!.titleSource === 'automatic'
  471. && existingTasks[0]!.title === '新任务'
  472. && existingTasks[0]!.messageCount === 0
  473. && !assignedTaskIds.has(existingTasks[0]!.id)
  474. ? existingTasks[0]
  475. : undefined
  476. const visibleMessages = legacyMessages.filter(message => message.visible !== false)
  477. const meaningfulMessages = visibleMessages.filter(
  478. message => message.content.trim() || message.attachments.length,
  479. )
  480. const firstMessage = legacyMessages[0]!,
  481. lastMessage = meaningfulMessages.at(-1) ?? visibleMessages.at(-1) ?? legacyMessages.at(-1)!,
  482. firstUserMessage = visibleMessages.find(
  483. message => message.role === 'user' && (message.content.trim() || message.attachments.length),
  484. ),
  485. title = notificationPlainText(firstUserMessage?.content ?? '', {
  486. maximum: 48,
  487. fallback: firstUserMessage?.attachments[0]?.name ?? '',
  488. }).trim()
  489. || notificationPlainText(conversation.preview, { maximum: 48, fallback: '' }).trim()
  490. || '历史任务',
  491. lastSeq = legacyMessages.reduce((value, message) => Math.max(value, message.seq), 0),
  492. readSeq = Math.min(conversation.readSeq, lastSeq),
  493. task: Task = {
  494. ...(reusable ?? {
  495. id: randomUUID(),
  496. conversationId: conversation.id,
  497. title: '历史任务',
  498. titleSource: 'automatic' as const,
  499. messageCount: 0,
  500. readSeq: 0,
  501. lastSeq: 0,
  502. unreadCount: 0,
  503. createdAt: firstMessage.createdAt,
  504. updatedAt: lastMessage.createdAt,
  505. }),
  506. title,
  507. titleSource: 'automatic',
  508. messageCount: visibleMessages.length,
  509. readSeq,
  510. lastSeq,
  511. unreadCount: visibleMessages.filter(message => message.seq > readSeq).length,
  512. lastMessageAt: lastMessage.createdAt,
  513. createdAt: firstMessage.createdAt,
  514. updatedAt: lastMessage.createdAt,
  515. }
  516. for (const message of legacyMessages) {
  517. this.put(owner, 'message', message.id, {
  518. ...message,
  519. conversationTaskId: task.id,
  520. })
  521. }
  522. const migratedInteractions = new Map<string, string>()
  523. for (const kind of ['run', 'turn', 'interaction'] as const) {
  524. for (const entity of this.list<Record<string, unknown> & {
  525. id: string
  526. conversationId?: string
  527. conversationTaskId?: string
  528. agentId?: string
  529. }>(owner, kind)) {
  530. if (entity.conversationId !== conversation.id || entity.conversationTaskId) continue
  531. this.put(owner, kind, entity.id, { ...entity, conversationTaskId: task.id })
  532. if (kind === 'interaction' && entity.agentId) {
  533. migratedInteractions.set(entity.id, entity.agentId)
  534. }
  535. }
  536. }
  537. for (const [interactionId, agentId] of migratedInteractions) {
  538. const binding = this.get<Record<string, unknown> & {
  539. conversationTaskId?: string
  540. }>(owner, 'interaction-binding', interactionId)
  541. if (!binding || binding.conversationTaskId) continue
  542. this.put(owner, 'interaction-binding', interactionId, {
  543. ...binding,
  544. key: `${conversation.id}:${task.id}:${agentId}`,
  545. conversationTaskId: task.id,
  546. })
  547. }
  548. for (const agentId of conversation.memberIds) {
  549. const legacyKey = `${conversation.id}:${agentId}`,
  550. taskKey = `${conversation.id}:${task.id}:${agentId}`,
  551. binding = this.get<Record<string, unknown> & {
  552. conversationTaskId?: string
  553. }>(owner, 'binding', legacyKey)
  554. if (!binding || binding.conversationTaskId) continue
  555. this.put(owner, 'binding', taskKey, { ...binding, conversationTaskId: task.id })
  556. this.remove(owner, 'binding', legacyKey)
  557. }
  558. const context = this.get<Record<string, unknown> & {
  559. conversationTaskId?: string
  560. }>(owner, 'context', conversation.id)
  561. if (context && !context.conversationTaskId) {
  562. this.put(owner, 'context', task.id, { ...context, conversationTaskId: task.id })
  563. this.remove(owner, 'context', conversation.id)
  564. }
  565. this.db.prepare(
  566. `UPDATE workspace_commands
  567. SET result=json_set(result,'$.conversationTaskId',?)
  568. WHERE owner=? AND json_extract(result,'$.conversationId')=?
  569. AND json_extract(result,'$.conversationTaskId') IS NULL`,
  570. ).run(task.id, owner, conversation.id)
  571. const activeRuns = this.list<Run>(owner, 'run')
  572. .filter(run => run.conversationTaskId === task.id && !['complete', 'failed', 'interrupted'].includes(run.status))
  573. .sort((a, b) => a.createdAt - b.createdAt || a.id.localeCompare(b.id))
  574. task.activeRunId = activeRuns[0]?.id
  575. task.activeRunStatus = activeRuns[0]?.status
  576. this.put(owner, 'conversation-task', task.id, task)
  577. }
  578. tasks(owner: string, conversationId: string): Task[] {
  579. const conversation = this.require<Conversation>(owner, 'conversation', conversationId)
  580. if (conversation.kind !== 'group')
  581. throw new HttpError(400, '单聊不支持任务', 'conversation_tasks_unsupported')
  582. return this.list<Task>(owner, 'conversation-task')
  583. .filter((task) => task.conversationId === conversationId)
  584. .sort((a, b) => b.updatedAt - a.updatedAt || b.createdAt - a.createdAt || a.id.localeCompare(b.id))
  585. }
  586. createTask(owner: string, conversationId: string, input: unknown): Task {
  587. const body = parse(taskInput, input),
  588. conversation = this.require<Conversation>(owner, 'conversation', conversationId)
  589. if (conversation.kind !== 'group')
  590. throw new HttpError(400, '单聊不支持任务', 'conversation_tasks_unsupported')
  591. const now = Date.now(),
  592. explicitTitle = body.title?.trim(),
  593. task: Task = {
  594. id: randomUUID(),
  595. conversationId,
  596. title: explicitTitle || '新任务',
  597. titleSource: explicitTitle ? 'user' : 'automatic',
  598. messageCount: 0,
  599. readSeq: 0,
  600. lastSeq: 0,
  601. unreadCount: 0,
  602. createdAt: now,
  603. updatedAt: now,
  604. }
  605. this.atomic(() => {
  606. this.put(owner, 'conversation-task', task.id, task)
  607. this.event(owner, 'task.changed', task, conversationId)
  608. })
  609. return task
  610. }
  611. requireTask(owner: string, conversationId: string, taskId: string): Task {
  612. const task = this.require<Task>(owner, 'conversation-task', taskId)
  613. if (task.conversationId !== conversationId)
  614. throw new HttpError(404, '任务不存在', 'workspace_task_not_found')
  615. return task
  616. }
  617. resolveTask(owner: string, conversationId: string, taskId?: string): Task | undefined {
  618. const conversation = this.require<Conversation>(owner, 'conversation', conversationId)
  619. if (conversation.kind === 'direct') {
  620. if (taskId) throw new HttpError(400, '单聊不支持任务', 'conversation_tasks_unsupported')
  621. return undefined
  622. }
  623. if (taskId) return this.requireTask(owner, conversationId, taskId)
  624. return this.tasks(owner, conversationId)[0] ?? this.createTask(owner, conversationId, {})
  625. }
  626. updateTask(owner: string, conversationId: string, taskId: string, input: unknown): Task {
  627. const patch = parse(taskPatch, input)
  628. return this.atomic(() => {
  629. const task = this.requireTask(owner, conversationId, taskId),
  630. next: Task = { ...task, title: patch.title, titleSource: 'user', updatedAt: Date.now() }
  631. this.put(owner, 'conversation-task', task.id, next)
  632. this.event(owner, 'task.changed', next, conversationId)
  633. return next
  634. })
  635. }
  636. markTaskRead(owner: string, conversationId: string, taskId: string, seq: number): Task {
  637. return this.atomic(() => {
  638. const task = this.requireTask(owner, conversationId, taskId)
  639. task.readSeq = Math.max(task.readSeq, Math.min(seq, task.lastSeq))
  640. task.unreadCount = this.messages(owner, conversationId, Number.MAX_SAFE_INTEGER, Number.MAX_SAFE_INTEGER, false, task.id)
  641. .filter(message => message.seq > task.readSeq).length
  642. this.put(owner, 'conversation-task', task.id, task)
  643. this.event(owner, 'task.changed', task, conversationId)
  644. return task
  645. })
  646. }
  647. deleteTask(owner: string, conversationId: string, taskId: string): void {
  648. this.atomic(() => {
  649. const task = this.requireTask(owner, conversationId, taskId)
  650. if (this.list<Run>(owner, 'run').some(run => run.conversationTaskId === taskId && !['complete', 'failed', 'interrupted'].includes(run.status)))
  651. throw new HttpError(409, '任务仍在运行,暂时无法移除', 'workspace_task_running')
  652. const messageIds = new Set(this.list<Message>(owner, 'message').filter(message => message.conversationTaskId === taskId).map(message => message.id))
  653. 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)
  654. this.remove(owner, 'context', taskId)
  655. this.remove(owner, 'conversation-task', taskId)
  656. this.db.prepare("DELETE FROM workspace_commands WHERE owner=? AND json_extract(result,'$.conversationTaskId')=?").run(owner, taskId)
  657. for (const file of this.list<Record<string, unknown> & { id: string; messageId?: string }>(owner, 'file')) {
  658. if (!file.messageId || !messageIds.has(file.messageId)) continue
  659. this.put(owner, 'file', String(file.id), { ...file, conversationId: undefined, messageId: undefined })
  660. }
  661. this.event(owner, 'task.deleted', { id: taskId, conversationId }, conversationId)
  662. const remaining = this.tasks(owner, conversationId)
  663. if (!remaining.length) this.createTask(owner, conversationId, {})
  664. const conversation = this.require<Conversation>(owner, 'conversation', conversationId),
  665. history = this.messages(owner, conversationId, Number.MAX_SAFE_INTEGER, Number.MAX_SAFE_INTEGER)
  666. const last = history.at(-1)
  667. conversation.preview = last ? notificationPlainText(last.content, { maximum: 160, fallback: '' }) : ''
  668. conversation.previewAgentId = last?.role === 'assistant' ? last.agentId : undefined
  669. conversation.lastMessageAt = last?.createdAt
  670. conversation.updatedAt = Date.now()
  671. this.projectConversationRuns(owner, conversation)
  672. this.put(owner, 'conversation', conversationId, conversation)
  673. this.event(owner, 'conversation.changed', conversation, conversationId)
  674. })
  675. }
  676. updateConversation(owner: string, id: string, input: unknown): Conversation {
  677. const patch = parse(conversationPatch, input),
  678. c = this.require<Conversation>(owner, 'conversation', id)
  679. if (c.kind === 'direct' && Object.keys(patch).some((k) => k !== 'pinned'))
  680. throw new HttpError(400, '请编辑 Agent 资料', 'edit_agent_instead')
  681. const memberIds = patch.memberIds ?? c.memberIds
  682. const members = new Set(memberIds)
  683. if (!members.has(c.administratorId))
  684. throw new HttpError(400, '当前管理员不能移除,请先更换管理员并保存', 'administrator_required')
  685. if (
  686. members.size !== memberIds.length ||
  687. !members.has(patch.administratorId ?? c.administratorId) ||
  688. patch.autoReplyIds?.some((a) => !members.has(a) && !c.memberIds.includes(a)) ||
  689. Object.keys(patch.memberRoles ?? {}).some(a => !members.has(a) && !c.memberIds.includes(a))
  690. )
  691. throw new HttpError(400, '群成员或管理员无效', 'invalid_members')
  692. for (const memberId of memberIds) {
  693. const agent = this.require<Agent>(owner, 'agent', memberId)
  694. if(agent.temporaryGoalId)throw new HttpError(409,'临时助手不能加入其他群聊','helper_task_bound')
  695. if (!c.memberIds.includes(memberId) && agent.archived)
  696. throw new HttpError(409, '已归档 Agent 不能加入群聊', 'agent_archived')
  697. }
  698. const next = {
  699. ...c, ...patch, memberIds,
  700. autoReplyIds: (patch.autoReplyIds ?? c.autoReplyIds).filter(a => members.has(a)),
  701. memberRoles: Object.fromEntries(Object.entries(patch.memberRoles ?? c.memberRoles ?? {}).filter(([id]) => members.has(id))),
  702. updatedAt: Date.now(),
  703. }
  704. this.atomic(() => {
  705. this.put(owner, 'conversation', id, next)
  706. this.event(owner, 'conversation.changed', next, id)
  707. })
  708. return next
  709. }
  710. messages(
  711. owner: string,
  712. conversationId: string,
  713. before = Number.MAX_SAFE_INTEGER,
  714. limit = 100,
  715. includeHidden = false,
  716. conversationTaskId?: string,
  717. ): Message[] {
  718. this.require(owner, 'conversation', conversationId)
  719. if (conversationTaskId) this.requireTask(owner, conversationId, conversationTaskId)
  720. const taskClause = conversationTaskId ? "AND json_extract(data,'$.conversationTaskId')=?" : ''
  721. return this.db
  722. .prepare(
  723. `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 ?`,
  724. )
  725. .all(...(conversationTaskId ? [owner, conversationId, conversationTaskId, before, limit] : [owner, conversationId, before, limit]))
  726. .map((r) => this.messageForDisplay(owner, JSON.parse(String(r.data)) as Message))
  727. .reverse()
  728. }
  729. nativeMessageForFile?: (owner: string, file: StoredWorkspaceFile) => Message | undefined
  730. visibleFiles(owner: string): StoredWorkspaceFile[] {
  731. const messages = new Map(this.list<Message>(owner, 'message').map(message => [message.id, message]))
  732. return this.list<StoredWorkspaceFile>(owner, 'file').filter(file =>
  733. isVisibleMessageFile(file, messages.get(file.messageId ?? '') ?? this.nativeMessageForFile?.(owner, file)))
  734. }
  735. messageForDisplay(owner: string, message: Message): Message {
  736. const attachments = message.role === 'user' || message.role === 'assistant'
  737. ? message.attachments.filter(file => isVisibleMessageFile(
  738. this.get<StoredWorkspaceFile>(owner, 'file', file.id) ?? file, message))
  739. : []
  740. return { ...message, attachments }
  741. }
  742. private eventDataForDisplay(owner: string, type: string, data: unknown): unknown {
  743. if (type === 'message.changed') return this.messageForDisplay(owner, data as Message)
  744. return data
  745. }
  746. saveMessage(owner: string, message: Message): void {
  747. this.atomic(() => {
  748. const c = this.require<Conversation>(owner, 'conversation', message.conversationId)
  749. const previous = this.get<Message>(owner, 'message', message.id)
  750. if (previous && previous.conversationTaskId !== message.conversationTaskId)
  751. throw new HttpError(409, '消息不能移入其他任务', 'message_task_mismatch')
  752. const task = c.kind === 'group'
  753. ? message.conversationTaskId
  754. ? this.requireTask(owner, c.id, message.conversationTaskId)
  755. : (() => { throw new HttpError(400, '群聊消息必须指定任务', 'workspace_task_required') })()
  756. : undefined
  757. if (previous?.visible === false && message.visible !== false) message.seq = 0
  758. if (!message.seq) message.seq = c.lastSeq + 1
  759. c.lastSeq = Math.max(c.lastSeq, message.seq)
  760. if (message.visible !== false && (message.content.trim() || message.attachments.length)) {
  761. c.updatedAt = Date.now()
  762. c.lastMessageAt = Math.max(c.lastMessageAt ?? 0, message.createdAt)
  763. c.preview = notificationPlainText(message.content, { maximum: 160, fallback: '' })
  764. c.previewAgentId = message.role === 'assistant' ? message.agentId : undefined
  765. }
  766. this.put(owner, 'message', message.id, message)
  767. this.put(owner, 'conversation', c.id, c)
  768. if (task) {
  769. const becameVisible = message.visible !== false && (!previous || previous.visible === false),
  770. becameHidden = Boolean(previous && previous.visible !== false && message.visible === false)
  771. if (becameVisible) task.messageCount += 1
  772. if (becameHidden) task.messageCount = Math.max(0, task.messageCount - 1)
  773. task.lastSeq = Math.max(task.lastSeq, message.seq)
  774. if (becameVisible && message.seq > task.readSeq) task.unreadCount += 1
  775. if (becameHidden && message.seq > task.readSeq) task.unreadCount = Math.max(0, task.unreadCount - 1)
  776. if (message.visible !== false && (message.content.trim() || message.attachments.length)) {
  777. if (message.role === 'user' && task.titleSource === 'automatic' && task.messageCount <= 1) {
  778. const source = notificationPlainText(message.content, { maximum: 48, fallback: message.attachments[0]?.name ?? '' }).trim()
  779. if (source) task.title = source
  780. }
  781. task.lastMessageAt = Math.max(task.lastMessageAt ?? 0, message.createdAt)
  782. task.updatedAt = Date.now()
  783. }
  784. this.put(owner, 'conversation-task', task.id, task)
  785. this.event(owner, 'task.changed', task, c.id)
  786. }
  787. this.event(owner, 'message.changed', message, c.id)
  788. this.event(owner, 'conversation.changed', c, c.id)
  789. })
  790. }
  791. agentSummary(agent: Agent): Agent {
  792. return { ...agent, avatar: agent.avatar || encodeAgentAvatar(defaultAgentIdentity(agent.id, agent.name)) }
  793. }
  794. conversationSummary(owner: string, conversation: Conversation): Conversation {
  795. const member = conversation.kind === 'direct' ? this.get<Agent>(owner, 'agent', conversation.memberIds[0] ?? '') : undefined,
  796. unreadCount = conversation.kind === 'group'
  797. ? this.tasks(owner, conversation.id).reduce((count, task) => count + task.unreadCount, 0)
  798. : this.messages(owner, conversation.id, Number.MAX_SAFE_INTEGER, Number.MAX_SAFE_INTEGER).filter(message => message.seq > conversation.readSeq).length
  799. return {
  800. ...conversation,
  801. unreadCount,
  802. ...(member ? { avatar: this.agentSummary(member).avatar } : {}),
  803. lastMessageAt: conversation.lastMessageAt
  804. ?? (conversation.lastSeq > 0 ? this.messages(owner, conversation.id, Number.MAX_SAFE_INTEGER, 1).at(-1)?.createdAt : undefined)
  805. ?? conversation.createdAt,
  806. }
  807. }
  808. hiddenMessageIds(owner: string, conversationId: string, conversationTaskId?: string): string[] {
  809. return this.messages(owner, conversationId, Number.MAX_SAFE_INTEGER, Number.MAX_SAFE_INTEGER, true, conversationTaskId).filter(m => m.visible === false).map(m => m.id)
  810. }
  811. saveRun(owner: string, run: Run): void {
  812. this.atomic(() => {
  813. const c = this.require<Conversation>(owner, 'conversation', run.conversationId)
  814. run.updatedAt = Date.now()
  815. this.put(owner, 'run', run.id, run)
  816. if (run.conversationTaskId) {
  817. const task = this.requireTask(owner, c.id, run.conversationTaskId),
  818. activeRuns = this.list<Run>(owner, 'run')
  819. .filter(root => root.conversationTaskId === task.id && !['complete', 'failed', 'interrupted'].includes(root.status))
  820. .sort((a, b) => a.createdAt - b.createdAt || a.id.localeCompare(b.id)),
  821. active = activeRuns[0]
  822. task.activeRunId = active?.id
  823. task.activeRunStatus = active?.status
  824. this.put(owner, 'conversation-task', task.id, task)
  825. this.event(owner, 'task.changed', task, c.id)
  826. }
  827. this.projectConversationRuns(owner, c)
  828. this.put(owner, 'conversation', c.id, c)
  829. this.event(owner, 'run.changed', run, c.id)
  830. this.event(owner, 'conversation.changed', c, c.id)
  831. })
  832. }
  833. activeTaskCount(owner: string, conversationId: string): number {
  834. return new Set(
  835. this.list<Run>(owner, 'run')
  836. .filter(run => run.conversationId === conversationId && run.conversationTaskId && !['complete', 'failed', 'interrupted'].includes(run.status))
  837. .map(run => run.conversationTaskId!),
  838. ).size
  839. }
  840. private projectConversationRuns(owner: string, conversation: Conversation): void {
  841. const roots = this.list<Run>(owner, 'run').filter(run => run.conversationId === conversation.id && !['complete', 'failed', 'interrupted'].includes(run.status))
  842. .sort((a, b) => this.require<Message>(owner, 'message', a.messageId).seq - this.require<Message>(owner, 'message', b.messageId).seq),
  843. current = roots[0]
  844. conversation.activeRunId = current?.id
  845. conversation.activeAgentId = current?.activeAgentId
  846. conversation.activeRunStatus = current?.status
  847. 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)
  848. .map(row => JSON.parse(String(row.data)) as { agentId: string; status: string })
  849. conversation.activeAgentStates = Object.fromEntries(turns.map(turn => [turn.agentId, turn.status as 'running' | 'waiting' | 'uncertain' | 'queued']))
  850. 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)
  851. .map(row => JSON.parse(String(row.data)) as {id:string;agentId:string;status:string;updatedAt:number})
  852. 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 }]))
  853. conversation.queuedMessageCount = roots.filter(run => run.status === 'queued').length
  854. }
  855. ownsUpstream(id: string): boolean {
  856. return Boolean(
  857. this.db
  858. .prepare(
  859. `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`,
  860. )
  861. .get(id, id, id),
  862. )
  863. }
  864. close(): void {
  865. this.changes.removeAllListeners()
  866. this.db.close()
  867. }
  868. }