From d3c62c4f9da9e1a89929104649961468ed15013d Mon Sep 17 00:00:00 2001 From: CEQ151 Date: Fri, 29 May 2026 18:23:30 +0800 Subject: [PATCH] feat(ai): support editable message branches --- apps/cli/src/ai/agent.ts | 4 +- apps/cli/src/http/routes/ai.ts | 28 + apps/desktop/main/ai/agent/index.ts | 2 +- apps/desktop/main/ai/conversations.ts | 33 +- apps/desktop/main/ai/tools/types.ts | 2 + apps/desktop/main/ipc/ai.ts | 34 ++ apps/desktop/preload/apis/ai.ts | 41 ++ apps/desktop/preload/index.d.ts | 28 +- apps/desktop/tsconfig.node.json | 4 +- .../src/ai/__tests__/conversations.test.ts | 205 +++++++ packages/node-runtime/src/ai/conversations.ts | 481 +++++++++++++---- packages/node-runtime/src/ai/index.ts | 1 + packages/node-runtime/src/index.ts | 2 +- src/components/AIChat/ChatExplorer.vue | 30 ++ src/components/AIChat/chat/ChatMessage.vue | 100 +++- src/composables/useAIChat.ts | 3 + src/i18n/locales/en-US/ai.json | 4 + src/i18n/locales/ja-JP/ai.json | 4 + src/i18n/locales/zh-CN/ai.json | 4 + src/i18n/locales/zh-TW/ai.json | 4 + src/services/ai/electron.ts | 21 + src/services/ai/fetch.ts | 20 + src/services/ai/types.ts | 26 + src/services/ai/web.ts | 15 + src/services/registry.ts | 1 + src/stores/aiChat.ts | 509 +++++++++++++++++- 26 files changed, 1465 insertions(+), 141 deletions(-) create mode 100644 packages/node-runtime/src/ai/__tests__/conversations.test.ts diff --git a/apps/cli/src/ai/agent.ts b/apps/cli/src/ai/agent.ts index 3cd1e30d..e4e15b9c 100644 --- a/apps/cli/src/ai/agent.ts +++ b/apps/cli/src/ai/agent.ts @@ -32,6 +32,7 @@ export type { AgentStreamChunk } export interface RunAgentOptions { userMessage: string conversationId: string + historyLeafMessageId?: string | null chatType?: 'group' | 'private' locale?: string assistantSystemPrompt?: string @@ -51,6 +52,7 @@ export async function runServerAgent(options: RunAgentOptions): Promise { const { userMessage, conversationId, + historyLeafMessageId, chatType = 'group', locale = 'zh-CN', assistantSystemPrompt, @@ -135,7 +137,7 @@ export async function runServerAgent(options: RunAgentOptions): Promise { let history: SimpleHistoryMessage[] = [] try { - history = convManager.getHistoryForAgent(conversationId) + history = convManager.getHistoryForAgent(conversationId, undefined, historyLeafMessageId) } catch { // empty history on failure } diff --git a/apps/cli/src/http/routes/ai.ts b/apps/cli/src/http/routes/ai.ts index 132bf543..528af13c 100644 --- a/apps/cli/src/http/routes/ai.ts +++ b/apps/cli/src/http/routes/ai.ts @@ -521,6 +521,31 @@ export function registerAiRoutes( return convManager.getMessages(request.params.id) }) + server.post<{ + Params: { id: string } + Body: { + content: string + assistantContent: string + contentBlocks?: unknown[] + tokenUsage?: { promptTokens: number; completionTokens: number; totalTokens: number } + } + }>('/_web/ai/messages/:id/branches', async (request) => { + return convManager.createMessageBranch( + request.params.id, + request.body.content, + request.body.assistantContent, + request.body.contentBlocks as any, + request.body.tokenUsage + ) + }) + + server.post<{ + Params: { id: string } + Body: { messageId: string } + }>('/_web/ai/conversations/:id/branches/switch', async (request) => { + return convManager.switchMessageBranch(request.params.id, request.body.messageId) + }) + server.get<{ Params: { id: string } }>('/_web/ai/conversations/:id/token-usage', async (request) => { return convManager.getConversationTokenUsage(request.params.id) }) @@ -533,6 +558,7 @@ export function registerAiRoutes( Body: { userMessage: string conversationId: string + historyLeafMessageId?: string | null sessionId: string chatType?: 'group' | 'private' locale?: string @@ -550,6 +576,7 @@ export function registerAiRoutes( const { userMessage, conversationId, + historyLeafMessageId, sessionId, chatType, locale, @@ -660,6 +687,7 @@ export function registerAiRoutes( await runServerAgent({ userMessage, conversationId, + historyLeafMessageId, chatType, locale, assistantSystemPrompt, diff --git a/apps/desktop/main/ai/agent/index.ts b/apps/desktop/main/ai/agent/index.ts index ab4f1b96..0ca527b6 100644 --- a/apps/desktop/main/ai/agent/index.ts +++ b/apps/desktop/main/ai/agent/index.ts @@ -279,7 +279,7 @@ export class Agent { return [] } try { - return getHistoryForAgent(conversationId) + return getHistoryForAgent(conversationId, undefined, this.context.historyLeafMessageId) } catch (error) { aiLogger.warn('Agent', 'Failed to load history from DB, using empty history', { conversationId, error }) return [] diff --git a/apps/desktop/main/ai/conversations.ts b/apps/desktop/main/ai/conversations.ts index 4bae13ba..e3edfeee 100644 --- a/apps/desktop/main/ai/conversations.ts +++ b/apps/desktop/main/ai/conversations.ts @@ -9,7 +9,14 @@ import { AIConversationManager } from '@openchatlab/node-runtime' import { getPathProvider } from '../path-context' import { aiLogger } from './logger' -export type { AIConversation, AIMessage, AIMessageRole, ContentBlock, TokenUsageData } from '@openchatlab/node-runtime' +export type { + AIConversation, + AIMessage, + AIMessageRole, + ContentBlock, + TokenUsageData, + MessageBranchResult, +} from '@openchatlab/node-runtime' let manager: AIConversationManager | null = null @@ -93,6 +100,26 @@ export function deleteMessage(messageId: string) { return getManager().deleteMessage(messageId) } +export function createMessageBranch( + originalUserMessageId: string, + newUserContent: string, + assistantContent: string, + contentBlocks?: import('@openchatlab/node-runtime').ContentBlock[], + tokenUsage?: import('@openchatlab/node-runtime').TokenUsageData +) { + return getManager().createMessageBranch( + originalUserMessageId, + newUserContent, + assistantContent, + contentBlocks, + tokenUsage + ) +} + +export function switchMessageBranch(conversationId: string, messageId: string) { + return getManager().switchMessageBranch(conversationId, messageId) +} + export function setPendingDebugContext(conversationId: string, debugContext: string) { return getManager().setPendingDebugContext(conversationId, debugContext) } @@ -109,8 +136,8 @@ export function getConversationTokenUsage(conversationId: string) { return getManager().getConversationTokenUsage(conversationId) } -export function getHistoryForAgent(conversationId: string, maxMessages?: number) { - return getManager().getHistoryForAgent(conversationId, maxMessages) +export function getHistoryForAgent(conversationId: string, maxMessages?: number, leafMessageId?: string | null) { + return getManager().getHistoryForAgent(conversationId, maxMessages, leafMessageId) } export function addSummaryMessage( diff --git a/apps/desktop/main/ai/tools/types.ts b/apps/desktop/main/ai/tools/types.ts index 1eba2fa7..cfcc8922 100644 --- a/apps/desktop/main/ai/tools/types.ts +++ b/apps/desktop/main/ai/tools/types.ts @@ -37,6 +37,8 @@ export interface ToolContext { sessionId: string /** 当前 AI 对话 ID(用于上下文管理隔离) */ conversationId?: string + /** 读取 Agent 历史时使用的叶子消息;编辑重答时指向被编辑消息的父节点 */ + historyLeafMessageId?: string | null /** 当前聊天数据库快照(仅用于提示模型当前数据范围,不能替代工具检索结果) */ dataSnapshot?: DataSnapshot /** 时间过滤器 */ diff --git a/apps/desktop/main/ipc/ai.ts b/apps/desktop/main/ipc/ai.ts index 219e3bc5..0dd4b04c 100644 --- a/apps/desktop/main/ipc/ai.ts +++ b/apps/desktop/main/ipc/ai.ts @@ -284,6 +284,40 @@ export function registerAIHandlers({ win }: IpcContext): void { } }) + ipcMain.handle( + 'ai:createMessageBranch', + async ( + _, + originalUserMessageId: string, + newUserContent: string, + assistantContent: string, + contentBlocks?: aiConversations.ContentBlock[], + tokenUsage?: aiConversations.TokenUsageData + ) => { + try { + return aiConversations.createMessageBranch( + originalUserMessageId, + newUserContent, + assistantContent, + contentBlocks, + tokenUsage + ) + } catch (error) { + console.error('Failed to create AI message branch:', error) + throw error + } + } + ) + + ipcMain.handle('ai:switchMessageBranch', async (_, conversationId: string, messageId: string) => { + try { + return aiConversations.switchMessageBranch(conversationId, messageId) + } catch (error) { + console.error('Failed to switch AI message branch:', error) + throw error + } + }) + // ==================== 脱敏规则 ==================== ipcMain.handle('ai:getDefaultDesensitizeRules', (_, locale: string) => { diff --git a/apps/desktop/preload/apis/ai.ts b/apps/desktop/preload/apis/ai.ts index 55d7fc56..dde5a49d 100644 --- a/apps/desktop/preload/apis/ai.ts +++ b/apps/desktop/preload/apis/ai.ts @@ -27,6 +27,7 @@ export interface AIConversation { sessionId: string title: string | null assistantId: string + activeMessageId?: string | null createdAt: number updatedAt: number } @@ -34,6 +35,7 @@ export interface AIConversation { // 内容块类型(用于 AI 消息的混合渲染) export type ContentBlock = | { type: 'text'; text: string } + | { type: 'think'; tag: string; text: string; durationMs?: number } | { type: 'tool' tool: { @@ -44,6 +46,8 @@ export type ContentBlock = } } | { type: 'skill'; skillId: string; skillName: string } + | { type: 'error'; error: SerializedErrorInfo } + | { type: 'summary_meta'; bufferBoundaryTimestamp: number; compressedMessageCount: number } export type AIMessageRole = 'user' | 'assistant' | 'summary' @@ -59,12 +63,26 @@ export interface AIMessage { role: AIMessageRole 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[] tokenUsage?: TokenUsageData } +export interface MessageBranchResult { + userMessage: AIMessage + assistantMessage: AIMessage +} + // LLM API 类型 export interface LLMProvider { id: string @@ -171,6 +189,7 @@ export interface PreprocessConfig { export interface ToolContext { sessionId: string conversationId?: string + historyLeafMessageId?: string | null timeFilter?: { startTs: number; endTs: number } maxMessagesLimit?: number ownerInfo?: { platformId: string; displayName: string } @@ -496,6 +515,27 @@ export const aiApi = { ) }, + createMessageBranch: ( + originalUserMessageId: string, + newUserContent: string, + assistantContent: string, + contentBlocks?: ContentBlock[], + tokenUsage?: TokenUsageData + ): Promise => { + return ipcRenderer.invoke( + 'ai:createMessageBranch', + originalUserMessageId, + newUserContent, + assistantContent, + contentBlocks, + tokenUsage + ) + }, + + switchMessageBranch: (conversationId: string, messageId: string): Promise => { + return ipcRenderer.invoke('ai:switchMessageBranch', conversationId, messageId) + }, + /** * 获取 AI 对话的所有消息 */ @@ -1014,6 +1054,7 @@ export const agentApi = { const sanitizedContext: ToolContext = { sessionId: context.sessionId, conversationId: context.conversationId, + historyLeafMessageId: context.historyLeafMessageId, timeFilter: context.timeFilter ? { startTs: context.timeFilter.startTs, diff --git a/apps/desktop/preload/index.d.ts b/apps/desktop/preload/index.d.ts index c2892ab1..18c9a826 100644 --- a/apps/desktop/preload/index.d.ts +++ b/apps/desktop/preload/index.d.ts @@ -304,6 +304,7 @@ interface AIConversation { sessionId: string title: string | null assistantId: string + activeMessageId?: string | null createdAt: number updatedAt: number } @@ -311,6 +312,7 @@ interface AIConversation { // 内容块类型(用于 AI 消息的混合渲染) type AIContentBlock = | { type: 'text'; text: string } + | { type: 'think'; tag: string; text: string; durationMs?: number } | { type: 'tool' tool: { @@ -321,6 +323,7 @@ type AIContentBlock = } } | { type: 'skill'; skillId: string; skillName: string } + | { type: 'error'; error: SerializedErrorInfo } | { type: 'summary_meta' bufferBoundaryTimestamp: number @@ -341,12 +344,26 @@ interface AIMessage { role: AIMessageRole 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?: AIContentBlock[] tokenUsage?: AITokenUsageData } +interface MessageBranchResult { + userMessage: AIMessage + assistantMessage: AIMessage +} + interface AiApi { searchMessages: ( sessionId: string, @@ -408,7 +425,14 @@ interface AiApi { contentBlocks?: AIContentBlock[], tokenUsage?: AITokenUsageData ) => Promise - getMessages: (conversationId: string) => Promise + createMessageBranch: ( + originalUserMessageId: string, + newUserContent: string, + assistantContent: string, + contentBlocks?: AIContentBlock[], + tokenUsage?: AITokenUsageData + ) => Promise + switchMessageBranch: (conversationId: string, messageId: string) => Promise getMessages: (conversationId: string) => Promise getConversationTokenUsage: (conversationId: string) => Promise deleteMessage: (messageId: string) => Promise @@ -744,6 +768,7 @@ interface PreprocessConfig { interface ToolContext { sessionId: string conversationId?: string + historyLeafMessageId?: string | null timeFilter?: { startTs: number; endTs: number } /** 用户配置:每次发送给 AI 的最大消息条数 */ maxMessagesLimit?: number @@ -1225,6 +1250,7 @@ export { SearchMessageResult, AIConversation, AIMessage, + MessageBranchResult, LLMProviderInfo, AIServiceConfigDisplay, LLMChatMessage, diff --git a/apps/desktop/tsconfig.node.json b/apps/desktop/tsconfig.node.json index 28984e5d..5756edbd 100644 --- a/apps/desktop/tsconfig.node.json +++ b/apps/desktop/tsconfig.node.json @@ -11,6 +11,7 @@ "../../packages/node-runtime/**/*", "../../packages/config/**/*", "../../packages/parser/**/*", + "../../packages/mcp-server/**/*", "../../apps/cli/**/*", "../../packages/tools/**/*", "../../packages/sync/**/*" @@ -23,7 +24,8 @@ "paths": { "@/*": ["../../src/*"], "@electron/*": ["./*"], - "@openchatlab/*": ["../../packages/*"] + "@openchatlab/*": ["../../packages/*"], + "chatlab-mcp": ["../../packages/mcp-server/src"] } } } diff --git a/packages/node-runtime/src/ai/__tests__/conversations.test.ts b/packages/node-runtime/src/ai/__tests__/conversations.test.ts new file mode 100644 index 00000000..bfb45c8a --- /dev/null +++ b/packages/node-runtime/src/ai/__tests__/conversations.test.ts @@ -0,0 +1,205 @@ +import { describe, it } from 'node:test' +import assert from 'node:assert/strict' +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import Database from 'better-sqlite3' +import { AIConversationManager } from '../conversations' + +const sqliteNativeBinding = process.env.CHATLAB_TEST_SQLITE_NATIVE_BINDING + +function createTempDir(): string { + return mkdtempSync(join(tmpdir(), 'chatlab-ai-conv-')) +} + +function createTestDatabase(filename: string): Database.Database { + return sqliteNativeBinding ? new Database(filename, { nativeBinding: sqliteNativeBinding }) : new Database(filename) +} + +function createManager(dir: string): AIConversationManager { + return sqliteNativeBinding + ? new AIConversationManager(dir, { nativeBinding: sqliteNativeBinding }) + : new AIConversationManager(dir) +} + +function cleanup(dir: string): void { + try { + rmSync(dir, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 }) + } catch { + // Windows can hold SQLite WAL handles briefly after close; temp cleanup is best-effort. + } +} + +describe('AIConversationManager message branches', () => { + it('migrates legacy flat messages into an active path', () => { + const dir = createTempDir() + try { + const db = createTestDatabase(join(dir, 'conversations.db')) + db.exec(` + CREATE TABLE ai_conversation ( + id TEXT PRIMARY KEY, + session_id TEXT NOT NULL, + title 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 + ); + `) + db.prepare('INSERT INTO ai_conversation VALUES (?, ?, ?, ?, ?)').run('conv-1', 'session-1', 'Legacy', 1, 4) + db.prepare('INSERT INTO ai_message VALUES (?, ?, ?, ?, ?, NULL, NULL, NULL)').run( + 'm1', + 'conv-1', + 'user', + 'one', + 1 + ) + db.prepare('INSERT INTO ai_message VALUES (?, ?, ?, ?, ?, NULL, NULL, NULL)').run( + 'm2', + 'conv-1', + 'assistant', + 'two', + 2 + ) + db.close() + + const manager = createManager(dir) + const messages = manager.getMessages('conv-1') + assert.deepEqual( + messages.map((message) => message.content), + ['one', 'two'] + ) + assert.equal(messages[0]?.parentId, null) + assert.equal(messages[1]?.parentId, 'm1') + assert.equal(manager.getConversation('conv-1')?.activeMessageId, 'm2') + manager.close() + } finally { + cleanup(dir) + } + }) + + it('does not rerun legacy backfill over existing root branches', () => { + 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() + + manager = createManager(dir) + const messages = manager.getMessages(conversation.id) + assert.deepEqual( + messages.map((message) => message.content), + ['edited root', 'edited answer'] + ) + assert.equal(messages[0]?.parentId, null) + assert.equal(manager.getConversation(conversation.id)?.activeMessageId, branch.assistantMessage.id) + manager.close() + } finally { + cleanup(dir) + } + }) + + it('creates editable branches without losing the original path', () => { + 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 branch = manager.createMessageBranch('' + user1.id, 'edited question', 'edited answer', undefined, { + promptTokens: 10, + completionTokens: 11, + totalTokens: 21, + }) + + assert.deepEqual( + manager.getMessages(conversation.id).map((message) => message.content), + ['edited question', 'edited 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.deepEqual( + manager.getMessages(conversation.id).map((message) => message.content), + ['original question', 'original answer', 'follow up', 'follow answer'] + ) + assert.equal(manager.getConversation(conversation.id)?.activeMessageId, oldLeaf.id) + assert.deepEqual(manager.getConversationTokenUsage(conversation.id), { + promptTokens: 5, + completionTokens: 7, + totalTokens: 12, + }) + manager.close() + } finally { + cleanup(dir) + } + }) + + it('keeps summaries on inactive branches when adding a new summary', () => { + 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) + + assert.deepEqual( + manager.getMessages(conversation.id).map((message) => message.content), + ['intro', 'answer', 'original summary', 'follow up', 'follow answer'] + ) + manager.close() + } finally { + cleanup(dir) + } + }) +}) diff --git a/packages/node-runtime/src/ai/conversations.ts b/packages/node-runtime/src/ai/conversations.ts index 919c4f19..3cb5d6ea 100644 --- a/packages/node-runtime/src/ai/conversations.ts +++ b/packages/node-runtime/src/ai/conversations.ts @@ -18,6 +18,7 @@ export interface AIConversation { sessionId: string title: string | null assistantId: string + activeMessageId?: string | null createdAt: number updatedAt: number } @@ -54,12 +55,41 @@ export interface AIMessage { role: AIMessageRole 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[] tokenUsage?: TokenUsageData } +interface AIMessageRow { + id: string + conversationId: string + role: string + content: string + timestamp: number + parentId: string | null + siblingGroupId: string | null + branchIndex: number | null + dataKeywords: string | null + dataMessageCount: number | null + contentBlocks: string | null + tokenUsage: string | null +} + +export interface MessageBranchResult { + userMessage: AIMessage + assistantMessage: AIMessage +} + export interface ConversationManagerLogger { warn(category: string, message: string, extra?: Record): void } @@ -104,6 +134,7 @@ export class AIConversationManager { id TEXT PRIMARY KEY, session_id TEXT NOT NULL, title TEXT, + active_message_id TEXT, created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL ); @@ -117,6 +148,9 @@ export class AIConversationManager { data_keywords TEXT, data_message_count INTEGER, content_blocks TEXT, + parent_id TEXT, + sibling_group_id TEXT, + branch_index INTEGER DEFAULT 0, FOREIGN KEY(conversation_id) REFERENCES ai_conversation(id) ON DELETE CASCADE ); @@ -133,6 +167,11 @@ export class AIConversationManager { const messageTableInfo = db.pragma('table_info(ai_message)') as Array<{ name: string }> const messageColumns = messageTableInfo.map((col) => col.name) + const needsMessageTreeBackfill = + !messageColumns.includes('parent_id') || + !messageColumns.includes('sibling_group_id') || + !messageColumns.includes('branch_index') + if (!messageColumns.includes('content_blocks')) { db.exec('ALTER TABLE ai_message ADD COLUMN content_blocks TEXT') } @@ -142,22 +181,204 @@ export class AIConversationManager { if (!messageColumns.includes('debug_context')) { db.exec('ALTER TABLE ai_message ADD COLUMN debug_context TEXT') } + if (!messageColumns.includes('parent_id')) { + db.exec('ALTER TABLE ai_message ADD COLUMN parent_id TEXT') + } + if (!messageColumns.includes('sibling_group_id')) { + db.exec('ALTER TABLE ai_message ADD COLUMN sibling_group_id TEXT') + } + if (!messageColumns.includes('branch_index')) { + db.exec('ALTER TABLE ai_message ADD COLUMN branch_index INTEGER DEFAULT 0') + } const convTableInfo = db.pragma('table_info(ai_conversation)') as Array<{ name: string }> const convColumns = convTableInfo.map((col) => col.name) + const needsConversationBackfill = !convColumns.includes('active_message_id') if (!convColumns.includes('assistant_id')) { db.exec(`ALTER TABLE ai_conversation ADD COLUMN assistant_id TEXT DEFAULT '${DEFAULT_GENERAL_ID}'`) } + if (!convColumns.includes('active_message_id')) { + db.exec('ALTER TABLE ai_conversation ADD COLUMN active_message_id TEXT') + } + + if (needsMessageTreeBackfill || needsConversationBackfill) { + this.backfillMessageTree(db) + } + + db.exec(` + CREATE INDEX IF NOT EXISTS idx_ai_message_parent ON ai_message(parent_id); + CREATE INDEX IF NOT EXISTS idx_ai_message_sibling ON ai_message(sibling_group_id); + `) } catch (error) { console.error('[AI DB Migration] Migration failed:', error) } } + private backfillMessageTree(db: Database.Database): void { + const conversations = db.prepare('SELECT id FROM ai_conversation').all() as Array<{ id: string }> + const updateMessage = db.prepare( + 'UPDATE ai_message SET parent_id = ?, sibling_group_id = COALESCE(sibling_group_id, ?), branch_index = COALESCE(branch_index, 0) WHERE id = ?' + ) + const updateConversation = db.prepare('UPDATE ai_conversation SET active_message_id = ? WHERE id = ?') + + const tx = db.transaction(() => { + for (const conversation of conversations) { + const messages = db + .prepare( + `SELECT id, parent_id as parentId, sibling_group_id as siblingGroupId + FROM ai_message WHERE conversation_id = ? ORDER BY timestamp ASC, id ASC` + ) + .all(conversation.id) as Array<{ id: string; parentId: string | null; siblingGroupId: string | null }> + + let previousId: string | null = null + for (const message of messages) { + const parentId = message.parentId === undefined ? previousId : (message.parentId ?? previousId) + updateMessage.run(parentId, message.siblingGroupId ?? message.id, message.id) + previousId = message.id + } + + const conversationRow = db + .prepare('SELECT active_message_id as activeMessageId FROM ai_conversation WHERE id = ?') + .get(conversation.id) as { activeMessageId: string | null } | undefined + if (!conversationRow?.activeMessageId && previousId) { + updateConversation.run(previousId, conversation.id) + } + } + }) + + tx() + } + private generateId(prefix: string): string { return `${prefix}_${Date.now()}_${Math.random().toString(36).slice(2, 8)}` } + private parseMessageRow(row: AIMessageRow, branch?: AIMessage['branch']): AIMessage { + return { + id: row.id, + conversationId: row.conversationId, + role: row.role as AIMessageRole, + 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, + tokenUsage: row.tokenUsage ? JSON.parse(row.tokenUsage) : undefined, + } + } + + private getMessageRow(messageId: string): AIMessageRow | null { + const db = this.getDb() + const row = 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 id = ?` + ) + .get(messageId) as AIMessageRow | undefined + return row ?? null + } + + private getActiveMessageId(conversationId: string): string | null { + const db = this.getDb() + const row = db + .prepare('SELECT active_message_id as activeMessageId FROM ai_conversation WHERE id = ?') + .get(conversationId) as { activeMessageId: string | null } | undefined + if (row?.activeMessageId) { + const activeExists = db.prepare('SELECT 1 FROM ai_message WHERE id = ?').get(row.activeMessageId) + if (activeExists) return row.activeMessageId + } + + const fallback = db + .prepare('SELECT id FROM ai_message WHERE conversation_id = ? ORDER BY timestamp DESC, id DESC LIMIT 1') + .get(conversationId) as { id: string } | undefined + if (fallback?.id) { + db.prepare('UPDATE ai_conversation SET active_message_id = ? WHERE id = ?').run(fallback.id, conversationId) + return fallback.id + } + return null + } + + private getActivePathRows(conversationId: string, leafMessageId?: string | null): AIMessageRow[] { + const db = this.getDb() + if (leafMessageId === null) return [] + let currentId = leafMessageId ?? this.getActiveMessageId(conversationId) + const rows: AIMessageRow[] = [] + const seen = new Set() + + while (currentId && !seen.has(currentId)) { + seen.add(currentId) + const row = this.getMessageRow(currentId) + if (!row || row.conversationId !== conversationId) break + rows.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() + + 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 + } + // ==================== 生命周期 ==================== close(): void { @@ -246,7 +467,7 @@ export class AIConversationManager { VALUES (?, ?, ?, ?, ?, ?)` ).run(id, sessionId, title || null, assistantId, now, now) - return { id, sessionId, title: title || null, assistantId, createdAt: now, updatedAt: now } + return { id, sessionId, title: title || null, assistantId, activeMessageId: null, createdAt: now, updatedAt: now } } getConversationCountsBySession(): Map { @@ -270,6 +491,7 @@ export class AIConversationManager { return db .prepare( `SELECT id, session_id as sessionId, title, assistant_id as assistantId, + active_message_id as activeMessageId, created_at as createdAt, updated_at as updatedAt FROM ai_conversation WHERE session_id = ? ORDER BY updated_at DESC` ) @@ -281,6 +503,7 @@ export class AIConversationManager { const row = db .prepare( `SELECT id, session_id as sessionId, title, assistant_id as assistantId, + active_message_id as activeMessageId, created_at as createdAt, updated_at as updatedAt FROM ai_conversation WHERE id = ?` ) @@ -318,6 +541,9 @@ export class AIConversationManager { const db = this.getDb() const now = Math.floor(Date.now() / 1000) const id = this.generateId('msg') + const parentId = this.getActiveMessageId(conversationId) + const siblingGroupId = id + const branchIndex = 0 const pendingDebug = role === 'assistant' ? this.pendingDebugContextMap.get(conversationId) : undefined if (pendingDebug) { @@ -325,8 +551,11 @@ export class AIConversationManager { } db.prepare( - `INSERT INTO ai_message (id, conversation_id, role, content, timestamp, data_keywords, data_message_count, content_blocks, token_usage, debug_context) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` + `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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` ).run( id, conversationId, @@ -337,10 +566,17 @@ export class AIConversationManager { dataMessageCount ?? null, contentBlocks ? JSON.stringify(contentBlocks) : null, tokenUsage ? JSON.stringify(tokenUsage) : null, - pendingDebug ?? null + pendingDebug ?? null, + parentId, + siblingGroupId, + branchIndex ) - db.prepare('UPDATE ai_conversation SET updated_at = ? WHERE id = ?').run(now, conversationId) + db.prepare('UPDATE ai_conversation SET active_message_id = ?, updated_at = ? WHERE id = ?').run( + id, + now, + conversationId + ) return { id, @@ -348,6 +584,9 @@ export class AIConversationManager { role, content, timestamp: now, + parentId, + siblingGroupId, + branchIndex, dataKeywords, dataMessageCount, contentBlocks, @@ -356,37 +595,8 @@ export class AIConversationManager { } getMessages(conversationId: string): AIMessage[] { - const db = this.getDb() - const rows = db - .prepare( - `SELECT id, conversation_id as conversationId, role, content, timestamp, - 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` - ) - .all(conversationId) as Array<{ - id: string - conversationId: string - role: string - content: string - timestamp: number - dataKeywords: string | null - dataMessageCount: number | null - contentBlocks: string | null - tokenUsage: string | null - }> - - return rows.map((row) => ({ - id: row.id, - conversationId: row.conversationId, - role: row.role as AIMessageRole, - content: row.content, - timestamp: row.timestamp, - dataKeywords: row.dataKeywords ? JSON.parse(row.dataKeywords) : undefined, - dataMessageCount: row.dataMessageCount ?? undefined, - contentBlocks: row.contentBlocks ? JSON.parse(row.contentBlocks) : undefined, - tokenUsage: row.tokenUsage ? JSON.parse(row.tokenUsage) : undefined, - })) + const rows = this.getActivePathRows(conversationId) + return rows.map((row) => this.parseMessageRow(row, this.getBranchMeta(row))) } deleteMessage(messageId: string): boolean { @@ -395,18 +605,115 @@ export class AIConversationManager { return result.changes > 0 } - getConversationTokenUsage(conversationId: string): TokenUsageData { + createMessageBranch( + originalUserMessageId: string, + newUserContent: string, + assistantContent: string, + contentBlocks?: ContentBlock[], + tokenUsage?: TokenUsageData + ): MessageBranchResult { const db = this.getDb() - const row = db + const original = this.getMessageRow(originalUserMessageId) + if (!original || original.role !== 'user') { + throw new Error('Original user message not found') + } + + 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(SUM(json_extract(token_usage, '$.promptTokens')), 0) as promptTokens, - COALESCE(SUM(json_extract(token_usage, '$.completionTokens')), 0) as completionTokens, - COALESCE(SUM(json_extract(token_usage, '$.totalTokens')), 0) as totalTokens - FROM ai_message WHERE conversation_id = ? AND token_usage IS NOT NULL` + `SELECT COALESCE(MAX(branch_index), -1) + 1 as branchIndex + FROM ai_message WHERE conversation_id = ? AND sibling_group_id = ?` ) - .get(conversationId) as { promptTokens: number; completionTokens: number; totalTokens: number } - return { promptTokens: row.promptTokens, completionTokens: row.completionTokens, totalTokens: row.totalTokens } + .get(original.conversationId, siblingGroupId) as { branchIndex: number } + + const pendingDebug = this.pendingDebugContextMap.get(original.conversationId) + if (pendingDebug) { + this.pendingDebugContextMap.delete(original.conversationId) + } + + 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 + ) + + 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)` + ).run( + assistantMessageId, + original.conversationId, + assistantContent, + now, + contentBlocks ? JSON.stringify(contentBlocks) : null, + tokenUsage ? JSON.stringify(tokenUsage) : null, + pendingDebug ?? null, + userMessageId, + assistantMessageId + ) + + db.prepare('UPDATE ai_conversation SET active_message_id = ?, updated_at = ? WHERE id = ?').run( + assistantMessageId, + now, + original.conversationId + ) + }) + + 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)), + } + } + + switchMessageBranch(conversationId: string, messageId: string): AIMessage[] { + const target = this.getMessageRow(messageId) + if (!target || target.conversationId !== conversationId) { + throw new Error('Message branch not found') + } + + 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) + + return this.getMessages(conversationId) + } + + getConversationTokenUsage(conversationId: string): TokenUsageData { + const messages = this.getMessages(conversationId) + return messages.reduce( + (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 } + ) } // ==================== Debug context ==================== @@ -430,9 +737,10 @@ export class AIConversationManager { getHistoryForAgent( conversationId: string, - maxMessages?: number + maxMessages?: number, + leafMessageId?: string | null ): Array<{ role: 'user' | 'assistant' | 'summary'; content: string }> { - const messages = this.getMessages(conversationId) + const messages = this.getActivePathRows(conversationId, leafMessageId).map((row) => this.parseMessageRow(row)) const validMessages = messages.filter( (m) => (m.role === 'user' || m.role === 'assistant' || m.role === 'summary') && m.content?.trim() ) @@ -490,9 +798,6 @@ export class AIConversationManager { content: string, meta: { bufferBoundaryTimestamp: number; compressedMessageCount: number } ): AIMessage { - const db = this.getDb() - db.prepare("DELETE FROM ai_message WHERE conversation_id = ? AND role = 'summary'").run(conversationId) - const contentBlocks: ContentBlock[] = [ { type: 'summary_meta', @@ -505,78 +810,32 @@ export class AIConversationManager { } getLatestSummary(conversationId: string): AIMessage | null { - const db = this.getDb() - const row = db - .prepare( - `SELECT id, conversation_id as conversationId, role, content, timestamp, - data_keywords as dataKeywords, data_message_count as dataMessageCount, - content_blocks as contentBlocks - FROM ai_message WHERE conversation_id = ? AND role = 'summary' - ORDER BY timestamp DESC LIMIT 1` - ) - .get(conversationId) as - | { - id: string - conversationId: string - role: string - content: string - timestamp: number - dataKeywords: string | null - dataMessageCount: number | null - contentBlocks: string | null - } - | undefined - - if (!row) return null - return { - id: row.id, - conversationId: row.conversationId, - role: row.role as AIMessageRole, - content: row.content, - timestamp: row.timestamp, - dataKeywords: row.dataKeywords ? JSON.parse(row.dataKeywords) : undefined, - dataMessageCount: row.dataMessageCount ?? undefined, - contentBlocks: row.contentBlocks ? JSON.parse(row.contentBlocks) : undefined, - } + const row = [...this.getActivePathRows(conversationId)].reverse().find((message) => message.role === 'summary') + return row ? this.parseMessageRow(row) : null } getMessagesAfterSummary( conversationId: string, summaryTimestamp: number ): Array<{ role: AIMessageRole; content: string; timestamp: number }> { - const db = this.getDb() - const rows = db - .prepare( - `SELECT role, content, timestamp FROM ai_message - WHERE conversation_id = ? AND timestamp > ? AND role IN ('user', 'assistant') - ORDER BY timestamp ASC` - ) - .all(conversationId, summaryTimestamp) as Array<{ role: string; content: string; timestamp: number }> - return rows.map((r) => ({ role: r.role as AIMessageRole, content: r.content, timestamp: r.timestamp })) + return this.getActivePathRows(conversationId) + .filter((row) => row.timestamp > summaryTimestamp && (row.role === 'user' || row.role === 'assistant')) + .map((row) => ({ role: row.role as AIMessageRole, content: row.content, timestamp: row.timestamp })) } getAllUserAssistantMessages( conversationId: string ): Array<{ role: AIMessageRole; content: string; timestamp: number }> { - const db = this.getDb() - const rows = db - .prepare( - `SELECT role, content, timestamp FROM ai_message - WHERE conversation_id = ? AND role IN ('user', 'assistant') - ORDER BY timestamp ASC` - ) - .all(conversationId) as Array<{ role: string; content: string; timestamp: number }> - return rows.map((r) => ({ role: r.role as AIMessageRole, content: r.content, timestamp: r.timestamp })) + return this.getActivePathRows(conversationId) + .filter((row) => row.role === 'user' || row.role === 'assistant') + .map((row) => ({ role: row.role as AIMessageRole, content: row.content, timestamp: row.timestamp })) } getMessageCountAfterSummary(conversationId: string): number { const summary = this.getLatestSummary(conversationId) if (!summary) { - const db = this.getDb() - const row = db - .prepare("SELECT COUNT(*) as count FROM ai_message WHERE conversation_id = ? AND role IN ('user', 'assistant')") - .get(conversationId) as { count: number } - return row.count + return this.getActivePathRows(conversationId).filter((row) => row.role === 'user' || row.role === 'assistant') + .length } const metaBlock = summary.contentBlocks?.find( @@ -584,12 +843,8 @@ export class AIConversationManager { ) const boundary = metaBlock?.bufferBoundaryTimestamp ?? summary.timestamp - const db = this.getDb() - const row = db - .prepare( - "SELECT COUNT(*) as count FROM ai_message WHERE conversation_id = ? AND timestamp >= ? AND role IN ('user', 'assistant')" - ) - .get(conversationId, boundary) as { count: number } - return row.count + return this.getActivePathRows(conversationId).filter( + (row) => row.timestamp >= boundary && (row.role === 'user' || row.role === 'assistant') + ).length } } diff --git a/packages/node-runtime/src/ai/index.ts b/packages/node-runtime/src/ai/index.ts index 2e199522..1b20bb4d 100644 --- a/packages/node-runtime/src/ai/index.ts +++ b/packages/node-runtime/src/ai/index.ts @@ -32,6 +32,7 @@ export type { ContentBlock, TokenUsageData, ConversationManagerLogger, + MessageBranchResult, } from './conversations' // Tokenizer diff --git a/packages/node-runtime/src/index.ts b/packages/node-runtime/src/index.ts index 5fc79f90..2538d296 100644 --- a/packages/node-runtime/src/index.ts +++ b/packages/node-runtime/src/index.ts @@ -79,7 +79,7 @@ export { } from './nlp' // AI 助手/技能解析器 + 对话管理 -export type { AssistantConfig, AssistantSummary, SkillDef, SkillSummary } from './ai' +export type { AssistantConfig, AssistantSummary, SkillDef, SkillSummary, MessageBranchResult } from './ai' export { parseAssistantFile, serializeAssistant, parseSkillFile, extractSkillId } from './ai' export { AIConversationManager } from './ai' export { countTokens, countMessagesTokens } from './ai' diff --git a/src/components/AIChat/ChatExplorer.vue b/src/components/AIChat/ChatExplorer.vue index 1f1a98a6..17feb419 100644 --- a/src/components/AIChat/ChatExplorer.vue +++ b/src/components/AIChat/ChatExplorer.vue @@ -54,6 +54,8 @@ const { agentStatus, selectedAssistantId, sendMessage, + editMessageAndRegenerate, + switchMessageBranch, loadConversation, startNewConversation, loadMoreSourceMessages, @@ -244,6 +246,28 @@ async function handleSend(payload: { content: string; mentionedMembers: Mentione conversationListRef.value?.refresh() } +async function handleEditMessage(payload: { messageId: string; content: string }) { + const result = await editMessageAndRegenerate(payload.messageId, payload.content) + if (!result.success) { + if (result.reason === 'busy') { + showRunningTaskToast() + } + return + } + scrollToBottom(true) + conversationListRef.value?.refresh() +} + +async function handleSwitchMessageBranch(messageId: string | null) { + if (!messageId) return + const ok = await switchMessageBranch(messageId) + if (!ok) { + showLockedActionToast() + return + } + scrollToBottom(true) +} + // 切换数据源面板 function toggleSourcePanel() { isSourcePanelCollapsed.value = !isSourcePanelCollapsed.value @@ -413,10 +437,16 @@ watch( -import { computed } from 'vue' +import { computed, ref, watch, nextTick } from 'vue' import { useI18n } from 'vue-i18n' import dayjs from 'dayjs' import MarkdownIt from 'markdown-it' @@ -13,6 +13,7 @@ const toast = useToast() // Props const props = defineProps<{ + messageId?: string role: 'user' | 'assistant' | 'summary' content: string timestamp: number @@ -21,6 +22,19 @@ const props = defineProps<{ contentBlocks?: ContentBlock[] /** 是否显示截屏按钮(仅 AI 回复) */ showCaptureButton?: boolean + editable?: boolean + branch?: { + index: number + total: number + prevMessageId: string | null + nextMessageId: string | null + } +}>() + +const emit = defineEmits<{ + edit: [payload: { messageId: string; content: string }] + branchPrev: [messageId: string | null] + branchNext: [messageId: string | null] }>() // 格式化时间 @@ -31,6 +45,11 @@ const formattedTime = computed(() => { // 是否是用户消息 const isUser = computed(() => props.role === 'user') const isSummary = computed(() => props.role === 'summary') +const isEditing = ref(false) +const editContent = ref(props.content) +const editTextareaRef = ref(null) +const canEdit = computed(() => isUser.value && props.editable && !props.isStreaming && !!props.messageId) +const hasBranchSwitcher = computed(() => isUser.value && !!props.branch && props.branch.total > 1) // 创建 markdown-it 实例 const md = new MarkdownIt({ @@ -77,6 +96,37 @@ const renderedContent = computed(() => { return md.render(props.content) }) +watch( + () => props.content, + (content) => { + if (!isEditing.value) editContent.value = content + } +) + +async function startEditing() { + if (!canEdit.value) return + editContent.value = props.content + isEditing.value = true + await nextTick() + editTextareaRef.value?.focus() +} + +function cancelEditing() { + isEditing.value = false + editContent.value = props.content +} + +function submitEditing() { + if (!props.messageId) return + const content = editContent.value.trim() + if (!content || content === props.content.trim()) { + cancelEditing() + return + } + isEditing.value = false + emit('edit', { messageId: props.messageId, content }) +} + // 过滤无内容的文本/思考块,避免显示空气泡 const visibleBlocks = computed(() => { const blocks = props.contentBlocks || [] @@ -331,7 +381,28 @@ async function handleCopyMarkdown() {