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:
diguaandCursor committed 2026-05-30 14:19:42 +08:00
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)
+215 -163
View File
@@ -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 ====================
-1
View File
@@ -32,7 +32,6 @@ export type {
ContentBlock,
TokenUsageData,
ConversationManagerLogger,
MessageBranchResult,
} from './conversations'
// Tokenizer
+1 -1
View File
@@ -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'