feat: add remote channels and richer notes

This commit is contained in:
lofyer
2026-08-09 15:48:52 +08:00
parent 417a9fccb6
commit 6c891f3522
69 changed files with 13012 additions and 499 deletions
+445 -4
View File
@@ -96,7 +96,7 @@ describe('AssistantDatabase', () => {
database.close()
})
it('migrates existing databases to schema version 8', async () => {
it('migrates existing databases to schema version 15', async () => {
const directory = await mkdtemp(
join(tmpdir(), 'goodbuddy-assistant-migration-')
)
@@ -124,7 +124,7 @@ describe('AssistantDatabase', () => {
user_version: number
}
).user_version
).toBe(8)
).toBe(15)
expect(
current
.prepare(
@@ -164,6 +164,28 @@ describe('AssistantDatabase', () => {
{ name: 'messages_state_idx' },
{ name: 'tasks_status_idx' }
])
expect(
(
current.prepare('PRAGMA table_info(tasks)').all() as Array<{
name: string
}>
).some((column) => column.name === 'visible')
).toBe(true)
expect(
(
current
.prepare('PRAGMA table_info(magic_note_entries)')
.all() as Array<{ name: string }>
).some((column) => column.name === 'image_bytes')
).toBe(true)
expect(
current
.prepare(
`SELECT name FROM sqlite_master
WHERE type = 'table' AND name = 'magic_todos'`
)
.get()
).toEqual({ name: 'magic_todos' })
current.close()
})
@@ -197,7 +219,7 @@ describe('AssistantDatabase', () => {
user_version: number
}
).user_version
).toBe(8)
).toBe(15)
expect(
current
.prepare(
@@ -235,6 +257,52 @@ describe('AssistantDatabase', () => {
current.close()
})
it('backfills checklist todos when migrating existing magic notes', async () => {
const directory = await mkdtemp(
join(tmpdir(), 'goodbuddy-magic-todo-migration-')
)
temporaryDirectories.push(directory)
const databasePath = join(directory, 'assistant.sqlite')
const initial = new AssistantDatabase(databasePath)
initial.initialize('C:\\Workspace')
const project = initial.listProjects()[0]!
const note = initial.createMagicNote({
projectId: project.id,
title: '迁移笔记'
})
initial.createMagicNoteEntry({
noteId: note.id,
content: {
version: 1,
ops: [
{ insert: '迁移待办' },
{ insert: '\n', attributes: { list: 'unchecked' } }
]
},
plainText: '迁移待办'
})
initial.close()
const legacy = new DatabaseSync(databasePath)
legacy.exec(`
DELETE FROM magic_todos;
PRAGMA user_version = 9;
`)
legacy.close()
const migrated = new AssistantDatabase(databasePath)
migrated.initialize('C:\\Workspace')
expect(migrated.listMagicTodos(project.id)).toEqual([
expect.objectContaining({
noteId: note.id,
source: 'note',
title: '迁移待办',
completed: false
})
])
migrated.close()
})
it('creates a default project and persists project updates', async () => {
const database = await createDatabase()
const [defaultProject] = database.listProjects()
@@ -264,7 +332,6 @@ describe('AssistantDatabase', () => {
name: '产品发布 2',
defaultWorkMode: 'execute'
})
database.setProjectArchived(project.id, true)
expect(database.listProjects()).toHaveLength(1)
expect(database.listProjects(true)).toEqual(
@@ -278,6 +345,179 @@ describe('AssistantDatabase', () => {
database.close()
})
it('idempotently creates protected channel projects by channel identity', async () => {
const database = await createDatabase()
const sameName = database.createProject({
name: '微信 ClawBot',
description: '普通同名项目',
rootPath: 'C:\\Ordinary',
defaultWorkMode: 'execute'
})
const first = database.ensureChannelProjects('C:\\Users\\test')
const second = database.ensureChannelProjects('C:\\Ignored')
expect(first).toEqual([
expect.objectContaining({
name: '微信 ClawBot',
rootPath: 'C:\\Users\\test',
defaultWorkMode: 'ask',
kind: 'channel',
channel: 'weixin'
}),
expect.objectContaining({
kind: 'channel',
channel: 'wecom'
}),
expect.objectContaining({
kind: 'channel',
channel: 'dingtalk'
})
])
expect(second.map((project) => project.id)).toEqual(
first.map((project) => project.id)
)
expect(database.getProject(sameName.id)).toMatchObject({
kind: 'user',
channel: undefined,
rootPath: 'C:\\Ordinary'
})
const weixin = first[0]!
const updated = database.updateProject(weixin.id, {
name: '不可重命名',
description: '更新后的通道说明',
rootPath: 'C:\\Remote',
defaultWorkMode: 'execute'
})
expect(updated).toMatchObject({
name: '微信 ClawBot',
description: '更新后的通道说明',
rootPath: 'C:\\Remote',
defaultWorkMode: 'execute'
})
expect(() =>
database.updateProject(weixin.id, {
name: weixin.name,
description: weixin.description,
rootPath: ' ',
defaultWorkMode: 'execute'
})
).toThrow('通道项目必须设置默认工作目录')
expect(() =>
database.setProjectArchived(weixin.id, true)
).toThrow('系统通道项目不能归档')
expect(() =>
database.deleteProject(weixin.id, weixin.name)
).toThrow('系统通道项目不能删除')
database.close()
})
it('persists one protected remote conversation per channel identity', async () => {
const database = await createDatabase()
const project = database.ensureChannelProjects(
'C:\\Users\\test'
)[0]!
const first = database.getOrCreateRemoteConversation({
projectId: project.id,
channel: 'weixin',
accountId: 'default',
externalConversationId: 'remote-user-1',
conversationType: 'direct',
title: '微信 ClawBot · ****0001',
accountDisplay: '发送者 ****0001'
})
const second = database.getOrCreateRemoteConversation({
projectId: project.id,
channel: 'weixin',
accountId: 'default',
externalConversationId: 'remote-user-1',
conversationType: 'direct',
title: '微信 ClawBot · ****0001',
accountDisplay: '发送者 ****0001'
})
expect(second.id).toBe(first.id)
database.appendRemoteConversationMessage({
conversationId: first.id,
role: 'user',
content: '请分析状态',
status: '微信 ClawBot · 对话'
})
database.appendRemoteConversationMessage({
conversationId: first.id,
role: 'assistant',
content: '状态正常',
status: '微信 ClawBot · 已完成'
})
expect(database.getConversation(first.id)).toMatchObject({
projectId: project.id,
remote: {
channel: 'weixin',
accountDisplay: '发送者 ****0001',
conversationType: 'direct'
},
messages: [
{
role: 'user',
content: '请分析状态',
status: '微信 ClawBot · 对话'
},
{
role: 'assistant',
content: '状态正常',
status: '微信 ClawBot · 已完成'
}
]
})
database.replaceConversations([])
expect(database.getConversation(first.id).remote?.channel).toBe(
'weixin'
)
database.close()
})
it('persists remote event deduplication and failed reply outbox state', async () => {
const directory = await mkdtemp(
join(tmpdir(), 'goodbuddy-channel-state-')
)
temporaryDirectories.push(directory)
const databasePath = join(directory, 'assistant.sqlite')
const database = new AssistantDatabase(databasePath)
database.initialize('C:\\Workspace')
expect(database.claimChannelEvent('weixin', 'event-1')).toBe(true)
expect(database.claimChannelEvent('weixin', 'event-1')).toBe(false)
expect(database.claimChannelEvent('dingtalk', 'event-1')).toBe(true)
const entry = database.enqueueChannelResult({
channel: 'weixin',
eventId: 'event-1',
conversationId: 'conversation-1',
recipientId: 'sender-1',
status: 'completed',
output: '已完成'
})
database.markChannelResult(entry.id, 'failed')
expect(database.listUndeliveredChannelResults()).toEqual([
{
...entry,
state: 'failed',
attempts: 1
}
])
database.markChannelResult(entry.id, 'delivered')
expect(database.listUndeliveredChannelResults()).toEqual([])
database.close()
const reopened = new AssistantDatabase(databasePath)
reopened.initialize('C:\\Workspace')
expect(reopened.claimChannelEvent('weixin', 'event-1')).toBe(
false
)
reopened.close()
})
it('safely deletes a confirmed project and its scoped data', async () => {
const database = await createDatabase()
const project = database.createProject({
@@ -1347,6 +1587,202 @@ describe('AssistantDatabase', () => {
database.close()
})
it('persists scoped magic notes and AI comments without todo proposals', async () => {
const database = await createDatabase()
const project = database.listProjects()[0]!
const globalNote = database.createMagicNote({
title: '全局笔记'
})
const projectNote = database.createMagicNote({
projectId: project.id,
title: '项目笔记'
})
expect(database.listMagicNotes()).toEqual([
expect.objectContaining({ id: globalNote.id, title: '全局笔记' })
])
expect(database.listMagicNotes(project.id)).toEqual([
expect.objectContaining({ id: projectNote.id, title: '项目笔记' })
])
const withEntry = database.createMagicNoteEntry({
noteId: projectNote.id,
content: {
version: 1,
ops: [
{ insert: '整理发布清单', attributes: { bold: true } },
{ insert: '\n' }
]
},
plainText: '整理发布清单'
})
const entry = withEntry.entries[0]!
expect(withEntry).toMatchObject({
entryCount: 1,
preview: '整理发布清单'
})
const analyzed = database.saveMagicNoteAnalysis({
entryId: entry.id,
expectedRevision: entry.revision,
comments: [
{
id: '00000000-0000-4000-8000-000000000401',
kind: 'suggestion',
content: '可以拆成可检查的发布步骤。'
}
]
})
expect(analyzed.entries[0]!.comments).toEqual([
expect.objectContaining({
kind: 'suggestion',
content: '可以拆成可检查的发布步骤。'
})
])
expect(database.listTasks()).toEqual([])
database.close()
})
it('synchronizes note checklists and standalone magic todos bidirectionally', async () => {
const database = await createDatabase()
const project = database.listProjects()[0]!
const note = database.createMagicNote({
projectId: project.id,
title: '发布笔记'
})
const withEntry = database.createMagicNoteEntry({
noteId: note.id,
content: {
version: 1,
ops: [
{ insert: '核对发布材料' },
{ insert: '\n', attributes: { list: 'unchecked' } },
{ insert: '上传构建产物' },
{ insert: '\n', attributes: { list: 'checked' } }
]
},
plainText: '核对发布材料\n上传构建产物'
})
const entry = withEntry.entries[0]!
const noteTodos = database.listMagicTodos(project.id)
expect(noteTodos).toEqual([
expect.objectContaining({
noteId: note.id,
entryId: entry.id,
source: 'note',
title: '核对发布材料',
completed: false
}),
expect.objectContaining({
source: 'note',
title: '上传构建产物',
completed: true
})
])
const completed = database.updateMagicTodo({
todoId: noteTodos[0]!.id,
completed: true,
expectedRevision: noteTodos[0]!.revision
})
expect(completed.completed).toBe(true)
expect(database.getMagicNote(note.id).entries[0]!.content.ops).toEqual(
expect.arrayContaining([
expect.objectContaining({
insert: '\n',
attributes: expect.objectContaining({ list: 'checked' })
})
])
)
const updatedEntry = database.getMagicNote(note.id).entries[0]!
database.updateMagicNoteEntry({
entryId: entry.id,
expectedRevision: updatedEntry.revision,
content: {
version: 1,
ops: [
{ insert: '新增首项' },
{ insert: '\n', attributes: { list: 'unchecked' } },
{ insert: '上传构建产物' },
{ insert: '\n', attributes: { list: 'unchecked' } },
{ insert: '核对发布材料' },
{ insert: '\n', attributes: { list: 'checked' } }
]
},
plainText: '新增首项\n上传构建产物\n核对发布材料'
})
const reordered = database.listMagicTodos(project.id)
expect(
reordered.find((todo) => todo.title === '核对发布材料')
).toMatchObject({
id: noteTodos[0]!.id,
completed: true,
sourceIndex: 2
})
expect(
reordered.find((todo) => todo.title === '上传构建产物')
).toMatchObject({
id: noteTodos[1]!.id,
completed: false,
sourceIndex: 1
})
const manual = database.createMagicTodo({
projectId: project.id,
title: '手动待办',
instructions: '补充验收说明'
})
expect(manual).toMatchObject({
source: 'manual',
completed: false,
title: '手动待办'
})
const edited = database.updateMagicTodo({
todoId: manual.id,
title: '更新后的手动待办',
instructions: '新的说明',
expectedRevision: manual.revision
})
expect(edited).toMatchObject({
title: '更新后的手动待办',
instructions: '新的说明'
})
database.deleteMagicTodo(edited.id)
expect(
database.listMagicTodos(project.id).some((todo) => todo.id === edited.id)
).toBe(false)
database.close()
})
it('protects magic note records from stale revisions', async () => {
const database = await createDatabase()
const note = database.createMagicNote({ title: '并发笔记' })
const withEntry = database.createMagicNoteEntry({
noteId: note.id,
content: { version: 1, ops: [{ insert: '初始内容\n' }] },
plainText: '初始内容'
})
const entry = withEntry.entries[0]!
database.updateMagicNoteEntry({
entryId: entry.id,
expectedRevision: entry.revision,
content: { version: 1, ops: [{ insert: '新内容\n' }] },
plainText: '新内容'
})
expect(() =>
database.updateMagicNoteEntry({
entryId: entry.id,
expectedRevision: entry.revision,
content: { version: 1, ops: [{ insert: '过期内容\n' }] },
plainText: '过期内容'
})
).toThrow('记录已被更新')
database.close()
})
it('clears private assistant content while preserving workspace configuration', async () => {
const database = await createDatabase()
const project = database.listProjects()[0]!
@@ -1395,6 +1831,10 @@ describe('AssistantDatabase', () => {
cacheRead: 2,
cacheWrite: 1
})
database.createMagicNote({
projectId: project.id,
title: '待清除笔记'
})
expect(database.getTokenUsageSummary().totals.totalTokens).toBe(15)
database.clearAssistantData()
@@ -1406,6 +1846,7 @@ describe('AssistantDatabase', () => {
expect(database.listHeartbeatConfigs(project.id)).toEqual([])
expect(database.listTasks()).toEqual([])
expect(database.listArtifacts(project.id)).toEqual([])
expect(database.listMagicNotes(project.id)).toEqual([])
expect(database.getTokenUsageSummary()).toEqual({
totals: {
callCount: 0,