workspaceStore.ts 37 KB

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