|
|
@@ -10,6 +10,7 @@ import { decodeAgentMascotAvatar, isAgentImageAvatar, defaultAgentIdentity, enco
|
|
|
import type {
|
|
|
WorkspaceAgent as Agent,
|
|
|
WorkspaceConversation as Conversation,
|
|
|
+ WorkspaceTask as Task,
|
|
|
WorkspaceMessage as Message,
|
|
|
WorkspaceRun as Run,
|
|
|
WorkspaceEvent,
|
|
|
@@ -28,6 +29,7 @@ const avatar = z
|
|
|
(v) => !v || isAgentImageAvatar(v) || !!decodeAgentMascotAvatar(v) || /^builtin:team-animal:(fox|whale|owl|rabbit|bear)$/.test(v),
|
|
|
'请选择有效的内置头像或 PNG、JPEG、WebP 图片',
|
|
|
)
|
|
|
+const taskTitle = z.string().trim().min(1).max(100).refine((v) => !/[\u0000-\u001f]/.test(v), '标题不能包含控制字符')
|
|
|
export const agentInput = z
|
|
|
.object({
|
|
|
name,
|
|
|
@@ -77,6 +79,12 @@ export const conversationPatch = z
|
|
|
pinned: z.boolean().optional(),
|
|
|
})
|
|
|
.strict()
|
|
|
+export const taskInput = z
|
|
|
+ .object({ title: z.string().trim().max(100).refine((v) => !/[\u0000-\u001f]/.test(v), '标题不能包含控制字符').optional() })
|
|
|
+ .strict()
|
|
|
+export const taskPatch = z
|
|
|
+ .object({ title: taskTitle })
|
|
|
+ .strict()
|
|
|
export function parse<T>(schema: z.ZodType<T>, value: unknown): T {
|
|
|
const parsed = schema.safeParse(value)
|
|
|
if (!parsed.success)
|
|
|
@@ -103,7 +111,10 @@ export class WorkspaceStore {
|
|
|
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));`)
|
|
|
this.db.exec(`CREATE INDEX IF NOT EXISTS workspace_turn_run ON workspace_entities(owner, json_extract(data,'$.runId')) WHERE kind='turn';
|
|
|
CREATE INDEX IF NOT EXISTS workspace_turn_status ON workspace_entities(owner, json_extract(data,'$.status'), json_extract(data,'$.planned')) WHERE kind='turn';
|
|
|
- CREATE INDEX IF NOT EXISTS workspace_turn_conversation ON workspace_entities(owner, json_extract(data,'$.conversationId')) WHERE kind='turn';`)
|
|
|
+ CREATE INDEX IF NOT EXISTS workspace_turn_conversation ON workspace_entities(owner, json_extract(data,'$.conversationId')) WHERE kind='turn';
|
|
|
+ CREATE INDEX IF NOT EXISTS workspace_message_task ON workspace_entities(owner, json_extract(data,'$.conversationTaskId'), json_extract(data,'$.seq')) WHERE kind='message';
|
|
|
+ CREATE INDEX IF NOT EXISTS workspace_run_task ON workspace_entities(owner, json_extract(data,'$.conversationTaskId'), json_extract(data,'$.status')) WHERE kind='run';
|
|
|
+ CREATE INDEX IF NOT EXISTS workspace_turn_task ON workspace_entities(owner, json_extract(data,'$.conversationTaskId'), json_extract(data,'$.status')) WHERE kind='turn';`)
|
|
|
this.changes.setMaxListeners(0)
|
|
|
this.atomic(() => {
|
|
|
this.db.exec('CREATE TABLE IF NOT EXISTS workspace_migrations(id TEXT PRIMARY KEY)')
|
|
|
@@ -340,9 +351,108 @@ export class WorkspaceStore {
|
|
|
this.atomic(() => {
|
|
|
this.put(owner, 'conversation', c.id, c)
|
|
|
this.event(owner, 'conversation.changed', c, c.id)
|
|
|
+ this.createTask(owner, c.id, {})
|
|
|
})
|
|
|
return c
|
|
|
}
|
|
|
+ tasks(owner: string, conversationId: string): Task[] {
|
|
|
+ const conversation = this.require<Conversation>(owner, 'conversation', conversationId)
|
|
|
+ if (conversation.kind !== 'group')
|
|
|
+ throw new HttpError(400, '单聊不支持任务', 'conversation_tasks_unsupported')
|
|
|
+ return this.list<Task>(owner, 'conversation-task')
|
|
|
+ .filter((task) => task.conversationId === conversationId)
|
|
|
+ .sort((a, b) => b.updatedAt - a.updatedAt || b.createdAt - a.createdAt || a.id.localeCompare(b.id))
|
|
|
+ }
|
|
|
+ createTask(owner: string, conversationId: string, input: unknown): Task {
|
|
|
+ const body = parse(taskInput, input),
|
|
|
+ conversation = this.require<Conversation>(owner, 'conversation', conversationId)
|
|
|
+ if (conversation.kind !== 'group')
|
|
|
+ throw new HttpError(400, '单聊不支持任务', 'conversation_tasks_unsupported')
|
|
|
+ const now = Date.now(),
|
|
|
+ explicitTitle = body.title?.trim(),
|
|
|
+ task: Task = {
|
|
|
+ id: randomUUID(),
|
|
|
+ conversationId,
|
|
|
+ title: explicitTitle || '新任务',
|
|
|
+ titleSource: explicitTitle ? 'user' : 'automatic',
|
|
|
+ messageCount: 0,
|
|
|
+ readSeq: 0,
|
|
|
+ lastSeq: 0,
|
|
|
+ unreadCount: 0,
|
|
|
+ createdAt: now,
|
|
|
+ updatedAt: now,
|
|
|
+ }
|
|
|
+ this.atomic(() => {
|
|
|
+ this.put(owner, 'conversation-task', task.id, task)
|
|
|
+ this.event(owner, 'task.changed', task, conversationId)
|
|
|
+ })
|
|
|
+ return task
|
|
|
+ }
|
|
|
+ requireTask(owner: string, conversationId: string, taskId: string): Task {
|
|
|
+ const task = this.require<Task>(owner, 'conversation-task', taskId)
|
|
|
+ if (task.conversationId !== conversationId)
|
|
|
+ throw new HttpError(404, '任务不存在', 'workspace_task_not_found')
|
|
|
+ return task
|
|
|
+ }
|
|
|
+ resolveTask(owner: string, conversationId: string, taskId?: string): Task | undefined {
|
|
|
+ const conversation = this.require<Conversation>(owner, 'conversation', conversationId)
|
|
|
+ if (conversation.kind === 'direct') {
|
|
|
+ if (taskId) throw new HttpError(400, '单聊不支持任务', 'conversation_tasks_unsupported')
|
|
|
+ return undefined
|
|
|
+ }
|
|
|
+ if (taskId) return this.requireTask(owner, conversationId, taskId)
|
|
|
+ return this.tasks(owner, conversationId)[0] ?? this.createTask(owner, conversationId, {})
|
|
|
+ }
|
|
|
+ updateTask(owner: string, conversationId: string, taskId: string, input: unknown): Task {
|
|
|
+ const patch = parse(taskPatch, input)
|
|
|
+ return this.atomic(() => {
|
|
|
+ const task = this.requireTask(owner, conversationId, taskId),
|
|
|
+ next: Task = { ...task, title: patch.title, titleSource: 'user', updatedAt: Date.now() }
|
|
|
+ this.put(owner, 'conversation-task', task.id, next)
|
|
|
+ this.event(owner, 'task.changed', next, conversationId)
|
|
|
+ return next
|
|
|
+ })
|
|
|
+ }
|
|
|
+ markTaskRead(owner: string, conversationId: string, taskId: string, seq: number): Task {
|
|
|
+ return this.atomic(() => {
|
|
|
+ const task = this.requireTask(owner, conversationId, taskId)
|
|
|
+ task.readSeq = Math.max(task.readSeq, Math.min(seq, task.lastSeq))
|
|
|
+ task.unreadCount = this.messages(owner, conversationId, Number.MAX_SAFE_INTEGER, Number.MAX_SAFE_INTEGER, false, task.id)
|
|
|
+ .filter(message => message.seq > task.readSeq).length
|
|
|
+ this.put(owner, 'conversation-task', task.id, task)
|
|
|
+ this.event(owner, 'task.changed', task, conversationId)
|
|
|
+ return task
|
|
|
+ })
|
|
|
+ }
|
|
|
+ deleteTask(owner: string, conversationId: string, taskId: string): void {
|
|
|
+ this.atomic(() => {
|
|
|
+ const task = this.requireTask(owner, conversationId, taskId)
|
|
|
+ if (this.list<Run>(owner, 'run').some(run => run.conversationTaskId === taskId && !['complete', 'failed', 'interrupted'].includes(run.status)))
|
|
|
+ throw new HttpError(409, '任务仍在运行,暂时无法移除', 'workspace_task_running')
|
|
|
+ const messageIds = new Set(this.list<Message>(owner, 'message').filter(message => message.conversationTaskId === taskId).map(message => message.id))
|
|
|
+ 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)
|
|
|
+ this.remove(owner, 'context', taskId)
|
|
|
+ this.remove(owner, 'conversation-task', taskId)
|
|
|
+ this.db.prepare("DELETE FROM workspace_commands WHERE owner=? AND json_extract(result,'$.conversationTaskId')=?").run(owner, taskId)
|
|
|
+ for (const file of this.list<Record<string, unknown> & { id: string; messageId?: string }>(owner, 'file')) {
|
|
|
+ if (!file.messageId || !messageIds.has(file.messageId)) continue
|
|
|
+ this.put(owner, 'file', String(file.id), { ...file, conversationId: undefined, messageId: undefined })
|
|
|
+ }
|
|
|
+ this.event(owner, 'task.deleted', { id: taskId, conversationId }, conversationId)
|
|
|
+ const remaining = this.tasks(owner, conversationId)
|
|
|
+ if (!remaining.length) this.createTask(owner, conversationId, {})
|
|
|
+ const conversation = this.require<Conversation>(owner, 'conversation', conversationId),
|
|
|
+ history = this.messages(owner, conversationId, Number.MAX_SAFE_INTEGER, Number.MAX_SAFE_INTEGER)
|
|
|
+ const last = history.at(-1)
|
|
|
+ conversation.preview = last ? notificationPlainText(last.content, { maximum: 160, fallback: '' }) : ''
|
|
|
+ conversation.previewAgentId = last?.role === 'assistant' ? last.agentId : undefined
|
|
|
+ conversation.lastMessageAt = last?.createdAt
|
|
|
+ conversation.updatedAt = Date.now()
|
|
|
+ this.projectConversationRuns(owner, conversation)
|
|
|
+ this.put(owner, 'conversation', conversationId, conversation)
|
|
|
+ this.event(owner, 'conversation.changed', conversation, conversationId)
|
|
|
+ })
|
|
|
+ }
|
|
|
updateConversation(owner: string, id: string, input: unknown): Conversation {
|
|
|
const patch = parse(conversationPatch, input),
|
|
|
c = this.require<Conversation>(owner, 'conversation', id)
|
|
|
@@ -382,13 +492,16 @@ export class WorkspaceStore {
|
|
|
before = Number.MAX_SAFE_INTEGER,
|
|
|
limit = 100,
|
|
|
includeHidden = false,
|
|
|
+ conversationTaskId?: string,
|
|
|
): Message[] {
|
|
|
this.require(owner, 'conversation', conversationId)
|
|
|
+ if (conversationTaskId) this.requireTask(owner, conversationId, conversationTaskId)
|
|
|
+ const taskClause = conversationTaskId ? "AND json_extract(data,'$.conversationTaskId')=?" : ''
|
|
|
return this.db
|
|
|
.prepare(
|
|
|
- `SELECT data FROM workspace_entities WHERE owner=? AND kind='message' AND json_extract(data,'$.conversationId')=? AND json_extract(data,'$.seq')<? ${includeHidden ? '' : "AND coalesce(json_extract(data,'$.visible'),1) != 0"} ORDER BY json_extract(data,'$.seq') DESC LIMIT ?`,
|
|
|
+ `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 ?`,
|
|
|
)
|
|
|
- .all(owner, conversationId, before, limit)
|
|
|
+ .all(...(conversationTaskId ? [owner, conversationId, conversationTaskId, before, limit] : [owner, conversationId, before, limit]))
|
|
|
.map((r) => JSON.parse(String(r.data)) as Message)
|
|
|
.reverse()
|
|
|
}
|
|
|
@@ -396,6 +509,13 @@ export class WorkspaceStore {
|
|
|
this.atomic(() => {
|
|
|
const c = this.require<Conversation>(owner, 'conversation', message.conversationId)
|
|
|
const previous = this.get<Message>(owner, 'message', message.id)
|
|
|
+ if (previous && previous.conversationTaskId !== message.conversationTaskId)
|
|
|
+ throw new HttpError(409, '消息不能移入其他任务', 'message_task_mismatch')
|
|
|
+ const task = c.kind === 'group'
|
|
|
+ ? message.conversationTaskId
|
|
|
+ ? this.requireTask(owner, c.id, message.conversationTaskId)
|
|
|
+ : (() => { throw new HttpError(400, '群聊消息必须指定任务', 'workspace_task_required') })()
|
|
|
+ : undefined
|
|
|
if (previous?.visible === false && message.visible !== false) message.seq = 0
|
|
|
if (!message.seq) message.seq = c.lastSeq + 1
|
|
|
c.lastSeq = Math.max(c.lastSeq, message.seq)
|
|
|
@@ -407,6 +527,25 @@ export class WorkspaceStore {
|
|
|
}
|
|
|
this.put(owner, 'message', message.id, message)
|
|
|
this.put(owner, 'conversation', c.id, c)
|
|
|
+ if (task) {
|
|
|
+ const becameVisible = message.visible !== false && (!previous || previous.visible === false),
|
|
|
+ becameHidden = Boolean(previous && previous.visible !== false && message.visible === false)
|
|
|
+ if (becameVisible) task.messageCount += 1
|
|
|
+ if (becameHidden) task.messageCount = Math.max(0, task.messageCount - 1)
|
|
|
+ task.lastSeq = Math.max(task.lastSeq, message.seq)
|
|
|
+ if (becameVisible && message.seq > task.readSeq) task.unreadCount += 1
|
|
|
+ if (becameHidden && message.seq > task.readSeq) task.unreadCount = Math.max(0, task.unreadCount - 1)
|
|
|
+ if (message.visible !== false && (message.content.trim() || message.attachments.length)) {
|
|
|
+ if (message.role === 'user' && task.titleSource === 'automatic' && task.messageCount <= 1) {
|
|
|
+ const source = notificationPlainText(message.content, { maximum: 48, fallback: message.attachments[0]?.name ?? '' }).trim()
|
|
|
+ if (source) task.title = source
|
|
|
+ }
|
|
|
+ task.lastMessageAt = Math.max(task.lastMessageAt ?? 0, message.createdAt)
|
|
|
+ task.updatedAt = Date.now()
|
|
|
+ }
|
|
|
+ this.put(owner, 'conversation-task', task.id, task)
|
|
|
+ this.event(owner, 'task.changed', task, c.id)
|
|
|
+ }
|
|
|
this.event(owner, 'message.changed', message, c.id)
|
|
|
this.event(owner, 'conversation.changed', c, c.id)
|
|
|
})
|
|
|
@@ -415,42 +554,66 @@ export class WorkspaceStore {
|
|
|
return { ...agent, avatar: agent.avatar || encodeAgentAvatar(defaultAgentIdentity(agent.id, agent.name)) }
|
|
|
}
|
|
|
conversationSummary(owner: string, conversation: Conversation): Conversation {
|
|
|
- const member = conversation.kind === 'direct' ? this.get<Agent>(owner, 'agent', conversation.memberIds[0] ?? '') : undefined
|
|
|
+ const member = conversation.kind === 'direct' ? this.get<Agent>(owner, 'agent', conversation.memberIds[0] ?? '') : undefined,
|
|
|
+ unreadCount = conversation.kind === 'group'
|
|
|
+ ? this.tasks(owner, conversation.id).reduce((count, task) => count + task.unreadCount, 0)
|
|
|
+ : this.messages(owner, conversation.id, Number.MAX_SAFE_INTEGER, Number.MAX_SAFE_INTEGER).filter(message => message.seq > conversation.readSeq).length
|
|
|
return {
|
|
|
...conversation,
|
|
|
- unreadCount: this.messages(owner, conversation.id, Number.MAX_SAFE_INTEGER, Number.MAX_SAFE_INTEGER).filter(m => m.seq > conversation.readSeq).length,
|
|
|
+ unreadCount,
|
|
|
...(member ? { avatar: this.agentSummary(member).avatar } : {}),
|
|
|
lastMessageAt: conversation.lastMessageAt
|
|
|
?? (conversation.lastSeq > 0 ? this.messages(owner, conversation.id, Number.MAX_SAFE_INTEGER, 1).at(-1)?.createdAt : undefined)
|
|
|
?? conversation.createdAt,
|
|
|
}
|
|
|
}
|
|
|
- hiddenMessageIds(owner: string, conversationId: string): string[] {
|
|
|
- return this.messages(owner, conversationId, Number.MAX_SAFE_INTEGER, Number.MAX_SAFE_INTEGER, true).filter(m => m.visible === false).map(m => m.id)
|
|
|
+ hiddenMessageIds(owner: string, conversationId: string, conversationTaskId?: string): string[] {
|
|
|
+ return this.messages(owner, conversationId, Number.MAX_SAFE_INTEGER, Number.MAX_SAFE_INTEGER, true, conversationTaskId).filter(m => m.visible === false).map(m => m.id)
|
|
|
}
|
|
|
saveRun(owner: string, run: Run): void {
|
|
|
this.atomic(() => {
|
|
|
const c = this.require<Conversation>(owner, 'conversation', run.conversationId)
|
|
|
run.updatedAt = Date.now()
|
|
|
this.put(owner, 'run', run.id, run)
|
|
|
- const roots = this.list<Run>(owner, 'run').filter(r => r.conversationId === c.id && !['complete', 'failed', 'interrupted'].includes(r.status))
|
|
|
- .sort((a, b) => this.require<Message>(owner, 'message', a.messageId).seq - this.require<Message>(owner, 'message', b.messageId).seq)
|
|
|
- const current = roots[0]
|
|
|
- c.activeRunId = current?.id
|
|
|
- c.activeAgentId = current?.activeAgentId
|
|
|
- c.activeRunStatus = current?.status
|
|
|
- const tasks = 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, c.id)
|
|
|
- .map(row => JSON.parse(String(row.data)) as { agentId: string; status: string })
|
|
|
- c.activeAgentStates = Object.fromEntries(tasks.map(t => [t.agentId, t.status as 'running' | 'waiting' | 'uncertain' | 'queued']))
|
|
|
- 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, c.id, Date.now() - 2000)
|
|
|
- .map(row => JSON.parse(String(row.data)) as {id:string;agentId:string;status:string;updatedAt:number})
|
|
|
- c.avatarSignals = Object.fromEntries(outcomes.filter(t => Date.now() - t.updatedAt < 2000).map(t => [t.agentId, { id:t.id, state:t.status === 'failed' ? 'failure' as const : 'success' as const, at:t.updatedAt }]))
|
|
|
- c.queuedMessageCount = roots.filter(r => r.status === 'queued').length
|
|
|
+ if (run.conversationTaskId) {
|
|
|
+ const task = this.requireTask(owner, c.id, run.conversationTaskId),
|
|
|
+ activeRuns = this.list<Run>(owner, 'run')
|
|
|
+ .filter(root => root.conversationTaskId === task.id && !['complete', 'failed', 'interrupted'].includes(root.status))
|
|
|
+ .sort((a, b) => a.createdAt - b.createdAt || a.id.localeCompare(b.id)),
|
|
|
+ active = activeRuns[0]
|
|
|
+ task.activeRunId = active?.id
|
|
|
+ task.activeRunStatus = active?.status
|
|
|
+ this.put(owner, 'conversation-task', task.id, task)
|
|
|
+ this.event(owner, 'task.changed', task, c.id)
|
|
|
+ }
|
|
|
+ this.projectConversationRuns(owner, c)
|
|
|
this.put(owner, 'conversation', c.id, c)
|
|
|
this.event(owner, 'run.changed', run, c.id)
|
|
|
this.event(owner, 'conversation.changed', c, c.id)
|
|
|
})
|
|
|
}
|
|
|
+ activeTaskCount(owner: string, conversationId: string): number {
|
|
|
+ return new Set(
|
|
|
+ this.list<Run>(owner, 'run')
|
|
|
+ .filter(run => run.conversationId === conversationId && run.conversationTaskId && !['complete', 'failed', 'interrupted'].includes(run.status))
|
|
|
+ .map(run => run.conversationTaskId!),
|
|
|
+ ).size
|
|
|
+ }
|
|
|
+ private projectConversationRuns(owner: string, conversation: Conversation): void {
|
|
|
+ const roots = this.list<Run>(owner, 'run').filter(run => run.conversationId === conversation.id && !['complete', 'failed', 'interrupted'].includes(run.status))
|
|
|
+ .sort((a, b) => this.require<Message>(owner, 'message', a.messageId).seq - this.require<Message>(owner, 'message', b.messageId).seq),
|
|
|
+ current = roots[0]
|
|
|
+ conversation.activeRunId = current?.id
|
|
|
+ conversation.activeAgentId = current?.activeAgentId
|
|
|
+ conversation.activeRunStatus = current?.status
|
|
|
+ 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)
|
|
|
+ .map(row => JSON.parse(String(row.data)) as { agentId: string; status: string })
|
|
|
+ conversation.activeAgentStates = Object.fromEntries(turns.map(turn => [turn.agentId, turn.status as 'running' | 'waiting' | 'uncertain' | 'queued']))
|
|
|
+ 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)
|
|
|
+ .map(row => JSON.parse(String(row.data)) as {id:string;agentId:string;status:string;updatedAt:number})
|
|
|
+ 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 }]))
|
|
|
+ conversation.queuedMessageCount = roots.filter(run => run.status === 'queued').length
|
|
|
+ }
|
|
|
ownsUpstream(id: string): boolean {
|
|
|
return Boolean(
|
|
|
this.db
|