fix: preserve conversation and note data

This commit is contained in:
lofyer
2026-08-13 01:32:10 +08:00
parent 40696d9ac7
commit 04a260133a
4 changed files with 180 additions and 31 deletions
+64 -5
View File
@@ -98,7 +98,7 @@ describe('AssistantDatabase', () => {
database.close()
})
it('migrates existing databases to schema version 17', async () => {
it('migrates existing databases to schema version 18', async () => {
const directory = await mkdtemp(
join(tmpdir(), 'goodbuddy-assistant-migration-')
)
@@ -127,7 +127,7 @@ describe('AssistantDatabase', () => {
user_version: number
}
).user_version
).toBe(17)
).toBe(18)
expect(
current
.prepare(
@@ -231,7 +231,7 @@ describe('AssistantDatabase', () => {
user_version: number
}
).user_version
).toBe(17)
).toBe(18)
expect(
current
.prepare(
@@ -591,6 +591,37 @@ describe('AssistantDatabase', () => {
database.close()
})
it('returns the latest 500 remote messages in chronological order', async () => {
const database = await createDatabase()
const project = database.ensureChannelProjects(
'C:\\Users\\test',
channelDefaultProfileId
)[0]!
const conversation = database.getOrCreateRemoteConversation({
projectId: project.id,
channel: 'weixin',
accountId: 'default',
externalConversationId: 'long-remote-history',
conversationType: 'direct',
title: '微信 ClawBot · 长对话',
accountDisplay: '发送者 ****0002',
runtimeSelection: { provider: 'continue' }
})
for (let index = 0; index < 502; index += 1) {
database.appendRemoteConversationMessage({
conversationId: conversation.id,
role: index % 2 === 0 ? 'user' : 'assistant',
content: `消息 ${index}`
})
}
const messages = database.getConversation(conversation.id).messages
expect(messages).toHaveLength(500)
expect(messages[0]?.content).toBe('消息 2')
expect(messages.at(-1)?.content).toBe('消息 501')
database.close()
})
it('persists remote event deduplication and failed reply outbox state', async () => {
const directory = await mkdtemp(
join(tmpdir(), 'goodbuddy-channel-state-')
@@ -1069,6 +1100,7 @@ describe('AssistantDatabase', () => {
provider: 'model',
profileId: '00000000-0000-4000-8000-000000000299'
},
knowledgeRetrievalMode: 'always',
title: '发布讨论',
updatedAt: 1_775_000_000_000,
messages: [
@@ -1140,7 +1172,16 @@ describe('AssistantDatabase', () => {
rank: -0.03,
retrievalChannels: ['fts', 'vector']
}
]
],
knowledgeRetrieval: {
mode: 'always',
state: 'succeeded',
libraryCount: 1,
resultCount: 1,
durationMs: 42,
usedChannels: ['fts', 'vector'],
warnings: []
}
}
]
}
@@ -1154,6 +1195,7 @@ describe('AssistantDatabase', () => {
provider: 'model',
profileId: '00000000-0000-4000-8000-000000000299'
},
knowledgeRetrievalMode: 'always',
messages: [
expect.objectContaining({
role: 'user',
@@ -1202,7 +1244,16 @@ describe('AssistantDatabase', () => {
documentName: '发布说明.md',
retrievalChannels: ['fts', 'vector']
})
]
],
knowledgeRetrieval: {
mode: 'always',
state: 'succeeded',
libraryCount: 1,
resultCount: 1,
durationMs: 42,
usedChannels: ['fts', 'vector'],
warnings: []
}
})
]
})
@@ -1795,6 +1846,14 @@ describe('AssistantDatabase', () => {
expect.objectContaining({ id: secondNote.id, title: '第二篇笔记' })
])
)
expect(database.searchMagicNotes('全局', 5)).toEqual([
expect.objectContaining({
noteId: globalNote.id,
noteTitle: '全局笔记',
content: ''
})
])
expect(database.searchMagicNotes('全局', 5)[0]?.entryId).toBeUndefined()
const withEntry = database.createMagicNoteEntry({
noteId: secondNote.id,
+114 -24
View File
@@ -105,6 +105,7 @@ type ConversationRow = {
id: string
project_id: string | null
runtime_selection_json: string | null
knowledge_retrieval_mode: 'auto' | 'always' | null
title: string
channel: ProjectChannel | null
external_account_id: string | null
@@ -174,6 +175,7 @@ type MessageMetadata = {
tools?: ConversationSnapshot['messages'][number]['tools']
sources?: string[]
sourceReferences?: ConversationSnapshot['messages'][number]['sourceReferences']
knowledgeRetrieval?: ConversationSnapshot['messages'][number]['knowledgeRetrieval']
artifactIds?: string[]
attachments?: ConversationSnapshot['messages'][number]['attachments']
}
@@ -1243,7 +1245,8 @@ export class AssistantDatabase {
const database = this.requireDatabase()
const conversations = database
.prepare(
`SELECT id, project_id, runtime_selection_json, title, channel,
`SELECT id, project_id, runtime_selection_json,
knowledge_retrieval_mode, title, channel,
external_account_id, external_conversation_id,
conversation_type, account_display, updated_at
FROM conversations
@@ -1255,10 +1258,15 @@ export class AssistantDatabase {
const messageStatement = database.prepare(
`SELECT id, conversation_id, role, content, state, metadata_json,
created_at
FROM messages
WHERE conversation_id = ?
ORDER BY sequence ASC
LIMIT 500`
FROM (
SELECT id, conversation_id, role, content, state, metadata_json,
created_at, sequence
FROM messages
WHERE conversation_id = ?
ORDER BY sequence DESC
LIMIT 500
)
ORDER BY sequence ASC`
)
return conversations.map((conversation) => ({
id: conversation.id,
@@ -1266,6 +1274,8 @@ export class AssistantDatabase {
runtimeSelection: parseRuntimeSelection(
conversation.runtime_selection_json
),
knowledgeRetrievalMode:
conversation.knowledge_retrieval_mode ?? undefined,
...(conversation.channel &&
conversation.conversation_type &&
conversation.account_display
@@ -1305,6 +1315,7 @@ export class AssistantDatabase {
: metadata.tools,
sources: metadata.sources,
sourceReferences: metadata.sourceReferences,
knowledgeRetrieval: metadata.knowledgeRetrieval,
artifactIds: metadata.artifactIds,
attachments: metadata.attachments
}
@@ -1422,9 +1433,9 @@ export class AssistantDatabase {
`)
const insertConversation = database.prepare(
`INSERT INTO conversations
(id, project_id, runtime_selection_json, work_mode, title, status,
created_at, updated_at)
VALUES (?, ?, ?, 'ask', ?, 'active', ?, ?)`
(id, project_id, runtime_selection_json, knowledge_retrieval_mode,
work_mode, title, status, created_at, updated_at)
VALUES (?, ?, ?, ?, 'ask', ?, 'active', ?, ?)`
)
const insertMessage = database.prepare(
`INSERT INTO messages
@@ -1443,6 +1454,7 @@ export class AssistantDatabase {
conversation.runtimeSelection
? JSON.stringify(conversation.runtimeSelection)
: null,
conversation.knowledgeRetrievalMode ?? null,
conversation.title,
updatedAt,
updatedAt
@@ -1465,6 +1477,7 @@ export class AssistantDatabase {
tools: message.tools,
sources: message.sources,
sourceReferences: message.sourceReferences,
knowledgeRetrieval: message.knowledgeRetrieval,
artifactIds: message.artifactIds,
attachments: message.attachments
}),
@@ -1863,16 +1876,60 @@ export class AssistantDatabase {
}
}
createMagicNote(input: { title: string }): MagicNoteDetail {
createMagicNote(input: {
title: string
content?: MagicNoteRichContent
}): MagicNoteDetail {
const id = randomUUID()
const now = new Date().toISOString()
this.requireDatabase()
.prepare(
`INSERT INTO magic_notes
(id, project_id, title, pinned, revision, created_at, updated_at)
VALUES (?, ?, ?, 0, 0, ?, ?)`
)
.run(id, null, input.title, now, now)
const database = this.requireDatabase()
const embeddedBytes = input.content
? magicNoteEmbeddedBytes(input.content)
: 0
if (embeddedBytes > MAGIC_NOTE_MAX_NOTE_EMBED_BYTES) {
throw new Error('一篇笔记中的图片、视频和附件总大小不能超过 64 MB')
}
database.exec('BEGIN IMMEDIATE')
try {
database
.prepare(
`INSERT INTO magic_notes
(id, project_id, title, pinned, revision, created_at, updated_at)
VALUES (?, ?, ?, 0, ?, ?, ?)`
)
.run(id, null, input.title, input.content ? 1 : 0, now, now)
if (input.content) {
const entryId = randomUUID()
database
.prepare(
`INSERT INTO magic_note_entries
(id, note_id, content_json, plain_text, comments_json,
actions_json, analyzed_at, revision, created_at, updated_at,
image_bytes)
VALUES (?, ?, ?, ?, '[]', '[]', NULL, 0, ?, ?, ?)`
)
.run(
entryId,
id,
JSON.stringify(input.content),
magicNotePlainText(input.content),
now,
now,
embeddedBytes
)
this.syncMagicNoteTodos(
database,
id,
entryId,
input.content,
now
)
}
database.exec('COMMIT')
} catch (error) {
database.exec('ROLLBACK')
throw error
}
return this.getMagicNote(id)
}
@@ -2135,25 +2192,27 @@ export class AssistantDatabase {
this.requireDatabase()
.prepare(
`SELECT n.id AS note_id, n.title AS note_title,
e.id AS entry_id, e.plain_text, e.updated_at
FROM magic_note_entries e
INNER JOIN magic_notes n ON n.id = e.note_id
e.id AS entry_id, COALESCE(e.plain_text, '') AS plain_text,
COALESCE(e.updated_at, n.updated_at) AS updated_at
FROM magic_notes n
LEFT JOIN magic_note_entries e ON e.note_id = n.id
WHERE n.title LIKE ? ESCAPE '\\'
OR e.plain_text LIKE ? ESCAPE '\\'
ORDER BY e.updated_at DESC, e.rowid DESC
ORDER BY COALESCE(e.updated_at, n.updated_at) DESC,
COALESCE(e.rowid, n.rowid) DESC
LIMIT ?`
)
.all(pattern, pattern, limit) as Array<{
note_id: string
note_title: string
entry_id: string
entry_id: string | null
plain_text: string
updated_at: string
}>
).map((row) => ({
noteId: row.note_id,
noteTitle: row.note_title.slice(0, 100),
entryId: row.entry_id,
entryId: row.entry_id ?? undefined,
content: row.plain_text.slice(0, 12_000),
updatedAt: row.updated_at
}))
@@ -4287,12 +4346,12 @@ export class AssistantDatabase {
const version = database
.prepare('PRAGMA user_version')
.get() as { user_version: number }
if (version.user_version > 17) {
if (version.user_version > 18) {
throw new Error(
` GoodBuddy ${version.user_version}`
)
}
if (version.user_version === 17) {
if (version.user_version === 18) {
return
}
if (version.user_version < 1) {
@@ -4314,6 +4373,11 @@ export class AssistantDatabase {
id TEXT PRIMARY KEY,
project_id TEXT REFERENCES projects(id) ON DELETE SET NULL,
runtime_selection_json TEXT,
knowledge_retrieval_mode TEXT
CHECK(
knowledge_retrieval_mode IS NULL OR
knowledge_retrieval_mode IN ('auto', 'always')
),
work_mode TEXT NOT NULL DEFAULT 'ask'
CHECK(work_mode IN ('ask', 'execute')),
title TEXT NOT NULL,
@@ -5134,6 +5198,32 @@ export class AssistantDatabase {
throw error
}
}
if (version.user_version < 18) {
database.exec('BEGIN IMMEDIATE')
try {
const conversationColumns = new Set(
(
database
.prepare('PRAGMA table_info(conversations)')
.all() as Array<{ name: string }>
).map((column) => column.name)
)
if (!conversationColumns.has('knowledge_retrieval_mode')) {
database.exec(`
ALTER TABLE conversations
ADD COLUMN knowledge_retrieval_mode TEXT
CHECK(
knowledge_retrieval_mode IS NULL OR
knowledge_retrieval_mode IN ('auto', 'always')
);
`)
}
database.exec('PRAGMA user_version = 18; COMMIT;')
} catch (error) {
database.exec('ROLLBACK')
throw error
}
}
}
private requireDatabase(): DatabaseSync {
@@ -100,7 +100,7 @@ describe('AssistantDatabase heartbeat persistence', () => {
).count
check.close()
migrated.close()
expect(version).toBe(17)
expect(version).toBe(18)
expect(heartbeatTableCount).toBe(3)
})
+1 -1
View File
@@ -446,7 +446,7 @@ export type MagicNoteDraftAnalysis = {
export type MagicNoteSearchResult = {
noteId: string
noteTitle: string
entryId: string
entryId?: string
content: string
updatedAt: string
}