workspaceStore.ts 40 KB

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