فهرست منبع

修复旧群聊内容未归入首条任务

LSamien 1 ماه پیش
والد
کامیت
69968dc1d3
2فایلهای تغییر یافته به همراه235 افزوده شده و 0 حذف شده
  1. 133 0
      src/server/workspaceStore.ts
  2. 102 0
      tests/server/workspace.test.ts

+ 133 - 0
src/server/workspaceStore.ts

@@ -150,6 +150,16 @@ export class WorkspaceStore {
       }
       this.db.prepare('INSERT INTO workspace_migrations VALUES(?)').run('avatar-random-default-v1')
     })
+    this.atomic(() => {
+      const migration = 'conversation-task-history-v1'
+      if (this.db.prepare('SELECT id FROM workspace_migrations WHERE id=?').get(migration)) return
+      for (const owner of this.owners()) {
+        for (const conversation of this.list<Conversation>(owner, 'conversation')) {
+          if (conversation.kind === 'group') this.backfillConversationTask(owner, conversation)
+        }
+      }
+      this.db.prepare('INSERT INTO workspace_migrations VALUES(?)').run(migration)
+    })
   }
   get<T>(owner: string, kind: string, id: string): T | undefined {
     const row = this.db
@@ -355,6 +365,129 @@ export class WorkspaceStore {
     })
     return c
   }
