fix: preserve conversation and note data
This commit is contained in:
@@ -98,7 +98,7 @@ describe('AssistantDatabase', () => {
|
|||||||
database.close()
|
database.close()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('migrates existing databases to schema version 17', async () => {
|
it('migrates existing databases to schema version 18', async () => {
|
||||||
const directory = await mkdtemp(
|
const directory = await mkdtemp(
|
||||||
join(tmpdir(), 'goodbuddy-assistant-migration-')
|
join(tmpdir(), 'goodbuddy-assistant-migration-')
|
||||||
)
|
)
|
||||||
@@ -127,7 +127,7 @@ describe('AssistantDatabase', () => {
|
|||||||
user_version: number
|
user_version: number
|
||||||
}
|
}
|
||||||
).user_version
|
).user_version
|
||||||
).toBe(17)
|
).toBe(18)
|
||||||
expect(
|
expect(
|
||||||
current
|
current
|
||||||
.prepare(
|
.prepare(
|
||||||
@@ -231,7 +231,7 @@ describe('AssistantDatabase', () => {
|
|||||||
user_version: number
|
user_version: number
|
||||||
}
|
}
|
||||||
).user_version
|
).user_version
|
||||||
).toBe(17)
|
).toBe(18)
|
||||||
expect(
|
expect(
|
||||||
current
|
current
|
||||||
.prepare(
|
.prepare(
|
||||||
@@ -591,6 +591,37 @@ describe('AssistantDatabase', () => {
|
|||||||
database.close()
|
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 () => {
|
it('persists remote event deduplication and failed reply outbox state', async () => {
|
||||||
const directory = await mkdtemp(
|
const directory = await mkdtemp(
|
||||||
join(tmpdir(), 'goodbuddy-channel-state-')
|
join(tmpdir(), 'goodbuddy-channel-state-')
|
||||||
@@ -1069,6 +1100,7 @@ describe('AssistantDatabase', () => {
|
|||||||
provider: 'model',
|
provider: 'model',
|
||||||
profileId: '00000000-0000-4000-8000-000000000299'
|
profileId: '00000000-0000-4000-8000-000000000299'
|
||||||
},
|
},
|
||||||
|
knowledgeRetrievalMode: 'always',
|
||||||
title: '发布讨论',
|
title: '发布讨论',
|
||||||
updatedAt: 1_775_000_000_000,
|
updatedAt: 1_775_000_000_000,
|
||||||
messages: [
|
messages: [
|
||||||
@@ -1140,7 +1172,16 @@ describe('AssistantDatabase', () => {
|
|||||||
rank: -0.03,
|
rank: -0.03,
|
||||||
retrievalChannels: ['fts', 'vector']
|
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',
|
provider: 'model',
|
||||||
profileId: '00000000-0000-4000-8000-000000000299'
|
profileId: '00000000-0000-4000-8000-000000000299'
|
||||||
},
|
},
|
||||||
|
knowledgeRetrievalMode: 'always',
|
||||||
messages: [
|
messages: [
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
role: 'user',
|
role: 'user',
|
||||||
@@ -1202,7 +1244,16 @@ describe('AssistantDatabase', () => {
|
|||||||
documentName: '发布说明.md',
|
documentName: '发布说明.md',
|
||||||
retrievalChannels: ['fts', 'vector']
|
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.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({
|
const withEntry = database.createMagicNoteEntry({
|
||||||
noteId: secondNote.id,
|
noteId: secondNote.id,
|
||||||
|
|||||||
@@ -105,6 +105,7 @@ type ConversationRow = {
|
|||||||
id: string
|
id: string
|
||||||
project_id: string | null
|
project_id: string | null
|
||||||
runtime_selection_json: string | null
|
runtime_selection_json: string | null
|
||||||
|
knowledge_retrieval_mode: 'auto' | 'always' | null
|
||||||
title: string
|
title: string
|
||||||
channel: ProjectChannel | null
|
channel: ProjectChannel | null
|
||||||
external_account_id: string | null
|
external_account_id: string | null
|
||||||
@@ -174,6 +175,7 @@ type MessageMetadata = {
|
|||||||
tools?: ConversationSnapshot['messages'][number]['tools']
|
tools?: ConversationSnapshot['messages'][number]['tools']
|
||||||
sources?: string[]
|
sources?: string[]
|
||||||
sourceReferences?: ConversationSnapshot['messages'][number]['sourceReferences']
|
sourceReferences?: ConversationSnapshot['messages'][number]['sourceReferences']
|
||||||
|
knowledgeRetrieval?: ConversationSnapshot['messages'][number]['knowledgeRetrieval']
|
||||||
artifactIds?: string[]
|
artifactIds?: string[]
|
||||||
attachments?: ConversationSnapshot['messages'][number]['attachments']
|
attachments?: ConversationSnapshot['messages'][number]['attachments']
|
||||||
}
|
}
|
||||||
@@ -1243,7 +1245,8 @@ export class AssistantDatabase {
|
|||||||
const database = this.requireDatabase()
|
const database = this.requireDatabase()
|
||||||
const conversations = database
|
const conversations = database
|
||||||
.prepare(
|
.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,
|
external_account_id, external_conversation_id,
|
||||||
conversation_type, account_display, updated_at
|
conversation_type, account_display, updated_at
|
||||||
FROM conversations
|
FROM conversations
|
||||||
@@ -1255,10 +1258,15 @@ export class AssistantDatabase {
|
|||||||
const messageStatement = database.prepare(
|
const messageStatement = database.prepare(
|
||||||
`SELECT id, conversation_id, role, content, state, metadata_json,
|
`SELECT id, conversation_id, role, content, state, metadata_json,
|
||||||
created_at
|
created_at
|
||||||
FROM messages
|
FROM (
|
||||||
WHERE conversation_id = ?
|
SELECT id, conversation_id, role, content, state, metadata_json,
|
||||||
ORDER BY sequence ASC
|
created_at, sequence
|
||||||
LIMIT 500`
|
FROM messages
|
||||||
|
WHERE conversation_id = ?
|
||||||
|
ORDER BY sequence DESC
|
||||||
|
LIMIT 500
|
||||||
|
)
|
||||||
|
ORDER BY sequence ASC`
|
||||||
)
|
)
|
||||||
return conversations.map((conversation) => ({
|
return conversations.map((conversation) => ({
|
||||||
id: conversation.id,
|
id: conversation.id,
|
||||||
@@ -1266,6 +1274,8 @@ export class AssistantDatabase {
|
|||||||
runtimeSelection: parseRuntimeSelection(
|
runtimeSelection: parseRuntimeSelection(
|
||||||
conversation.runtime_selection_json
|
conversation.runtime_selection_json
|
||||||
),
|
),
|
||||||
|
knowledgeRetrievalMode:
|
||||||
|
conversation.knowledge_retrieval_mode ?? undefined,
|
||||||
...(conversation.channel &&
|
...(conversation.channel &&
|
||||||
conversation.conversation_type &&
|
conversation.conversation_type &&
|
||||||
conversation.account_display
|
conversation.account_display
|
||||||
@@ -1305,6 +1315,7 @@ export class AssistantDatabase {
|
|||||||
: metadata.tools,
|
: metadata.tools,
|
||||||
sources: metadata.sources,
|
sources: metadata.sources,
|
||||||
sourceReferences: metadata.sourceReferences,
|
sourceReferences: metadata.sourceReferences,
|
||||||
|
knowledgeRetrieval: metadata.knowledgeRetrieval,
|
||||||
artifactIds: metadata.artifactIds,
|
artifactIds: metadata.artifactIds,
|
||||||
attachments: metadata.attachments
|
attachments: metadata.attachments
|
||||||
}
|
}
|
||||||
@@ -1422,9 +1433,9 @@ export class AssistantDatabase {
|
|||||||
`)
|
`)
|
||||||
const insertConversation = database.prepare(
|
const insertConversation = database.prepare(
|
||||||
`INSERT INTO conversations
|
`INSERT INTO conversations
|
||||||
(id, project_id, runtime_selection_json, work_mode, title, status,
|
(id, project_id, runtime_selection_json, knowledge_retrieval_mode,
|
||||||
created_at, updated_at)
|
work_mode, title, status, created_at, updated_at)
|
||||||
VALUES (?, ?, ?, 'ask', ?, 'active', ?, ?)`
|
VALUES (?, ?, ?, ?, 'ask', ?, 'active', ?, ?)`
|
||||||
)
|
)
|
||||||
const insertMessage = database.prepare(
|
const insertMessage = database.prepare(
|
||||||
`INSERT INTO messages
|
`INSERT INTO messages
|
||||||
@@ -1443,6 +1454,7 @@ export class AssistantDatabase {
|
|||||||
conversation.runtimeSelection
|
conversation.runtimeSelection
|
||||||
? JSON.stringify(conversation.runtimeSelection)
|
? JSON.stringify(conversation.runtimeSelection)
|
||||||
: null,
|
: null,
|
||||||
|
conversation.knowledgeRetrievalMode ?? null,
|
||||||
conversation.title,
|
conversation.title,
|
||||||
updatedAt,
|
updatedAt,
|
||||||
updatedAt
|
updatedAt
|
||||||
@@ -1465,6 +1477,7 @@ export class AssistantDatabase {
|
|||||||
tools: message.tools,
|
tools: message.tools,
|
||||||
sources: message.sources,
|
sources: message.sources,
|
||||||
sourceReferences: message.sourceReferences,
|
sourceReferences: message.sourceReferences,
|
||||||
|
knowledgeRetrieval: message.knowledgeRetrieval,
|
||||||
artifactIds: message.artifactIds,
|
artifactIds: message.artifactIds,
|
||||||
attachments: message.attachments
|
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 id = randomUUID()
|
||||||
const now = new Date().toISOString()
|
const now = new Date().toISOString()
|
||||||
this.requireDatabase()
|
const database = this.requireDatabase()
|
||||||
.prepare(
|
const embeddedBytes = input.content
|
||||||
`INSERT INTO magic_notes
|
? magicNoteEmbeddedBytes(input.content)
|
||||||
(id, project_id, title, pinned, revision, created_at, updated_at)
|
: 0
|
||||||
VALUES (?, ?, ?, 0, 0, ?, ?)`
|
if (embeddedBytes > MAGIC_NOTE_MAX_NOTE_EMBED_BYTES) {
|
||||||
)
|
throw new Error('一篇笔记中的图片、视频和附件总大小不能超过 64 MB')
|
||||||
.run(id, null, input.title, now, now)
|
}
|
||||||
|
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)
|
return this.getMagicNote(id)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2135,25 +2192,27 @@ export class AssistantDatabase {
|
|||||||
this.requireDatabase()
|
this.requireDatabase()
|
||||||
.prepare(
|
.prepare(
|
||||||
`SELECT n.id AS note_id, n.title AS note_title,
|
`SELECT n.id AS note_id, n.title AS note_title,
|
||||||
e.id AS entry_id, e.plain_text, e.updated_at
|
e.id AS entry_id, COALESCE(e.plain_text, '') AS plain_text,
|
||||||
FROM magic_note_entries e
|
COALESCE(e.updated_at, n.updated_at) AS updated_at
|
||||||
INNER JOIN magic_notes n ON n.id = e.note_id
|
FROM magic_notes n
|
||||||
|
LEFT JOIN magic_note_entries e ON e.note_id = n.id
|
||||||
WHERE n.title LIKE ? ESCAPE '\\'
|
WHERE n.title LIKE ? ESCAPE '\\'
|
||||||
OR e.plain_text 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 ?`
|
LIMIT ?`
|
||||||
)
|
)
|
||||||
.all(pattern, pattern, limit) as Array<{
|
.all(pattern, pattern, limit) as Array<{
|
||||||
note_id: string
|
note_id: string
|
||||||
note_title: string
|
note_title: string
|
||||||
entry_id: string
|
entry_id: string | null
|
||||||
plain_text: string
|
plain_text: string
|
||||||
updated_at: string
|
updated_at: string
|
||||||
}>
|
}>
|
||||||
).map((row) => ({
|
).map((row) => ({
|
||||||
noteId: row.note_id,
|
noteId: row.note_id,
|
||||||
noteTitle: row.note_title.slice(0, 100),
|
noteTitle: row.note_title.slice(0, 100),
|
||||||
entryId: row.entry_id,
|
entryId: row.entry_id ?? undefined,
|
||||||
content: row.plain_text.slice(0, 12_000),
|
content: row.plain_text.slice(0, 12_000),
|
||||||
updatedAt: row.updated_at
|
updatedAt: row.updated_at
|
||||||
}))
|
}))
|
||||||
@@ -4287,12 +4346,12 @@ export class AssistantDatabase {
|
|||||||
const version = database
|
const version = database
|
||||||
.prepare('PRAGMA user_version')
|
.prepare('PRAGMA user_version')
|
||||||
.get() as { user_version: number }
|
.get() as { user_version: number }
|
||||||
if (version.user_version > 17) {
|
if (version.user_version > 18) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`当前 GoodBuddy 不支持助理数据库版本 ${version.user_version},请升级应用后重试`
|
`当前 GoodBuddy 不支持助理数据库版本 ${version.user_version},请升级应用后重试`
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
if (version.user_version === 17) {
|
if (version.user_version === 18) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (version.user_version < 1) {
|
if (version.user_version < 1) {
|
||||||
@@ -4314,6 +4373,11 @@ export class AssistantDatabase {
|
|||||||
id TEXT PRIMARY KEY,
|
id TEXT PRIMARY KEY,
|
||||||
project_id TEXT REFERENCES projects(id) ON DELETE SET NULL,
|
project_id TEXT REFERENCES projects(id) ON DELETE SET NULL,
|
||||||
runtime_selection_json TEXT,
|
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'
|
work_mode TEXT NOT NULL DEFAULT 'ask'
|
||||||
CHECK(work_mode IN ('ask', 'execute')),
|
CHECK(work_mode IN ('ask', 'execute')),
|
||||||
title TEXT NOT NULL,
|
title TEXT NOT NULL,
|
||||||
@@ -5134,6 +5198,32 @@ export class AssistantDatabase {
|
|||||||
throw error
|
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 {
|
private requireDatabase(): DatabaseSync {
|
||||||
|
|||||||
@@ -100,7 +100,7 @@ describe('AssistantDatabase heartbeat persistence', () => {
|
|||||||
).count
|
).count
|
||||||
check.close()
|
check.close()
|
||||||
migrated.close()
|
migrated.close()
|
||||||
expect(version).toBe(17)
|
expect(version).toBe(18)
|
||||||
expect(heartbeatTableCount).toBe(3)
|
expect(heartbeatTableCount).toBe(3)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -446,7 +446,7 @@ export type MagicNoteDraftAnalysis = {
|
|||||||
export type MagicNoteSearchResult = {
|
export type MagicNoteSearchResult = {
|
||||||
noteId: string
|
noteId: string
|
||||||
noteTitle: string
|
noteTitle: string
|
||||||
entryId: string
|
entryId?: string
|
||||||
content: string
|
content: string
|
||||||
updatedAt: string
|
updatedAt: string
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user