mirror of
https://github.com/hellodigua/ChatLab.git
synced 2026-10-25 17:54:34 +08:00
refactor(ai): replace branching with edit-and-regenerate model
Remove the message branching/tree system in favor of a simpler editing model: - Default: edit a message and only regenerate the current round's AI response - Optional: overwrite all subsequent messages when editing Key changes: - Backend: add updateMessageContent, deleteAndRelinkMessage, insertMessageAfter, deleteMessagesFrom, forkConversation; remove createMessageBranch, switchMessageBranch - Platform layer: add IPC/HTTP handlers for new methods across Electron and CLI - Frontend store: refactor editMessageAndRegenerate with two modes - UI: replace branch navigation with "overwrite subsequent" checkbox, auto-resizing edit textarea - Fix insertMessageAfter missing debug_context from pendingDebugContextMap - Update tests to cover new editing methods Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
1 parent
d3c62c4f9d
commit
6224ca996e
22 files changed
+987
-720
No files matched your search
@@ -30,7 +30,7 @@ function cleanup(dir: string): void {
|
||||
}
|
||||
}
|
||||
|
||||
describe('AIConversationManager message branches', () => {
|
||||
describe('AIConversationManager legacy migration', () => {
|
||||
it('migrates legacy flat messages into an active path', () => {
|
||||
const dir = createTempDir()
|
||||
try {
|
||||
@@ -86,117 +86,296 @@ describe('AIConversationManager message branches', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('does not rerun legacy backfill over existing root branches', () => {
|
||||
it('repairs partially migrated legacy rows that already have tree columns', () => {
|
||||
const dir = createTempDir()
|
||||
try {
|
||||
let manager = createManager(dir)
|
||||
const conversation = manager.createConversation('session-1', 'Root branch', 'general_cn')
|
||||
const root = manager.addMessage(conversation.id, 'user', 'original root')
|
||||
manager.addMessage(conversation.id, 'assistant', 'original answer')
|
||||
const branch = manager.createMessageBranch(root.id, 'edited root', 'edited answer')
|
||||
assert.equal(branch.userMessage.parentId, null)
|
||||
manager.close()
|
||||
const db = createTestDatabase(join(dir, 'conversations.db'))
|
||||
db.exec(`
|
||||
CREATE TABLE ai_conversation (
|
||||
id TEXT PRIMARY KEY,
|
||||
session_id TEXT NOT NULL,
|
||||
title TEXT,
|
||||
assistant_id TEXT DEFAULT 'general_cn',
|
||||
active_message_id TEXT,
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
);
|
||||
CREATE TABLE ai_message (
|
||||
id TEXT PRIMARY KEY,
|
||||
conversation_id TEXT NOT NULL,
|
||||
role TEXT NOT NULL,
|
||||
content TEXT NOT NULL,
|
||||
timestamp INTEGER NOT NULL,
|
||||
data_keywords TEXT,
|
||||
data_message_count INTEGER,
|
||||
content_blocks TEXT,
|
||||
token_usage TEXT,
|
||||
debug_context TEXT,
|
||||
parent_id TEXT,
|
||||
sibling_group_id TEXT,
|
||||
branch_index INTEGER DEFAULT 0
|
||||
);
|
||||
`)
|
||||
db.prepare('INSERT INTO ai_conversation VALUES (?, ?, ?, ?, NULL, ?, ?)').run(
|
||||
'conv-partial',
|
||||
'session-1',
|
||||
'Partial',
|
||||
'general_cn',
|
||||
1,
|
||||
3
|
||||
)
|
||||
db.prepare('INSERT INTO ai_message VALUES (?, ?, ?, ?, ?, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL)').run(
|
||||
'pm1',
|
||||
'conv-partial',
|
||||
'user',
|
||||
'first',
|
||||
1
|
||||
)
|
||||
db.prepare('INSERT INTO ai_message VALUES (?, ?, ?, ?, ?, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL)').run(
|
||||
'pm2',
|
||||
'conv-partial',
|
||||
'assistant',
|
||||
'second',
|
||||
2
|
||||
)
|
||||
db.close()
|
||||
|
||||
manager = createManager(dir)
|
||||
const messages = manager.getMessages(conversation.id)
|
||||
const manager = createManager(dir)
|
||||
const messages = manager.getMessages('conv-partial')
|
||||
assert.deepEqual(
|
||||
messages.map((message) => message.content),
|
||||
['edited root', 'edited answer']
|
||||
['first', 'second']
|
||||
)
|
||||
assert.equal(messages[0]?.parentId, null)
|
||||
assert.equal(manager.getConversation(conversation.id)?.activeMessageId, branch.assistantMessage.id)
|
||||
assert.equal(messages[1]?.parentId, 'pm1')
|
||||
assert.equal(manager.getConversation('conv-partial')?.activeMessageId, 'pm2')
|
||||
manager.close()
|
||||
} finally {
|
||||
cleanup(dir)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
it('creates editable branches without losing the original path', () => {
|
||||
describe('AIConversationManager message editing', () => {
|
||||
it('updateMessageContent updates message text in place', () => {
|
||||
const dir = createTempDir()
|
||||
try {
|
||||
const manager = createManager(dir)
|
||||
const conversation = manager.createConversation('session-1', 'Branch', 'general_cn')
|
||||
const user1 = manager.addMessage(conversation.id, 'user', 'original question')
|
||||
manager.addMessage(conversation.id, 'assistant', 'original answer', undefined, undefined, undefined, {
|
||||
promptTokens: 1,
|
||||
completionTokens: 2,
|
||||
totalTokens: 3,
|
||||
})
|
||||
manager.addMessage(conversation.id, 'user', 'follow up')
|
||||
const oldLeaf = manager.addMessage(
|
||||
conversation.id,
|
||||
'assistant',
|
||||
'follow answer',
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
{
|
||||
promptTokens: 4,
|
||||
completionTokens: 5,
|
||||
totalTokens: 9,
|
||||
}
|
||||
const conv = manager.createConversation('s1', 'Test', 'general_cn')
|
||||
const msg = manager.addMessage(conv.id, 'user', 'original text')
|
||||
manager.addMessage(conv.id, 'assistant', 'reply')
|
||||
|
||||
manager.updateMessageContent(msg.id, 'edited text')
|
||||
|
||||
const messages = manager.getMessages(conv.id)
|
||||
assert.equal(messages[0]?.content, 'edited text')
|
||||
assert.equal(messages[1]?.content, 'reply')
|
||||
manager.close()
|
||||
} finally {
|
||||
cleanup(dir)
|
||||
}
|
||||
})
|
||||
|
||||
it('updateMessageContent throws for non-existent message', () => {
|
||||
const dir = createTempDir()
|
||||
try {
|
||||
const manager = createManager(dir)
|
||||
assert.throws(() => manager.updateMessageContent('non-existent', 'text'), /Message not found/)
|
||||
manager.close()
|
||||
} finally {
|
||||
cleanup(dir)
|
||||
}
|
||||
})
|
||||
|
||||
it('deleteAndRelinkMessage removes a message and rewires children', () => {
|
||||
const dir = createTempDir()
|
||||
try {
|
||||
const manager = createManager(dir)
|
||||
const conv = manager.createConversation('s1', 'Test', 'general_cn')
|
||||
const userMsg = manager.addMessage(conv.id, 'user', 'question')
|
||||
const aiMsg = manager.addMessage(conv.id, 'assistant', 'answer')
|
||||
const followUp = manager.addMessage(conv.id, 'user', 'follow up')
|
||||
manager.addMessage(conv.id, 'assistant', 'follow answer')
|
||||
|
||||
manager.deleteAndRelinkMessage(conv.id, aiMsg.id)
|
||||
|
||||
const messages = manager.getMessages(conv.id)
|
||||
assert.equal(messages.length, 3)
|
||||
assert.deepEqual(
|
||||
messages.map((m) => m.content),
|
||||
['question', 'follow up', 'follow answer']
|
||||
)
|
||||
// follow up's parent should now be userMsg (was aiMsg)
|
||||
assert.equal(messages[1]?.parentId, userMsg.id)
|
||||
assert.equal(messages[2]?.parentId, followUp.id)
|
||||
manager.close()
|
||||
} finally {
|
||||
cleanup(dir)
|
||||
}
|
||||
})
|
||||
|
||||
it('deleteAndRelinkMessage updates activeMessageId when removing the active leaf', () => {
|
||||
const dir = createTempDir()
|
||||
try {
|
||||
const manager = createManager(dir)
|
||||
const conv = manager.createConversation('s1', 'Test', 'general_cn')
|
||||
const userMsg = manager.addMessage(conv.id, 'user', 'question')
|
||||
const aiMsg = manager.addMessage(conv.id, 'assistant', 'answer')
|
||||
assert.equal(manager.getConversation(conv.id)?.activeMessageId, aiMsg.id)
|
||||
|
||||
manager.deleteAndRelinkMessage(conv.id, aiMsg.id)
|
||||
|
||||
assert.equal(manager.getConversation(conv.id)?.activeMessageId, userMsg.id)
|
||||
const messages = manager.getMessages(conv.id)
|
||||
assert.equal(messages.length, 1)
|
||||
assert.equal(messages[0]?.content, 'question')
|
||||
manager.close()
|
||||
} finally {
|
||||
cleanup(dir)
|
||||
}
|
||||
})
|
||||
|
||||
const branch = manager.createMessageBranch('' + user1.id, 'edited question', 'edited answer', undefined, {
|
||||
promptTokens: 10,
|
||||
completionTokens: 11,
|
||||
totalTokens: 21,
|
||||
})
|
||||
it('insertMessageAfter inserts a message in the middle of a chain', () => {
|
||||
const dir = createTempDir()
|
||||
try {
|
||||
const manager = createManager(dir)
|
||||
const conv = manager.createConversation('s1', 'Test', 'general_cn')
|
||||
const userMsg = manager.addMessage(conv.id, 'user', 'question')
|
||||
const followUp = manager.addMessage(conv.id, 'user', 'follow up')
|
||||
manager.addMessage(conv.id, 'assistant', 'follow answer')
|
||||
|
||||
const inserted = manager.insertMessageAfter(conv.id, userMsg.id, 'assistant', 'inserted answer')
|
||||
|
||||
const messages = manager.getMessages(conv.id)
|
||||
assert.equal(messages.length, 4)
|
||||
assert.deepEqual(
|
||||
manager.getMessages(conversation.id).map((message) => message.content),
|
||||
['edited question', 'edited answer']
|
||||
messages.map((m) => m.content),
|
||||
['question', 'inserted answer', 'follow up', 'follow answer']
|
||||
)
|
||||
assert.equal(branch.userMessage.branch?.total, 2)
|
||||
assert.equal(manager.getHistoryForAgent(conversation.id, undefined, branch.userMessage.parentId).length, 0)
|
||||
assert.deepEqual(manager.getConversationTokenUsage(conversation.id), {
|
||||
promptTokens: 10,
|
||||
completionTokens: 11,
|
||||
totalTokens: 21,
|
||||
})
|
||||
|
||||
manager.switchMessageBranch(conversation.id, user1.id)
|
||||
assert.equal(inserted.parentId, userMsg.id)
|
||||
// followUp's parent should be updated to the inserted message
|
||||
assert.equal(messages[2]?.parentId, inserted.id)
|
||||
manager.close()
|
||||
} finally {
|
||||
cleanup(dir)
|
||||
}
|
||||
})
|
||||
|
||||
it('insertMessageAfter appends to the end when no child exists', () => {
|
||||
const dir = createTempDir()
|
||||
try {
|
||||
const manager = createManager(dir)
|
||||
const conv = manager.createConversation('s1', 'Test', 'general_cn')
|
||||
const userMsg = manager.addMessage(conv.id, 'user', 'question')
|
||||
|
||||
const inserted = manager.insertMessageAfter(conv.id, userMsg.id, 'assistant', 'new answer')
|
||||
|
||||
const messages = manager.getMessages(conv.id)
|
||||
assert.equal(messages.length, 2)
|
||||
assert.deepEqual(
|
||||
manager.getMessages(conversation.id).map((message) => message.content),
|
||||
['original question', 'original answer', 'follow up', 'follow answer']
|
||||
messages.map((m) => m.content),
|
||||
['question', 'new answer']
|
||||
)
|
||||
assert.equal(manager.getConversation(conversation.id)?.activeMessageId, oldLeaf.id)
|
||||
assert.deepEqual(manager.getConversationTokenUsage(conversation.id), {
|
||||
promptTokens: 5,
|
||||
completionTokens: 7,
|
||||
totalTokens: 12,
|
||||
})
|
||||
assert.equal(inserted.parentId, userMsg.id)
|
||||
manager.close()
|
||||
} finally {
|
||||
cleanup(dir)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps summaries on inactive branches when adding a new summary', () => {
|
||||
describe('AIConversationManager deleteMessagesFrom', () => {
|
||||
it('deletes the target message and all subsequent messages', () => {
|
||||
const dir = createTempDir()
|
||||
try {
|
||||
const manager = createManager(dir)
|
||||
const conversation = manager.createConversation('session-1', 'Summary branch', 'general_cn')
|
||||
manager.addMessage(conversation.id, 'user', 'intro')
|
||||
manager.addMessage(conversation.id, 'assistant', 'answer')
|
||||
manager.addSummaryMessage(conversation.id, 'original summary', {
|
||||
bufferBoundaryTimestamp: 2,
|
||||
compressedMessageCount: 2,
|
||||
})
|
||||
const followUp = manager.addMessage(conversation.id, 'user', 'follow up')
|
||||
manager.addMessage(conversation.id, 'assistant', 'follow answer')
|
||||
|
||||
manager.createMessageBranch(followUp.id, 'edited follow up', 'edited follow answer')
|
||||
manager.addSummaryMessage(conversation.id, 'edited summary', {
|
||||
bufferBoundaryTimestamp: 4,
|
||||
compressedMessageCount: 2,
|
||||
})
|
||||
manager.switchMessageBranch(conversation.id, followUp.id)
|
||||
const conv = manager.createConversation('s1', 'Test', 'general_cn')
|
||||
const user1 = manager.addMessage(conv.id, 'user', 'q1')
|
||||
manager.addMessage(conv.id, 'assistant', 'a1')
|
||||
manager.addMessage(conv.id, 'user', 'q2')
|
||||
manager.addMessage(conv.id, 'assistant', 'a2')
|
||||
|
||||
manager.deleteMessagesFrom(conv.id, user1.id)
|
||||
|
||||
const messages = manager.getMessages(conv.id)
|
||||
assert.equal(messages.length, 0)
|
||||
assert.equal(manager.getConversation(conv.id)?.activeMessageId, null)
|
||||
manager.close()
|
||||
} finally {
|
||||
cleanup(dir)
|
||||
}
|
||||
})
|
||||
|
||||
it('deletes from a mid-chain message, preserving earlier ones', () => {
|
||||
const dir = createTempDir()
|
||||
try {
|
||||
const manager = createManager(dir)
|
||||
const conv = manager.createConversation('s1', 'Test', 'general_cn')
|
||||
const user1 = manager.addMessage(conv.id, 'user', 'q1')
|
||||
const ai1 = manager.addMessage(conv.id, 'assistant', 'a1')
|
||||
manager.addMessage(conv.id, 'user', 'q2')
|
||||
manager.addMessage(conv.id, 'assistant', 'a2')
|
||||
|
||||
manager.deleteMessagesFrom(conv.id, ai1.id)
|
||||
|
||||
const messages = manager.getMessages(conv.id)
|
||||
assert.equal(messages.length, 1)
|
||||
assert.equal(messages[0]?.content, 'q1')
|
||||
assert.equal(manager.getConversation(conv.id)?.activeMessageId, user1.id)
|
||||
manager.close()
|
||||
} finally {
|
||||
cleanup(dir)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('AIConversationManager forkConversation', () => {
|
||||
it('creates a new conversation with copied messages up to the specified point', () => {
|
||||
const dir = createTempDir()
|
||||
try {
|
||||
const manager = createManager(dir)
|
||||
const conv = manager.createConversation('s1', 'Original', 'general_cn')
|
||||
manager.addMessage(conv.id, 'user', 'q1')
|
||||
manager.addMessage(conv.id, 'assistant', 'a1')
|
||||
const q2 = manager.addMessage(conv.id, 'user', 'q2')
|
||||
manager.addMessage(conv.id, 'assistant', 'a2')
|
||||
|
||||
const forked = manager.forkConversation(conv.id, q2.id, 'Forked')
|
||||
|
||||
assert.notEqual(forked.id, conv.id)
|
||||
assert.equal(forked.title, 'Forked')
|
||||
assert.equal(forked.sessionId, 's1')
|
||||
|
||||
const forkedMessages = manager.getMessages(forked.id)
|
||||
assert.equal(forkedMessages.length, 2)
|
||||
assert.deepEqual(
|
||||
manager.getMessages(conversation.id).map((message) => message.content),
|
||||
['intro', 'answer', 'original summary', 'follow up', 'follow answer']
|
||||
forkedMessages.map((m) => m.content),
|
||||
['q1', 'a1']
|
||||
)
|
||||
|
||||
// Original conversation should be untouched
|
||||
const originalMessages = manager.getMessages(conv.id)
|
||||
assert.equal(originalMessages.length, 4)
|
||||
manager.close()
|
||||
} finally {
|
||||
cleanup(dir)
|
||||
}
|
||||
})
|
||||
|
||||
it('uses default fork title when none provided', () => {
|
||||
const dir = createTempDir()
|
||||
try {
|
||||
const manager = createManager(dir)
|
||||
const conv = manager.createConversation('s1', 'MyChat', 'general_cn')
|
||||
manager.addMessage(conv.id, 'user', 'q1')
|
||||
const a1 = manager.addMessage(conv.id, 'assistant', 'a1')
|
||||
|
||||
const forked = manager.forkConversation(conv.id, a1.id)
|
||||
|
||||
assert.equal(forked.title, 'MyChat (fork)')
|
||||
const forkedMessages = manager.getMessages(forked.id)
|
||||
assert.equal(forkedMessages.length, 1)
|
||||
assert.equal(forkedMessages[0]?.content, 'q1')
|
||||
manager.close()
|
||||
} finally {
|
||||
cleanup(dir)
|
||||
|
||||
@@ -56,14 +56,6 @@ export interface AIMessage {
|
||||
content: string
|
||||
timestamp: number
|
||||
parentId?: string | null
|
||||
siblingGroupId?: string
|
||||
branchIndex?: number
|
||||
branch?: {
|
||||
index: number
|
||||
total: number
|
||||
prevMessageId: string | null
|
||||
nextMessageId: string | null
|
||||
}
|
||||
dataKeywords?: string[]
|
||||
dataMessageCount?: number
|
||||
contentBlocks?: ContentBlock[]
|
||||
@@ -85,11 +77,6 @@ interface AIMessageRow {
|
||||
tokenUsage: string | null
|
||||
}
|
||||
|
||||
export interface MessageBranchResult {
|
||||
userMessage: AIMessage
|
||||
assistantMessage: AIMessage
|
||||
}
|
||||
|
||||
export interface ConversationManagerLogger {
|
||||
warn(category: string, message: string, extra?: Record<string, unknown>): void
|
||||
}
|
||||
@@ -202,7 +189,7 @@ export class AIConversationManager {
|
||||
db.exec('ALTER TABLE ai_conversation ADD COLUMN active_message_id TEXT')
|
||||
}
|
||||
|
||||
if (needsMessageTreeBackfill || needsConversationBackfill) {
|
||||
if (needsMessageTreeBackfill || needsConversationBackfill || this.hasUnbackfilledMessageTree(db)) {
|
||||
this.backfillMessageTree(db)
|
||||
}
|
||||
|
||||
@@ -215,6 +202,29 @@ export class AIConversationManager {
|
||||
}
|
||||
}
|
||||
|
||||
private hasUnbackfilledMessageTree(db: Database.Database): boolean {
|
||||
const row = db
|
||||
.prepare(
|
||||
`SELECT 1
|
||||
FROM ai_conversation c
|
||||
WHERE EXISTS (
|
||||
SELECT 1 FROM ai_message m
|
||||
WHERE m.conversation_id = c.id
|
||||
)
|
||||
AND (
|
||||
c.active_message_id IS NULL
|
||||
OR EXISTS (
|
||||
SELECT 1 FROM ai_message m
|
||||
WHERE m.conversation_id = c.id
|
||||
AND (m.sibling_group_id IS NULL OR m.branch_index IS NULL)
|
||||
)
|
||||
)
|
||||
LIMIT 1`
|
||||
)
|
||||
.get()
|
||||
return !!row
|
||||
}
|
||||
|
||||
private backfillMessageTree(db: Database.Database): void {
|
||||
const conversations = db.prepare('SELECT id FROM ai_conversation').all() as Array<{ id: string }>
|
||||
const updateMessage = db.prepare(
|
||||
@@ -254,7 +264,7 @@ export class AIConversationManager {
|
||||
return `${prefix}_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`
|
||||
}
|
||||
|
||||
private parseMessageRow(row: AIMessageRow, branch?: AIMessage['branch']): AIMessage {
|
||||
private parseMessageRow(row: AIMessageRow): AIMessage {
|
||||
return {
|
||||
id: row.id,
|
||||
conversationId: row.conversationId,
|
||||
@@ -262,9 +272,6 @@ export class AIConversationManager {
|
||||
content: row.content,
|
||||
timestamp: row.timestamp,
|
||||
parentId: row.parentId ?? null,
|
||||
siblingGroupId: row.siblingGroupId ?? row.id,
|
||||
branchIndex: row.branchIndex ?? 0,
|
||||
branch,
|
||||
dataKeywords: row.dataKeywords ? JSON.parse(row.dataKeywords) : undefined,
|
||||
dataMessageCount: row.dataMessageCount ?? undefined,
|
||||
contentBlocks: row.contentBlocks ? JSON.parse(row.contentBlocks) : undefined,
|
||||
@@ -306,77 +313,38 @@ export class AIConversationManager {
|
||||
return null
|
||||
}
|
||||
|
||||
private getAllMessageRows(conversationId: string): AIMessageRow[] {
|
||||
return this.getDb()
|
||||
.prepare(
|
||||
`SELECT id, conversation_id as conversationId, role, content, timestamp,
|
||||
parent_id as parentId, sibling_group_id as siblingGroupId, branch_index as branchIndex,
|
||||
data_keywords as dataKeywords, data_message_count as dataMessageCount,
|
||||
content_blocks as contentBlocks, token_usage as tokenUsage
|
||||
FROM ai_message WHERE conversation_id = ? ORDER BY timestamp ASC, id ASC`
|
||||
)
|
||||
.all(conversationId) as AIMessageRow[]
|
||||
}
|
||||
|
||||
private getActivePathRows(conversationId: string, leafMessageId?: string | null): AIMessageRow[] {
|
||||
const db = this.getDb()
|
||||
if (leafMessageId === null) return []
|
||||
|
||||
const allRows = this.getAllMessageRows(conversationId)
|
||||
if (allRows.length === 0) return []
|
||||
|
||||
const rowMap = new Map(allRows.map((row) => [row.id, row]))
|
||||
let currentId = leafMessageId ?? this.getActiveMessageId(conversationId)
|
||||
const rows: AIMessageRow[] = []
|
||||
const path: AIMessageRow[] = []
|
||||
const seen = new Set<string>()
|
||||
|
||||
while (currentId && !seen.has(currentId)) {
|
||||
seen.add(currentId)
|
||||
const row = this.getMessageRow(currentId)
|
||||
if (!row || row.conversationId !== conversationId) break
|
||||
rows.push(row)
|
||||
const row = rowMap.get(currentId)
|
||||
if (!row) break
|
||||
path.push(row)
|
||||
currentId = row.parentId
|
||||
}
|
||||
|
||||
if (rows.length === 0) {
|
||||
return db
|
||||
.prepare(
|
||||
`SELECT id, conversation_id as conversationId, role, content, timestamp,
|
||||
parent_id as parentId, sibling_group_id as siblingGroupId, branch_index as branchIndex,
|
||||
data_keywords as dataKeywords, data_message_count as dataMessageCount,
|
||||
content_blocks as contentBlocks, token_usage as tokenUsage
|
||||
FROM ai_message WHERE conversation_id = ? ORDER BY timestamp ASC, id ASC`
|
||||
)
|
||||
.all(conversationId) as AIMessageRow[]
|
||||
}
|
||||
|
||||
return rows.reverse()
|
||||
}
|
||||
|
||||
private getBranchMeta(row: AIMessageRow): AIMessage['branch'] | undefined {
|
||||
const groupId = row.siblingGroupId ?? row.id
|
||||
const siblings = this.getDb()
|
||||
.prepare(
|
||||
`SELECT id FROM ai_message
|
||||
WHERE conversation_id = ? AND sibling_group_id = ?
|
||||
ORDER BY branch_index ASC, timestamp ASC, id ASC`
|
||||
)
|
||||
.all(row.conversationId, groupId) as Array<{ id: string }>
|
||||
|
||||
if (siblings.length <= 1) return undefined
|
||||
const index = siblings.findIndex((item) => item.id === row.id)
|
||||
const safeIndex = index >= 0 ? index : 0
|
||||
return {
|
||||
index: safeIndex,
|
||||
total: siblings.length,
|
||||
prevMessageId: safeIndex > 0 ? siblings[safeIndex - 1]!.id : null,
|
||||
nextMessageId: safeIndex < siblings.length - 1 ? siblings[safeIndex + 1]!.id : null,
|
||||
}
|
||||
}
|
||||
|
||||
private getDeepestLeafId(messageId: string): string {
|
||||
const db = this.getDb()
|
||||
let currentId = messageId
|
||||
const seen = new Set<string>()
|
||||
|
||||
while (!seen.has(currentId)) {
|
||||
seen.add(currentId)
|
||||
const child = db
|
||||
.prepare(
|
||||
`SELECT id FROM ai_message
|
||||
WHERE parent_id = ?
|
||||
ORDER BY branch_index DESC, timestamp DESC, id DESC
|
||||
LIMIT 1`
|
||||
)
|
||||
.get(currentId) as { id: string } | undefined
|
||||
if (!child?.id) break
|
||||
currentId = child.id
|
||||
}
|
||||
|
||||
return currentId
|
||||
return path.length > 0 ? path.reverse() : allRows
|
||||
}
|
||||
|
||||
// ==================== 生命周期 ====================
|
||||
@@ -585,8 +553,6 @@ export class AIConversationManager {
|
||||
content,
|
||||
timestamp: now,
|
||||
parentId,
|
||||
siblingGroupId,
|
||||
branchIndex,
|
||||
dataKeywords,
|
||||
dataMessageCount,
|
||||
contentBlocks,
|
||||
@@ -595,8 +561,7 @@ export class AIConversationManager {
|
||||
}
|
||||
|
||||
getMessages(conversationId: string): AIMessage[] {
|
||||
const rows = this.getActivePathRows(conversationId)
|
||||
return rows.map((row) => this.parseMessageRow(row, this.getBranchMeta(row)))
|
||||
return this.getActivePathRows(conversationId).map((row) => this.parseMessageRow(row))
|
||||
}
|
||||
|
||||
deleteMessage(messageId: string): boolean {
|
||||
@@ -605,115 +570,202 @@ export class AIConversationManager {
|
||||
return result.changes > 0
|
||||
}
|
||||
|
||||
createMessageBranch(
|
||||
originalUserMessageId: string,
|
||||
newUserContent: string,
|
||||
assistantContent: string,
|
||||
contentBlocks?: ContentBlock[],
|
||||
tokenUsage?: TokenUsageData
|
||||
): MessageBranchResult {
|
||||
deleteMessagesFrom(conversationId: string, messageId: string): void {
|
||||
const db = this.getDb()
|
||||
const original = this.getMessageRow(originalUserMessageId)
|
||||
if (!original || original.role !== 'user') {
|
||||
throw new Error('Original user message not found')
|
||||
const target = this.getMessageRow(messageId)
|
||||
if (!target || target.conversationId !== conversationId) {
|
||||
throw new Error('Message not found in conversation')
|
||||
}
|
||||
|
||||
const activePath = this.getActivePathRows(conversationId)
|
||||
const targetIndex = activePath.findIndex((row) => row.id === messageId)
|
||||
if (targetIndex < 0) {
|
||||
throw new Error('Message not on active path')
|
||||
}
|
||||
|
||||
const idsToDelete = activePath.slice(targetIndex).map((row) => row.id)
|
||||
const placeholders = idsToDelete.map(() => '?').join(', ')
|
||||
db.prepare(`DELETE FROM ai_message WHERE id IN (${placeholders})`).run(...idsToDelete)
|
||||
|
||||
const newLeafId = targetIndex > 0 ? activePath[targetIndex - 1]!.id : null
|
||||
const now = Math.floor(Date.now() / 1000)
|
||||
const userMessageId = this.generateId('msg')
|
||||
const assistantMessageId = this.generateId('msg')
|
||||
const siblingGroupId = original.siblingGroupId ?? original.id
|
||||
const branchRow = db
|
||||
.prepare(
|
||||
`SELECT COALESCE(MAX(branch_index), -1) + 1 as branchIndex
|
||||
FROM ai_message WHERE conversation_id = ? AND sibling_group_id = ?`
|
||||
)
|
||||
.get(original.conversationId, siblingGroupId) as { branchIndex: number }
|
||||
db.prepare('UPDATE ai_conversation SET active_message_id = ?, updated_at = ? WHERE id = ?').run(
|
||||
newLeafId,
|
||||
now,
|
||||
conversationId
|
||||
)
|
||||
}
|
||||
|
||||
const pendingDebug = this.pendingDebugContextMap.get(original.conversationId)
|
||||
if (pendingDebug) {
|
||||
this.pendingDebugContextMap.delete(original.conversationId)
|
||||
forkConversation(sourceConversationId: string, upToMessageId: string, title?: string): AIConversation {
|
||||
const db = this.getDb()
|
||||
const source = this.getConversation(sourceConversationId)
|
||||
if (!source) {
|
||||
throw new Error('Source conversation not found')
|
||||
}
|
||||
|
||||
const tx = db.transaction(() => {
|
||||
db.prepare(
|
||||
`INSERT INTO ai_message (
|
||||
id, conversation_id, role, content, timestamp, data_keywords, data_message_count,
|
||||
content_blocks, token_usage, debug_context, parent_id, sibling_group_id, branch_index
|
||||
)
|
||||
VALUES (?, ?, 'user', ?, ?, NULL, NULL, NULL, NULL, NULL, ?, ?, ?)`
|
||||
).run(
|
||||
userMessageId,
|
||||
original.conversationId,
|
||||
newUserContent,
|
||||
now,
|
||||
original.parentId,
|
||||
siblingGroupId,
|
||||
branchRow.branchIndex
|
||||
)
|
||||
const activePath = this.getActivePathRows(sourceConversationId)
|
||||
const cutIndex = activePath.findIndex((row) => row.id === upToMessageId)
|
||||
if (cutIndex < 0) {
|
||||
throw new Error('Message not on active path')
|
||||
}
|
||||
|
||||
const messagesToCopy = activePath.slice(0, cutIndex)
|
||||
const now = Math.floor(Date.now() / 1000)
|
||||
const newConvId = this.generateId('conv')
|
||||
const forkTitle = title || `${source.title || 'Untitled'} (fork)`
|
||||
|
||||
db.prepare(
|
||||
`INSERT INTO ai_conversation (id, session_id, title, assistant_id, active_message_id, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, NULL, ?, ?)`
|
||||
).run(newConvId, source.sessionId, forkTitle, source.assistantId, now, now)
|
||||
|
||||
const idMap = new Map<string, string>()
|
||||
let lastNewId: string | null = null
|
||||
|
||||
for (const row of messagesToCopy) {
|
||||
const newMsgId = this.generateId('msg')
|
||||
idMap.set(row.id, newMsgId)
|
||||
const newParentId = row.parentId ? (idMap.get(row.parentId) ?? null) : null
|
||||
|
||||
db.prepare(
|
||||
`INSERT INTO ai_message (
|
||||
id, conversation_id, role, content, timestamp, data_keywords, data_message_count,
|
||||
content_blocks, token_usage, debug_context, parent_id, sibling_group_id, branch_index
|
||||
)
|
||||
VALUES (?, ?, 'assistant', ?, ?, NULL, NULL, ?, ?, ?, ?, ?, 0)`
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, NULL, ?, ?, 0)`
|
||||
).run(
|
||||
assistantMessageId,
|
||||
original.conversationId,
|
||||
assistantContent,
|
||||
now,
|
||||
contentBlocks ? JSON.stringify(contentBlocks) : null,
|
||||
tokenUsage ? JSON.stringify(tokenUsage) : null,
|
||||
pendingDebug ?? null,
|
||||
userMessageId,
|
||||
assistantMessageId
|
||||
newMsgId,
|
||||
newConvId,
|
||||
row.role,
|
||||
row.content,
|
||||
row.timestamp,
|
||||
row.dataKeywords,
|
||||
row.dataMessageCount,
|
||||
row.contentBlocks,
|
||||
row.tokenUsage,
|
||||
newParentId,
|
||||
newMsgId
|
||||
)
|
||||
lastNewId = newMsgId
|
||||
}
|
||||
|
||||
db.prepare('UPDATE ai_conversation SET active_message_id = ?, updated_at = ? WHERE id = ?').run(
|
||||
assistantMessageId,
|
||||
now,
|
||||
original.conversationId
|
||||
)
|
||||
})
|
||||
if (lastNewId) {
|
||||
db.prepare('UPDATE ai_conversation SET active_message_id = ? WHERE id = ?').run(lastNewId, newConvId)
|
||||
}
|
||||
|
||||
tx()
|
||||
|
||||
const userRow = this.getMessageRow(userMessageId)!
|
||||
const assistantRow = this.getMessageRow(assistantMessageId)!
|
||||
return {
|
||||
userMessage: this.parseMessageRow(userRow, this.getBranchMeta(userRow)),
|
||||
assistantMessage: this.parseMessageRow(assistantRow, this.getBranchMeta(assistantRow)),
|
||||
id: newConvId,
|
||||
sessionId: source.sessionId,
|
||||
title: forkTitle,
|
||||
assistantId: source.assistantId,
|
||||
activeMessageId: lastNewId,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
}
|
||||
}
|
||||
|
||||
switchMessageBranch(conversationId: string, messageId: string): AIMessage[] {
|
||||
updateMessageContent(messageId: string, newContent: string): void {
|
||||
const db = this.getDb()
|
||||
const result = db.prepare('UPDATE ai_message SET content = ? WHERE id = ?').run(newContent, messageId)
|
||||
if (result.changes === 0) throw new Error('Message not found')
|
||||
}
|
||||
|
||||
deleteAndRelinkMessage(conversationId: string, messageId: string): void {
|
||||
const db = this.getDb()
|
||||
const target = this.getMessageRow(messageId)
|
||||
if (!target || target.conversationId !== conversationId) {
|
||||
throw new Error('Message branch not found')
|
||||
throw new Error('Message not found in conversation')
|
||||
}
|
||||
|
||||
const leafId = this.getDeepestLeafId(messageId)
|
||||
const now = Math.floor(Date.now() / 1000)
|
||||
this.getDb()
|
||||
.prepare('UPDATE ai_conversation SET active_message_id = ?, updated_at = ? WHERE id = ?')
|
||||
.run(leafId, now, conversationId)
|
||||
db.prepare('UPDATE ai_message SET parent_id = ? WHERE parent_id = ? AND conversation_id = ?').run(
|
||||
target.parentId,
|
||||
messageId,
|
||||
conversationId
|
||||
)
|
||||
|
||||
return this.getMessages(conversationId)
|
||||
const conv = this.getConversation(conversationId)
|
||||
if (conv?.activeMessageId === messageId) {
|
||||
const now = Math.floor(Date.now() / 1000)
|
||||
db.prepare('UPDATE ai_conversation SET active_message_id = ?, updated_at = ? WHERE id = ?').run(
|
||||
target.parentId,
|
||||
now,
|
||||
conversationId
|
||||
)
|
||||
}
|
||||
|
||||
db.prepare('DELETE FROM ai_message WHERE id = ?').run(messageId)
|
||||
}
|
||||
|
||||
insertMessageAfter(
|
||||
conversationId: string,
|
||||
afterMessageId: string,
|
||||
role: AIMessageRole,
|
||||
content: string,
|
||||
contentBlocks?: ContentBlock[],
|
||||
tokenUsage?: TokenUsageData
|
||||
): AIMessage {
|
||||
const db = this.getDb()
|
||||
const now = Math.floor(Date.now() / 1000)
|
||||
const id = this.generateId('msg')
|
||||
|
||||
const pendingDebug = role === 'assistant' ? this.pendingDebugContextMap.get(conversationId) : undefined
|
||||
if (pendingDebug) {
|
||||
this.pendingDebugContextMap.delete(conversationId)
|
||||
}
|
||||
|
||||
const childRow = db
|
||||
.prepare('SELECT id FROM ai_message WHERE parent_id = ? AND conversation_id = ? LIMIT 1')
|
||||
.get(afterMessageId, conversationId) as { id: string } | undefined
|
||||
|
||||
db.prepare(
|
||||
`INSERT INTO ai_message (
|
||||
id, conversation_id, role, content, timestamp, data_keywords, data_message_count,
|
||||
content_blocks, token_usage, debug_context, parent_id, sibling_group_id, branch_index
|
||||
)
|
||||
VALUES (?, ?, ?, ?, ?, NULL, NULL, ?, ?, ?, ?, ?, 0)`
|
||||
).run(
|
||||
id,
|
||||
conversationId,
|
||||
role,
|
||||
content,
|
||||
now,
|
||||
contentBlocks ? JSON.stringify(contentBlocks) : null,
|
||||
tokenUsage ? JSON.stringify(tokenUsage) : null,
|
||||
pendingDebug ?? null,
|
||||
afterMessageId,
|
||||
id
|
||||
)
|
||||
|
||||
if (childRow) {
|
||||
db.prepare('UPDATE ai_message SET parent_id = ? WHERE id = ?').run(id, childRow.id)
|
||||
}
|
||||
|
||||
db.prepare('UPDATE ai_conversation SET updated_at = ? WHERE id = ?').run(now, conversationId)
|
||||
|
||||
return {
|
||||
id,
|
||||
conversationId,
|
||||
role,
|
||||
content,
|
||||
timestamp: now,
|
||||
parentId: afterMessageId,
|
||||
contentBlocks,
|
||||
tokenUsage,
|
||||
}
|
||||
}
|
||||
|
||||
getConversationTokenUsage(conversationId: string): TokenUsageData {
|
||||
const messages = this.getMessages(conversationId)
|
||||
return messages.reduce<TokenUsageData>(
|
||||
(total, message) => {
|
||||
if (message.tokenUsage) {
|
||||
total.promptTokens += message.tokenUsage.promptTokens
|
||||
total.completionTokens += message.tokenUsage.completionTokens
|
||||
total.totalTokens += message.tokenUsage.totalTokens
|
||||
}
|
||||
return total
|
||||
},
|
||||
{ promptTokens: 0, completionTokens: 0, totalTokens: 0 }
|
||||
)
|
||||
const rows = this.getActivePathRows(conversationId)
|
||||
const result: TokenUsageData = { promptTokens: 0, completionTokens: 0, totalTokens: 0 }
|
||||
for (const row of rows) {
|
||||
if (row.tokenUsage) {
|
||||
const usage = JSON.parse(row.tokenUsage) as TokenUsageData
|
||||
result.promptTokens += usage.promptTokens
|
||||
result.completionTokens += usage.completionTokens
|
||||
result.totalTokens += usage.totalTokens
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// ==================== Debug context ====================
|
||||
|
||||
@@ -32,7 +32,6 @@ export type {
|
||||
ContentBlock,
|
||||
TokenUsageData,
|
||||
ConversationManagerLogger,
|
||||
MessageBranchResult,
|
||||
} from './conversations'
|
||||
|
||||
// Tokenizer
|
||||
|
||||
@@ -79,7 +79,7 @@ export {
|
||||
} from './nlp'
|
||||
|
||||
// AI 助手/技能解析器 + 对话管理
|
||||
export type { AssistantConfig, AssistantSummary, SkillDef, SkillSummary, MessageBranchResult } from './ai'
|
||||
export type { AssistantConfig, AssistantSummary, SkillDef, SkillSummary } from './ai'
|
||||
export { parseAssistantFile, serializeAssistant, parseSkillFile, extractSkillId } from './ai'
|
||||
export { AIConversationManager } from './ai'
|
||||
export { countTokens, countMessagesTokens } from './ai'
|
||||
|
||||
Reference in new issue
Block a user