+  private backfillConversationTask(owner: string, conversation: Conversation): void {
+    const legacyMessages = this.list<Message>(owner, 'message')
+      .filter(message => message.conversationId === conversation.id && !message.conversationTaskId)
+      .sort((a, b) => a.seq - b.seq || a.createdAt - b.createdAt || a.id.localeCompare(b.id))
+    if (!legacyMessages.length) return
+
+    const existingTasks = this.tasks(owner, conversation.id)
+    const assignedTaskIds = new Set(
+      this.list<Message>(owner, 'message')
+        .filter(message => message.conversationId === conversation.id && message.conversationTaskId)
+        .map(message => message.conversationTaskId!),
+    )
+    const reusable = existingTasks.length === 1
+      && existingTasks[0]!.titleSource === 'automatic'
+      && existingTasks[0]!.title === '新任务'
+      && existingTasks[0]!.messageCount === 0
+      && !assignedTaskIds.has(existingTasks[0]!.id)
+      ? existingTasks[0]
+      : undefined
+    const visibleMessages = legacyMessages.filter(message => message.visible !== false)
+    const meaningfulMessages = visibleMessages.filter(
+      message => message.content.trim() || message.attachments.length,
+    )
+    const firstMessage = legacyMessages[0]!,
+      lastMessage = meaningfulMessages.at(-1) ?? visibleMessages.at(-1) ?? legacyMessages.at(-1)!,
+      firstUserMessage = visibleMessages.find(
+        message => message.role === 'user' && (message.content.trim() || message.attachments.length),
+      ),
+      title = notificationPlainText(firstUserMessage?.content ?? '', {
+        maximum: 48,
+        fallback: firstUserMessage?.attachments[0]?.name ?? '',
+      }).trim()
+        || notificationPlainText(conversation.preview, { maximum: 48, fallback: '' }).trim()
+        || '历史任务',
+      lastSeq = legacyMessages.reduce((value, message) => Math.max(value, message.seq), 0),
+      readSeq = Math.min(conversation.readSeq, lastSeq),
+      task: Task = {
+        ...(reusable ?? {
+          id: randomUUID(),
+          conversationId: conversation.id,
+          title: '历史任务',
+          titleSource: 'automatic' as const,
+          messageCount: 0,
+          readSeq: 0,
+          lastSeq: 0,
+          unreadCount: 0,
+          createdAt: firstMessage.createdAt,
+          updatedAt: lastMessage.createdAt,
+        }),
+        title,
+        titleSource: 'automatic',
+        messageCount: visibleMessages.length,
+        readSeq,
+        lastSeq,
+        unreadCount: visibleMessages.filter(message => message.seq > readSeq).length,
+        lastMessageAt: lastMessage.createdAt,
+        createdAt: firstMessage.createdAt,
+        updatedAt: lastMessage.createdAt,
+      }
+
+    for (const message of legacyMessages) {
+      this.put(owner, 'message', message.id, {
+        ...message,
+        conversationTaskId: task.id,
+      })
+    }
+    const migratedInteractions = new Map<string, string>()
+    for (const kind of ['run', 'turn', 'interaction'] as const) {
+      for (const entity of this.list<Record<string, unknown> & {
+        id: string
+        conversationId?: string
+        conversationTaskId?: string
+        agentId?: string
+      }>(owner, kind)) {
+        if (entity.conversationId !== conversation.id || entity.conversationTaskId) continue
+        this.put(owner, kind, entity.id, { ...entity, conversationTaskId: task.id })
+        if (kind === 'interaction' && entity.agentId) {
+          migratedInteractions.set(entity.id, entity.agentId)
+        }
+      }
+    }
+    for (const [interactionId, agentId] of migratedInteractions) {
+      const binding = this.get<Record<string, unknown> & {
+        conversationTaskId?: string
+      }>(owner, 'interaction-binding', interactionId)
+      if (!binding || binding.conversationTaskId) continue
+      this.put(owner, 'interaction-binding', interactionId, {
+        ...binding,
+        key: `${conversation.id}:${task.id}:${agentId}`,
+        conversationTaskId: task.id,
+      })
+    }
+    for (const agentId of conversation.memberIds) {
+      const legacyKey = `${conversation.id}:${agentId}`,
+        taskKey = `${conversation.id}:${task.id}:${agentId}`,
+        binding = this.get<Record<string, unknown> & {
+          conversationTaskId?: string
+        }>(owner, 'binding', legacyKey)
+      if (!binding || binding.conversationTaskId) continue
+      this.put(owner, 'binding', taskKey, { ...binding, conversationTaskId: task.id })
+      this.remove(owner, 'binding', legacyKey)
+    }
+    const context = this.get<Record<string, unknown> & {
+      conversationTaskId?: string
+    }>(owner, 'context', conversation.id)
+    if (context && !context.conversationTaskId) {
+      this.put(owner, 'context', task.id, { ...context, conversationTaskId: task.id })
+      this.remove(owner, 'context', conversation.id)
+    }
+    this.db.prepare(
+      `UPDATE workspace_commands
+       SET result=json_set(result,'$.conversationTaskId',?)
+       WHERE owner=? AND json_extract(result,'$.conversationId')=?
+         AND json_extract(result,'$.conversationTaskId') IS NULL`,
+    ).run(task.id, owner, conversation.id)
+
+    const activeRuns = this.list<Run>(owner, 'run')
+      .filter(run => run.conversationTaskId === task.id && !['complete', 'failed', 'interrupted'].includes(run.status))
+      .sort((a, b) => a.createdAt - b.createdAt || a.id.localeCompare(b.id))
+    task.activeRunId = activeRuns[0]?.id
+    task.activeRunStatus = activeRuns[0]?.status
+    this.put(owner, 'conversation-task', task.id, task)
+  }
   tasks(owner: string, conversationId: string): Task[] {
     const conversation = this.require<Conversation>(owner, 'conversation', conversationId)
     if (conversation.kind !== 'group')

+ 102 - 0
tests/server/workspace.test.ts

@@ -119,6 +119,108 @@ async function finished(id: string) {
   return store.require<WorkspaceRun>(owner, 'run', id)
 }
 describe('Web-owned workspace', () => {
+  it('backfills legacy group history into the initial task instead of leaving a new empty task', () => {
+    const legacyHome = mkdtempSync(join(tmpdir(), 'yaoyao-workspace-task-migration-'))
+    let seededStore: WorkspaceStore | undefined = new WorkspaceStore(legacyHome)
+    let migratedStore: WorkspaceStore | undefined
+    try {
+      const administrator = seededStore.createAgent(owner, { name: '历史管理员', profile: 'default' }),
+        member = seededStore.createAgent(owner, { name: '历史成员', profile: 'default' }),
+        conversation = seededStore.createGroup(owner, {
+          name: '公众号文案团队',
+          memberIds: [administrator.id, member.id],
+          administratorId: administrator.id,
+        }),
+        initialTask = seededStore.tasks(owner, conversation.id)[0]!,
+        userAt = 1_788_000_000_000,
+        assistantAt = userAt + 60_000
+      seededStore.put(owner, 'message', 'legacy-user', {
+        id: 'legacy-user', conversationId: conversation.id, seq: 1, role: 'user',
+        content: '请继续完成公众号文案', reasoning: '', status: 'complete',
+        attachments: [], tools: [], createdAt: userAt,
+      } satisfies WorkspaceMessage)
+      seededStore.put(owner, 'message', 'legacy-assistant', {
+        id: 'legacy-assistant', conversationId: conversation.id, seq: 2, role: 'assistant',
+        agentId: administrator.id, agentName: '历史管理员', content: '旧内容已经完成。',
+        reasoning: '', status: 'complete', attachments: [], tools: [], createdAt: assistantAt,
+      } satisfies WorkspaceMessage)
+      seededStore.put(owner, 'conversation', conversation.id, {
+        ...conversation,
+        readSeq: 1,
+        lastSeq: 2,
+        preview: '旧内容已经完成。',
+        lastMessageAt: assistantAt,
+        updatedAt: assistantAt,
+      })
+      seededStore.put(owner, 'run', 'legacy-run', {
+        id: 'legacy-run', conversationId: conversation.id, messageId: 'legacy-user',
+        mentionIds: [], status: 'complete', round: 1, createdAt: userAt, updatedAt: assistantAt,
+      } satisfies WorkspaceRun)
+      seededStore.put(owner, 'turn', 'legacy-turn', {
+        id: 'legacy-turn', runId: 'legacy-run', conversationId: conversation.id,
+        agentId: administrator.id, messageId: 'legacy-user', status: 'complete', createdAt: userAt,
+      })
+      seededStore.put(owner, 'interaction', 'legacy-interaction', {
+        id: 'legacy-interaction', conversationId: conversation.id, runId: 'legacy-run',
+        agentId: administrator.id, kind: 'clarification', message: '补充说明',
+        choices: [], resolved: true,
+      } satisfies WorkspaceInteraction)
+      seededStore.put(owner, 'interaction-binding', 'legacy-interaction', {
+        key: `${conversation.id}:${administrator.id}`,
+        upstreamId: 'upstream-interaction', taskId: 'legacy-turn',
+      })
+      seededStore.put(owner, 'binding', `${conversation.id}:${administrator.id}`, {
+        nodeId: 'local', profile: 'default', storedId: 'stored-legacy',
+        runtimeId: 'runtime-legacy', aliases: [], runId: 'legacy-run',
+        messageId: 'legacy-user', taskId: 'legacy-turn',
+      })
+      seededStore.put(owner, 'context', conversation.id, {
+        usedTokens: 100, limitTokens: 1_000, observedAt: assistantAt,
+      })
+      seededStore.db.prepare('DELETE FROM workspace_migrations WHERE id=?')
+        .run('conversation-task-history-v1')
+      seededStore.close()
+      seededStore = undefined
+
+      migratedStore = new WorkspaceStore(legacyHome)
+      const tasks = migratedStore.tasks(owner, conversation.id)
+      expect(tasks).toHaveLength(1)
+      expect(tasks[0]).toMatchObject({
+        id: initialTask.id,
+        title: '请继续完成公众号文案',
+        titleSource: 'automatic',
+        messageCount: 2,
+        readSeq: 1,
+        lastSeq: 2,
+        unreadCount: 1,
+        lastMessageAt: assistantAt,
+        createdAt: userAt,
+        updatedAt: assistantAt,
+      })
+      expect(
+        migratedStore.messages(
+          owner, conversation.id, Number.MAX_SAFE_INTEGER, 100, false, initialTask.id,
+        ).map(message => message.id),
+      ).toEqual(['legacy-user', 'legacy-assistant'])
+      expect(migratedStore.require<WorkspaceRun>(owner, 'run', 'legacy-run').conversationTaskId)
+        .toBe(initialTask.id)
+      expect(migratedStore.require<{ conversationTaskId: string }>(owner, 'turn', 'legacy-turn').conversationTaskId)
+        .toBe(initialTask.id)
+      expect(migratedStore.require<WorkspaceInteraction>(owner, 'interaction', 'legacy-interaction').conversationTaskId)
+        .toBe(initialTask.id)
+      expect(migratedStore.get(owner, 'binding', `${conversation.id}:${administrator.id}`)).toBeUndefined()
+      expect(migratedStore.get(owner, 'binding', `${conversation.id}:${initialTask.id}:${administrator.id}`))
+        .toMatchObject({ conversationTaskId: initialTask.id })
+      expect(migratedStore.get(owner, 'context', conversation.id)).toBeUndefined()
+      expect(migratedStore.get(owner, 'context', initialTask.id))
+        .toMatchObject({ conversationTaskId: initialTask.id })
+    } finally {
+      seededStore?.close()
+      migratedStore?.close()
+      rmSync(legacyHome, { recursive: true, force: true })
+    }
+  })
+
   it('isolates messages, Hermes sessions, bindings, and context between concurrent group tasks', async () => {
     const administrator = store.createAgent(owner, { name: '多任务远程管理员', profile: 'remote-profile', nodeId: 'remote-node' }),
       member = agent('多任务成员'),