Эх сурвалжийг харах

补全归档 Bot 和群聊的删除接口及关联数据清理

LSamien 1 сар өмнө
parent
commit
10b36c8826

+ 8 - 0
src/server/workspaceRoutes.ts

@@ -116,6 +116,14 @@ export function workspaceRouter(
     nodes.requireSource(user, agent)
     ctx.body = { agent: store.agentSummary(store.updateAgent(user, ctx.params.id, input)) }
   })
+  router.delete('/api/app/agents/:id', (ctx) => {
+    store.deleteAgent(owner(ctx), ctx.params.id)
+    ctx.body = { ok: true }
+  })
+  router.delete('/api/app/conversations/:id', (ctx) => {
+    store.deleteConversation(owner(ctx), ctx.params.id)
+    ctx.body = { ok: true }
+  })
   router.get('/api/app/conversations', (ctx) => {
     const user = owner(ctx)
     ctx.body = {

+ 47 - 0
src/server/workspaceStore.ts

@@ -372,6 +372,53 @@ export class WorkspaceStore {
       return next
     })
   }
+  deleteAgent(owner: string, id: string): void {
+    this.atomic(() => {
+      const agent = this.require<Agent>(owner, 'agent', id)
+      if (!agent.archived) throw new HttpError(409, '请先归档 Bot,再删除', 'agent_not_archived')
+      if (agent.temporaryGoalId) throw new HttpError(409, '临时助手由所属任务管理', 'helper_task_bound')
+      if (agent.computerEnvironmentId) throw new HttpError(409, '请先解除电脑共享,再删除 Bot', 'shared_computer_active')
+      const conversations = this.list<Conversation>(owner, 'conversation')
+      if (conversations.some(c => c.kind === 'group' && c.memberIds.includes(id)))
+        throw new HttpError(409, '此 Bot 仍是群聊成员,请先从群聊移除或删除相关群聊', 'agent_in_group')
+      if (this.list<{ agentId: string; status: string }>(owner, 'turn').some(work => work.agentId === id && !['complete', 'failed', 'interrupted', 'skipped'].includes(work.status)))
+        throw new HttpError(409, 'Bot 仍有任务未结束,请停止任务后重试', 'agent_running')
+      for (const conversation of conversations.filter(c => c.kind === 'direct' && c.memberIds[0] === id))
+        this.deleteConversation(owner, conversation.id, true)
+      this.remove(owner, 'agent', id)
+      this.event(owner, 'agent.deleted', { id })
+    })
+  }
+  deleteConversation(owner: string, id: string, deletingAgent = false): void {
+    this.atomic(() => {
+      const conversation = this.require<Conversation>(owner, 'conversation', id)
+      if (conversation.kind === 'direct' && !deletingAgent)
+        throw new HttpError(400, '请删除对应的 Bot', 'delete_agent_instead')
+      if (!conversation.archived) throw new HttpError(409, '请先归档聊天,再删除', 'conversation_not_archived')
+      if (['run', 'turn'].some(kind => this.list<{ conversationId: string; status: string }>(owner, kind).some(run => run.conversationId === id && !['complete', 'failed', 'interrupted'].includes(run.status))))
+        throw new HttpError(409, '聊天仍有任务未结束,请停止任务后重试', 'conversation_running')
+      if (this.list<{ conversationId: string; origin?: { conversationId: string }; status: string }>(owner, 'goal').some(goal =>
+        (goal.conversationId === id || goal.origin?.conversationId === id) && !['complete', 'blocked', 'cancelled'].includes(goal.status)))
+        throw new HttpError(409, '聊天仍有关联任务未结束,请停止任务后重试', 'conversation_running')
+      const taskIds = new Set(this.list<Task>(owner, 'conversation-task').filter(task => task.conversationId === id).map(task => task.id))
+      for (const taskId of taskIds) {
+        this.remove(owner, 'context', taskId)
+        this.db.prepare("DELETE FROM workspace_entities WHERE owner=? AND kind='task-delivery' AND json_extract(data,'$.goalId')=?").run(owner, taskId)
+      }
+      // Keep files in the file library, matching task deletion; detach their chat references.
+      for (const file of this.list<{ id: string; conversationId?: string; conversationTaskId?: string }>(owner, 'file')) {
+        if (file.conversationId === id || (file.conversationTaskId && taskIds.has(file.conversationTaskId)))
+          this.put(owner, 'file', file.id, { ...file, conversationId: undefined, conversationTaskId: undefined, messageId: undefined })
+      }
+      const bindingPrefix = `${id}:`
+      this.db.prepare("DELETE FROM workspace_entities WHERE owner=? AND ((kind='binding' AND substr(id,1,?)=?) OR (kind='interaction-binding' AND substr(json_extract(data,'$.key'),1,?)=?))").run(owner, bindingPrefix.length, bindingPrefix, bindingPrefix.length, bindingPrefix)
+      this.db.prepare("DELETE FROM workspace_entities WHERE owner=? AND kind IN ('message','run','turn','interaction','interaction-binding','binding','conversation-task','goal','assignment') AND json_extract(data,'$.conversationId')=?").run(owner, id)
+      this.db.prepare("DELETE FROM workspace_commands WHERE owner=? AND json_extract(result,'$.conversationId')=?").run(owner, id)
+      this.remove(owner, 'context', id)
+      this.remove(owner, 'conversation', id)
+      this.event(owner, 'conversation.deleted', { id }, id)
+    })
+  }
   createGroup(owner: string, input: unknown): Conversation {
     const body = parse(groupInput, input)
     const ids = new Set(body.memberIds)

+ 71 - 0
tests/server/workspaceRoutes.test.ts

@@ -95,6 +95,77 @@ afterEach(() => {
   rmSync(home, { recursive: true, force: true })
 })
 describe('application workspace HTTP contract', () => {
+  it('deletes only archived owned chats, clears their records, and preserves library files and members', async () => {
+    const store = runtime.workspace
+    const lead = store.createAgent('first', { name: '负责人', profile: 'default' })
+    const member = store.createAgent('first', { name: '成员', profile: 'default' })
+    const group = store.createGroup('first', { name: '待删除群聊', memberIds: [lead.id, member.id], administratorId: lead.id })
+    const task = store.tasks('first', group.id)[0]!
+    const path = `/api/app/conversations/${group.id}`
+    await req('delete', path).expect(409)
+    store.updateConversation('first', group.id, { archived: true })
+    store.put('second', 'conversation', group.id, group)
+    for (const kind of ['message', 'run', 'turn', 'interaction', 'interaction-binding', 'binding', 'assignment']) {
+      store.put('first', kind, kind, { id: kind, conversationId: group.id, conversationTaskId: task.id, status: 'complete' })
+      store.put('second', kind, kind, { id: kind, conversationId: group.id })
+    }
+    store.put('first', 'context', task.id, { used: 5 })
+    const bindingKey = `${group.id}:${task.id}:${lead.id}`
+    store.put('first', 'binding', bindingKey, { conversationTaskId: task.id, sessionId: 'session' })
+    store.put('first', 'interaction-binding', 'legacy-interaction', { key: bindingKey, taskId: 'turn' })
+    store.put('second', 'binding', bindingKey, { sessionId: 'other-session' })
+    store.put('first', 'file', 'file', { id: 'file', conversationId: group.id, messageId: 'message', name: '保留.txt' })
+    await req('delete', path, 'third').expect(404)
+    await req('delete', path).set('X-CSRF-Token', 'invalid').expect(403)
+    await req('delete', path).expect(200)
+    await req('get', path).expect(404)
+    expect(store.get('first', 'context', task.id)).toBeUndefined()
+    expect(store.get('first', 'conversation-task', task.id)).toBeUndefined()
+    expect(store.list('first', 'message')).toEqual([])
+    expect(store.list('first', 'run')).toEqual([])
+    expect(store.list('first', 'binding')).toEqual([])
+    expect(store.list('first', 'interaction-binding')).toEqual([])
+    expect(store.get('second', 'binding', bindingKey)).toEqual({ sessionId: 'other-session' })
+    expect(store.get('first', 'file', 'file')).toEqual({ id: 'file', name: '保留.txt' })
+    expect(store.list('first', 'agent')).toHaveLength(2)
+    expect(store.get('second', 'conversation', group.id)).toEqual(group)
+    expect(store.get('second', 'message', 'message')).toBeDefined()
+    expect((await req('get', '/api/app/events')).body.events.some((e: any) => e.type === 'conversation.deleted' && e.conversationId === group.id)).toBe(true)
+  })
+  it('deletes an archived Bot and its direct chat only after group references are removed', async () => {
+    const store = runtime.workspace
+    const bot = store.createAgent('first', { name: '归档 Bot', profile: 'default' })
+    const member = store.createAgent('first', { name: '成员', profile: 'default' })
+    const direct = store.list<any>('first', 'conversation').find(c => c.memberIds[0] === bot.id)!
+    const group = store.createGroup('first', { name: '关联群', memberIds: [bot.id, member.id], administratorId: member.id })
+    const path = `/api/app/agents/${bot.id}`
+    await req('delete', path).expect(409)
+    store.updateAgent('first', bot.id, { archived: true })
+    await req('delete', `/api/app/conversations/${direct.id}`).expect(400)
+    expect((await req('delete', path).expect(409)).body.code).toBe('agent_in_group')
+    expect(store.get('first', 'agent', bot.id)).toBeDefined()
+    store.updateConversation('first', group.id, { memberIds: [member.id] })
+    await req('delete', path, 'second').expect(404)
+    await req('delete', path).expect(200)
+    expect(store.get('first', 'agent', bot.id)).toBeUndefined()
+    expect(store.get('first', 'conversation', direct.id)).toBeUndefined()
+    expect(store.get('first', 'conversation', group.id)).toBeDefined()
+    expect(store.get('first', 'agent', member.id)).toBeDefined()
+  })
+  it('keeps archived chats intact while runs or delegated goals are unresolved', async () => {
+    const store = runtime.workspace
+    const bot = store.createAgent('first', { name: '运行中', profile: 'default' })
+    const direct = store.list<any>('first', 'conversation')[0]!
+    store.updateAgent('first', bot.id, { archived: true })
+    store.put('first', 'run', 'pending', { id: 'pending', conversationId: direct.id, status: 'uncertain' })
+    const path = `/api/app/agents/${bot.id}`
+    await req('delete', path).expect(409)
+    expect(store.get('first', 'conversation', direct.id)).toBeDefined()
+    store.remove('first', 'run', 'pending')
+    store.put('first', 'goal', 'delegated', { id: 'delegated', conversationId: 'another-chat', origin: { conversationId: direct.id }, status: 'waiting' })
+    await req('delete', path).expect(409)
+    expect(store.get('first', 'agent', bot.id)).toBeDefined()
+  })
   it('resumes a stopped goal from a real user request without duplicating its run',async()=>{
     vi.spyOn(runtime.workspaceRuntime,'wake').mockImplementation(()=>{})
     vi.spyOn(runtime.workspaceRuntime.teamTools,'requireAvailable').mockResolvedValue()