feat(ai): support editable message branches

This commit is contained in:
CEQ151 authored and digua committed 2026-05-30 14:19:42 +08:00
1 parent 3a8f61fdf0
commit d3c62c4f9d
26 files changed
+1465 -141

No files matched your search

+3 -1
View File
@@ -32,6 +32,7 @@ export type { AgentStreamChunk }
export interface RunAgentOptions { export interface RunAgentOptions {
userMessage: string userMessage: string
conversationId: string conversationId: string
historyLeafMessageId?: string | null
chatType?: 'group' | 'private' chatType?: 'group' | 'private'
locale?: string locale?: string
assistantSystemPrompt?: string assistantSystemPrompt?: string
@@ -51,6 +52,7 @@ export async function runServerAgent(options: RunAgentOptions): Promise<void> {
const { const {
userMessage, userMessage,
conversationId, conversationId,
historyLeafMessageId,
chatType = 'group', chatType = 'group',
locale = 'zh-CN', locale = 'zh-CN',
assistantSystemPrompt, assistantSystemPrompt,
@@ -135,7 +137,7 @@ export async function runServerAgent(options: RunAgentOptions): Promise<void> {
let history: SimpleHistoryMessage[] = [] let history: SimpleHistoryMessage[] = []
try { try {
history = convManager.getHistoryForAgent(conversationId) history = convManager.getHistoryForAgent(conversationId, undefined, historyLeafMessageId)
} catch { } catch {
// empty history on failure // empty history on failure
} }
+28
View File
@@ -521,6 +521,31 @@ export function registerAiRoutes(
return convManager.getMessages(request.params.id) 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) => { server.get<{ Params: { id: string } }>('/_web/ai/conversations/:id/token-usage', async (request) => {
return convManager.getConversationTokenUsage(request.params.id) return convManager.getConversationTokenUsage(request.params.id)
}) })
@@ -533,6 +558,7 @@ export function registerAiRoutes(
Body: { Body: {
userMessage: string userMessage: string
conversationId: string conversationId: string
historyLeafMessageId?: string | null
sessionId: string sessionId: string
chatType?: 'group' | 'private' chatType?: 'group' | 'private'
locale?: string locale?: string
@@ -550,6 +576,7 @@ export function registerAiRoutes(
const { const {
userMessage, userMessage,
conversationId, conversationId,
historyLeafMessageId,
sessionId, sessionId,
chatType, chatType,
locale, locale,
@@ -660,6 +687,7 @@ export function registerAiRoutes(
await runServerAgent({ await runServerAgent({
userMessage, userMessage,
conversationId, conversationId,
historyLeafMessageId,
chatType, chatType,
locale, locale,
assistantSystemPrompt, assistantSystemPrompt,
+1 -1
View File
@@ -279,7 +279,7 @@ export class Agent {
return [] return []
} }
try { try {
return getHistoryForAgent(conversationId) return getHistoryForAgent(conversationId, undefined, this.context.historyLeafMessageId)
} catch (error) { } catch (error) {
aiLogger.warn('Agent', 'Failed to load history from DB, using empty history', { conversationId, error }) aiLogger.warn('Agent', 'Failed to load history from DB, using empty history', { conversationId, error })
return [] return []
+30 -3
View File
@@ -9,7 +9,14 @@ import { AIConversationManager } from '@openchatlab/node-runtime'
import { getPathProvider } from '../path-context' import { getPathProvider } from '../path-context'
import { aiLogger } from './logger' 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 let manager: AIConversationManager | null = null
@@ -93,6 +100,26 @@ export function deleteMessage(messageId: string) {
return getManager().deleteMessage(messageId) 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) { export function setPendingDebugContext(conversationId: string, debugContext: string) {
return getManager().setPendingDebugContext(conversationId, debugContext) return getManager().setPendingDebugContext(conversationId, debugContext)
} }
@@ -109,8 +136,8 @@ export function getConversationTokenUsage(conversationId: string) {
return getManager().getConversationTokenUsage(conversationId) return getManager().getConversationTokenUsage(conversationId)
} }
export function getHistoryForAgent(conversationId: string, maxMessages?: number) { export function getHistoryForAgent(conversationId: string, maxMessages?: number, leafMessageId?: string | null) {
return getManager().getHistoryForAgent(conversationId, maxMessages) return getManager().getHistoryForAgent(conversationId, maxMessages, leafMessageId)
} }
export function addSummaryMessage( export function addSummaryMessage(
+2
View File
@@ -37,6 +37,8 @@ export interface ToolContext {
sessionId: string sessionId: string
/** 当前 AI 对话 ID(用于上下文管理隔离) */ /** 当前 AI 对话 ID(用于上下文管理隔离) */
conversationId?: string conversationId?: string
/** 读取 Agent 历史时使用的叶子消息;编辑重答时指向被编辑消息的父节点 */
historyLeafMessageId?: string | null
/** 当前聊天数据库快照(仅用于提示模型当前数据范围,不能替代工具检索结果) */ /** 当前聊天数据库快照(仅用于提示模型当前数据范围,不能替代工具检索结果) */
dataSnapshot?: DataSnapshot dataSnapshot?: DataSnapshot
/** 时间过滤器 */ /** 时间过滤器 */
+34
View File
@@ -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) => { ipcMain.handle('ai:getDefaultDesensitizeRules', (_, locale: string) => {
+41
View File
@@ -27,6 +27,7 @@ export interface AIConversation {
sessionId: string sessionId: string
title: string | null title: string | null
assistantId: string assistantId: string
activeMessageId?: string | null
createdAt: number createdAt: number
updatedAt: number updatedAt: number
} }
@@ -34,6 +35,7 @@ export interface AIConversation {
// 内容块类型(用于 AI 消息的混合渲染) // 内容块类型(用于 AI 消息的混合渲染)
export type ContentBlock = export type ContentBlock =
| { type: 'text'; text: string } | { type: 'text'; text: string }
| { type: 'think'; tag: string; text: string; durationMs?: number }
| { | {
type: 'tool' type: 'tool'
tool: { tool: {
@@ -44,6 +46,8 @@ export type ContentBlock =
} }
} }
| { type: 'skill'; skillId: string; skillName: string } | { type: 'skill'; skillId: string; skillName: string }
| { type: 'error'; error: SerializedErrorInfo }
| { type: 'summary_meta'; bufferBoundaryTimestamp: number; compressedMessageCount: number }
export type AIMessageRole = 'user' | 'assistant' | 'summary' export type AIMessageRole = 'user' | 'assistant' | 'summary'
@@ -59,12 +63,26 @@ export interface AIMessage {
role: AIMessageRole role: AIMessageRole
content: string content: string
timestamp: number timestamp: number
parentId?: string | null
siblingGroupId?: string
branchIndex?: number
branch?: {
index: number
total: number
prevMessageId: string | null
nextMessageId: string | null
}
dataKeywords?: string[] dataKeywords?: string[]
dataMessageCount?: number dataMessageCount?: number
contentBlocks?: ContentBlock[] contentBlocks?: ContentBlock[]
tokenUsage?: TokenUsageData tokenUsage?: TokenUsageData
} }
export interface MessageBranchResult {
userMessage: AIMessage
assistantMessage: AIMessage
}
// LLM API 类型 // LLM API 类型
export interface LLMProvider { export interface LLMProvider {
id: string id: string
@@ -171,6 +189,7 @@ export interface PreprocessConfig {
export interface ToolContext { export interface ToolContext {
sessionId: string sessionId: string
conversationId?: string conversationId?: string
historyLeafMessageId?: string | null
timeFilter?: { startTs: number; endTs: number } timeFilter?: { startTs: number; endTs: number }
maxMessagesLimit?: number maxMessagesLimit?: number
ownerInfo?: { platformId: string; displayName: string } ownerInfo?: { platformId: string; displayName: string }
@@ -496,6 +515,27 @@ export const aiApi = {
) )
}, },
createMessageBranch: (
originalUserMessageId: string,
newUserContent: string,
assistantContent: string,
contentBlocks?: ContentBlock[],
tokenUsage?: TokenUsageData
): Promise<MessageBranchResult> => {
return ipcRenderer.invoke(
'ai:createMessageBranch',
originalUserMessageId,
newUserContent,
assistantContent,
contentBlocks,
tokenUsage
)
},
switchMessageBranch: (conversationId: string, messageId: string): Promise<AIMessage[]> => {
return ipcRenderer.invoke('ai:switchMessageBranch', conversationId, messageId)
},
/** /**
* 获取 AI 对话的所有消息 * 获取 AI 对话的所有消息
*/ */
@@ -1014,6 +1054,7 @@ export const agentApi = {
const sanitizedContext: ToolContext = { const sanitizedContext: ToolContext = {
sessionId: context.sessionId, sessionId: context.sessionId,
conversationId: context.conversationId, conversationId: context.conversationId,
historyLeafMessageId: context.historyLeafMessageId,
timeFilter: context.timeFilter timeFilter: context.timeFilter
? { ? {
startTs: context.timeFilter.startTs, startTs: context.timeFilter.startTs,
+27 -1
View File
@@ -304,6 +304,7 @@ interface AIConversation {
sessionId: string sessionId: string
title: string | null title: string | null
assistantId: string assistantId: string
activeMessageId?: string | null
createdAt: number createdAt: number
updatedAt: number updatedAt: number
} }
@@ -311,6 +312,7 @@ interface AIConversation {
// 内容块类型(用于 AI 消息的混合渲染) // 内容块类型(用于 AI 消息的混合渲染)
type AIContentBlock = type AIContentBlock =
| { type: 'text'; text: string } | { type: 'text'; text: string }
| { type: 'think'; tag: string; text: string; durationMs?: number }
| { | {
type: 'tool' type: 'tool'
tool: { tool: {
@@ -321,6 +323,7 @@ type AIContentBlock =
} }
} }
| { type: 'skill'; skillId: string; skillName: string } | { type: 'skill'; skillId: string; skillName: string }
| { type: 'error'; error: SerializedErrorInfo }
| { | {
type: 'summary_meta' type: 'summary_meta'
bufferBoundaryTimestamp: number bufferBoundaryTimestamp: number
@@ -341,12 +344,26 @@ interface AIMessage {
role: AIMessageRole role: AIMessageRole
content: string content: string
timestamp: number timestamp: number
parentId?: string | null
siblingGroupId?: string
branchIndex?: number
branch?: {
index: number
total: number
prevMessageId: string | null
nextMessageId: string | null
}
dataKeywords?: string[] dataKeywords?: string[]
dataMessageCount?: number dataMessageCount?: number
contentBlocks?: AIContentBlock[] contentBlocks?: AIContentBlock[]
tokenUsage?: AITokenUsageData tokenUsage?: AITokenUsageData
} }
interface MessageBranchResult {
userMessage: AIMessage
assistantMessage: AIMessage
}
interface AiApi { interface AiApi {
searchMessages: ( searchMessages: (
sessionId: string, sessionId: string,
@@ -408,7 +425,14 @@ interface AiApi {
contentBlocks?: AIContentBlock[], contentBlocks?: AIContentBlock[],
tokenUsage?: AITokenUsageData tokenUsage?: AITokenUsageData
) => Promise<AIMessage> ) => Promise<AIMessage>
getMessages: (conversationId: string) => Promise<AIMessage[]> createMessageBranch: (
originalUserMessageId: string,
newUserContent: string,
assistantContent: string,
contentBlocks?: AIContentBlock[],
tokenUsage?: AITokenUsageData
) => Promise<MessageBranchResult>
switchMessageBranch: (conversationId: string, messageId: string) => Promise<AIMessage[]>
getMessages: (conversationId: string) => Promise<AIMessage[]> getMessages: (conversationId: string) => Promise<AIMessage[]>
getConversationTokenUsage: (conversationId: string) => Promise<AITokenUsageData> getConversationTokenUsage: (conversationId: string) => Promise<AITokenUsageData>
deleteMessage: (messageId: string) => Promise<boolean> deleteMessage: (messageId: string) => Promise<boolean>
@@ -744,6 +768,7 @@ interface PreprocessConfig {
interface ToolContext { interface ToolContext {
sessionId: string sessionId: string
conversationId?: string conversationId?: string
historyLeafMessageId?: string | null
timeFilter?: { startTs: number; endTs: number } timeFilter?: { startTs: number; endTs: number }
/** 用户配置:每次发送给 AI 的最大消息条数 */ /** 用户配置:每次发送给 AI 的最大消息条数 */
maxMessagesLimit?: number maxMessagesLimit?: number
@@ -1225,6 +1250,7 @@ export {
SearchMessageResult, SearchMessageResult,
AIConversation, AIConversation,
AIMessage, AIMessage,
MessageBranchResult,
LLMProviderInfo, LLMProviderInfo,
AIServiceConfigDisplay, AIServiceConfigDisplay,
LLMChatMessage, LLMChatMessage,
+3 -1
View File
@@ -11,6 +11,7 @@
"../../packages/node-runtime/**/*", "../../packages/node-runtime/**/*",
"../../packages/config/**/*", "../../packages/config/**/*",
"../../packages/parser/**/*", "../../packages/parser/**/*",
"../../packages/mcp-server/**/*",
"../../apps/cli/**/*", "../../apps/cli/**/*",
"../../packages/tools/**/*", "../../packages/tools/**/*",
"../../packages/sync/**/*" "../../packages/sync/**/*"
@@ -23,7 +24,8 @@
"paths": { "paths": {
"@/*": ["../../src/*"], "@/*": ["../../src/*"],
"@electron/*": ["./*"], "@electron/*": ["./*"],
"@openchatlab/*": ["../../packages/*"] "@openchatlab/*": ["../../packages/*"],
"chatlab-mcp": ["../../packages/mcp-server/src"]
} }
} }
} }
@@ -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)
}
})
})
+368 -113
View File
@@ -18,6 +18,7 @@ export interface AIConversation {
sessionId: string sessionId: string
title: string | null title: string | null
assistantId: string assistantId: string
activeMessageId?: string | null
createdAt: number createdAt: number
updatedAt: number updatedAt: number
} }
@@ -54,12 +55,41 @@ export interface AIMessage {
role: AIMessageRole role: AIMessageRole
content: string content: string
timestamp: number timestamp: number
parentId?: string | null
siblingGroupId?: string
branchIndex?: number
branch?: {
index: number
total: number
prevMessageId: string | null
nextMessageId: string | null
}
dataKeywords?: string[] dataKeywords?: string[]
dataMessageCount?: number dataMessageCount?: number
contentBlocks?: ContentBlock[] contentBlocks?: ContentBlock[]
tokenUsage?: TokenUsageData 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 { export interface ConversationManagerLogger {
warn(category: string, message: string, extra?: Record<string, unknown>): void warn(category: string, message: string, extra?: Record<string, unknown>): void
} }
@@ -104,6 +134,7 @@ export class AIConversationManager {
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
session_id TEXT NOT NULL, session_id TEXT NOT NULL,
title TEXT, title TEXT,
active_message_id TEXT,
created_at INTEGER NOT NULL, created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL updated_at INTEGER NOT NULL
); );
@@ -117,6 +148,9 @@ export class AIConversationManager {
data_keywords TEXT, data_keywords TEXT,
data_message_count INTEGER, data_message_count INTEGER,
content_blocks TEXT, 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 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 messageTableInfo = db.pragma('table_info(ai_message)') as Array<{ name: string }>
const messageColumns = messageTableInfo.map((col) => col.name) 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')) { if (!messageColumns.includes('content_blocks')) {
db.exec('ALTER TABLE ai_message ADD COLUMN content_blocks TEXT') db.exec('ALTER TABLE ai_message ADD COLUMN content_blocks TEXT')
} }
@@ -142,22 +181,204 @@ export class AIConversationManager {
if (!messageColumns.includes('debug_context')) { if (!messageColumns.includes('debug_context')) {
db.exec('ALTER TABLE ai_message ADD COLUMN debug_context TEXT') 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 convTableInfo = db.pragma('table_info(ai_conversation)') as Array<{ name: string }>
const convColumns = convTableInfo.map((col) => col.name) const convColumns = convTableInfo.map((col) => col.name)
const needsConversationBackfill = !convColumns.includes('active_message_id')
if (!convColumns.includes('assistant_id')) { if (!convColumns.includes('assistant_id')) {
db.exec(`ALTER TABLE ai_conversation ADD COLUMN assistant_id TEXT DEFAULT '${DEFAULT_GENERAL_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) { } catch (error) {
console.error('[AI DB Migration] Migration failed:', 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 { private generateId(prefix: string): string {
return `${prefix}_${Date.now()}_${Math.random().toString(36).slice(2, 8)}` 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<string>()
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<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
}
// ==================== 生命周期 ==================== // ==================== 生命周期 ====================
close(): void { close(): void {
@@ -246,7 +467,7 @@ export class AIConversationManager {
VALUES (?, ?, ?, ?, ?, ?)` VALUES (?, ?, ?, ?, ?, ?)`
).run(id, sessionId, title || null, assistantId, now, now) ).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<string, number> { getConversationCountsBySession(): Map<string, number> {
@@ -270,6 +491,7 @@ export class AIConversationManager {
return db return db
.prepare( .prepare(
`SELECT id, session_id as sessionId, title, assistant_id as assistantId, `SELECT id, session_id as sessionId, title, assistant_id as assistantId,
active_message_id as activeMessageId,
created_at as createdAt, updated_at as updatedAt created_at as createdAt, updated_at as updatedAt
FROM ai_conversation WHERE session_id = ? ORDER BY updated_at DESC` FROM ai_conversation WHERE session_id = ? ORDER BY updated_at DESC`
) )
@@ -281,6 +503,7 @@ export class AIConversationManager {
const row = db const row = db
.prepare( .prepare(
`SELECT id, session_id as sessionId, title, assistant_id as assistantId, `SELECT id, session_id as sessionId, title, assistant_id as assistantId,
active_message_id as activeMessageId,
created_at as createdAt, updated_at as updatedAt created_at as createdAt, updated_at as updatedAt
FROM ai_conversation WHERE id = ?` FROM ai_conversation WHERE id = ?`
) )
@@ -318,6 +541,9 @@ export class AIConversationManager {
const db = this.getDb() const db = this.getDb()
const now = Math.floor(Date.now() / 1000) const now = Math.floor(Date.now() / 1000)
const id = this.generateId('msg') 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 const pendingDebug = role === 'assistant' ? this.pendingDebugContextMap.get(conversationId) : undefined
if (pendingDebug) { if (pendingDebug) {
@@ -325,8 +551,11 @@ export class AIConversationManager {
} }
db.prepare( db.prepare(
`INSERT INTO ai_message (id, conversation_id, role, content, timestamp, data_keywords, data_message_count, content_blocks, token_usage, debug_context) `INSERT INTO ai_message (
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` 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( ).run(
id, id,
conversationId, conversationId,
@@ -337,10 +566,17 @@ export class AIConversationManager {
dataMessageCount ?? null, dataMessageCount ?? null,
contentBlocks ? JSON.stringify(contentBlocks) : null, contentBlocks ? JSON.stringify(contentBlocks) : null,
tokenUsage ? JSON.stringify(tokenUsage) : 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 { return {
id, id,
@@ -348,6 +584,9 @@ export class AIConversationManager {
role, role,
content, content,
timestamp: now, timestamp: now,
parentId,
siblingGroupId,
branchIndex,
dataKeywords, dataKeywords,
dataMessageCount, dataMessageCount,
contentBlocks, contentBlocks,
@@ -356,37 +595,8 @@ export class AIConversationManager {
} }
getMessages(conversationId: string): AIMessage[] { getMessages(conversationId: string): AIMessage[] {
const db = this.getDb() const rows = this.getActivePathRows(conversationId)
const rows = db return rows.map((row) => this.parseMessageRow(row, this.getBranchMeta(row)))
.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,
}))
} }
deleteMessage(messageId: string): boolean { deleteMessage(messageId: string): boolean {
@@ -395,18 +605,115 @@ export class AIConversationManager {
return result.changes > 0 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 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( .prepare(
`SELECT `SELECT COALESCE(MAX(branch_index), -1) + 1 as branchIndex
COALESCE(SUM(json_extract(token_usage, '$.promptTokens')), 0) as promptTokens, FROM ai_message WHERE conversation_id = ? AND sibling_group_id = ?`
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`
) )
.get(conversationId) as { promptTokens: number; completionTokens: number; totalTokens: number } .get(original.conversationId, siblingGroupId) as { branchIndex: number }
return { promptTokens: row.promptTokens, completionTokens: row.completionTokens, totalTokens: row.totalTokens }
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<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 }
)
} }
// ==================== Debug context ==================== // ==================== Debug context ====================
@@ -430,9 +737,10 @@ export class AIConversationManager {
getHistoryForAgent( getHistoryForAgent(
conversationId: string, conversationId: string,
maxMessages?: number maxMessages?: number,
leafMessageId?: string | null
): Array<{ role: 'user' | 'assistant' | 'summary'; content: string }> { ): 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( const validMessages = messages.filter(
(m) => (m.role === 'user' || m.role === 'assistant' || m.role === 'summary') && m.content?.trim() (m) => (m.role === 'user' || m.role === 'assistant' || m.role === 'summary') && m.content?.trim()
) )
@@ -490,9 +798,6 @@ export class AIConversationManager {
content: string, content: string,
meta: { bufferBoundaryTimestamp: number; compressedMessageCount: number } meta: { bufferBoundaryTimestamp: number; compressedMessageCount: number }
): AIMessage { ): AIMessage {
const db = this.getDb()
db.prepare("DELETE FROM ai_message WHERE conversation_id = ? AND role = 'summary'").run(conversationId)
const contentBlocks: ContentBlock[] = [ const contentBlocks: ContentBlock[] = [
{ {
type: 'summary_meta', type: 'summary_meta',
@@ -505,78 +810,32 @@ export class AIConversationManager {
} }
getLatestSummary(conversationId: string): AIMessage | null { getLatestSummary(conversationId: string): AIMessage | null {
const db = this.getDb() const row = [...this.getActivePathRows(conversationId)].reverse().find((message) => message.role === 'summary')
const row = db return row ? this.parseMessageRow(row) : null
.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,
}
} }
getMessagesAfterSummary( getMessagesAfterSummary(
conversationId: string, conversationId: string,
summaryTimestamp: number summaryTimestamp: number
): Array<{ role: AIMessageRole; content: string; timestamp: number }> { ): Array<{ role: AIMessageRole; content: string; timestamp: number }> {
const db = this.getDb() return this.getActivePathRows(conversationId)
const rows = db .filter((row) => row.timestamp > summaryTimestamp && (row.role === 'user' || row.role === 'assistant'))
.prepare( .map((row) => ({ role: row.role as AIMessageRole, content: row.content, timestamp: row.timestamp }))
`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 }))
} }
getAllUserAssistantMessages( getAllUserAssistantMessages(
conversationId: string conversationId: string
): Array<{ role: AIMessageRole; content: string; timestamp: number }> { ): Array<{ role: AIMessageRole; content: string; timestamp: number }> {
const db = this.getDb() return this.getActivePathRows(conversationId)
const rows = db .filter((row) => row.role === 'user' || row.role === 'assistant')
.prepare( .map((row) => ({ role: row.role as AIMessageRole, content: row.content, timestamp: row.timestamp }))
`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 }))
} }
getMessageCountAfterSummary(conversationId: string): number { getMessageCountAfterSummary(conversationId: string): number {
const summary = this.getLatestSummary(conversationId) const summary = this.getLatestSummary(conversationId)
if (!summary) { if (!summary) {
const db = this.getDb() return this.getActivePathRows(conversationId).filter((row) => row.role === 'user' || row.role === 'assistant')
const row = db .length
.prepare("SELECT COUNT(*) as count FROM ai_message WHERE conversation_id = ? AND role IN ('user', 'assistant')")
.get(conversationId) as { count: number }
return row.count
} }
const metaBlock = summary.contentBlocks?.find( const metaBlock = summary.contentBlocks?.find(
@@ -584,12 +843,8 @@ export class AIConversationManager {
) )
const boundary = metaBlock?.bufferBoundaryTimestamp ?? summary.timestamp const boundary = metaBlock?.bufferBoundaryTimestamp ?? summary.timestamp
const db = this.getDb() return this.getActivePathRows(conversationId).filter(
const row = db (row) => row.timestamp >= boundary && (row.role === 'user' || row.role === 'assistant')
.prepare( ).length
"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
} }
} }
+1
View File
@@ -32,6 +32,7 @@ export type {
ContentBlock, ContentBlock,
TokenUsageData, TokenUsageData,
ConversationManagerLogger, ConversationManagerLogger,
MessageBranchResult,
} from './conversations' } from './conversations'
// Tokenizer // Tokenizer
+1 -1
View File
@@ -79,7 +79,7 @@ export {
} from './nlp' } from './nlp'
// AI 助手/技能解析器 + 对话管理 // 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 { parseAssistantFile, serializeAssistant, parseSkillFile, extractSkillId } from './ai'
export { AIConversationManager } from './ai' export { AIConversationManager } from './ai'
export { countTokens, countMessagesTokens } from './ai' export { countTokens, countMessagesTokens } from './ai'
+30
View File
@@ -54,6 +54,8 @@ const {
agentStatus, agentStatus,
selectedAssistantId, selectedAssistantId,
sendMessage, sendMessage,
editMessageAndRegenerate,
switchMessageBranch,
loadConversation, loadConversation,
startNewConversation, startNewConversation,
loadMoreSourceMessages, loadMoreSourceMessages,
@@ -244,6 +246,28 @@ async function handleSend(payload: { content: string; mentionedMembers: Mentione
conversationListRef.value?.refresh() 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() { function toggleSourcePanel() {
isSourcePanelCollapsed.value = !isSourcePanelCollapsed.value isSourcePanelCollapsed.value = !isSourcePanelCollapsed.value
@@ -413,10 +437,16 @@ watch(
<ChatMessage <ChatMessage
v-if="pair.user && (pair.user.role === 'user' || pair.user.content)" v-if="pair.user && (pair.user.role === 'user' || pair.user.content)"
:role="pair.user.role" :role="pair.user.role"
:message-id="pair.user.id"
:content="pair.user.content" :content="pair.user.content"
:timestamp="pair.user.timestamp" :timestamp="pair.user.timestamp"
:is-streaming="pair.user.isStreaming" :is-streaming="pair.user.isStreaming"
:content-blocks="pair.user.contentBlocks" :content-blocks="pair.user.contentBlocks"
:branch="pair.user.branch"
:editable="!isAIThinking"
@edit="handleEditMessage"
@branch-prev="handleSwitchMessageBranch"
@branch-next="handleSwitchMessageBranch"
/> />
<!-- AI 回复 --> <!-- AI 回复 -->
<ChatMessage <ChatMessage
+98 -2
View File
@@ -1,5 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue' import { computed, ref, watch, nextTick } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import dayjs from 'dayjs' import dayjs from 'dayjs'
import MarkdownIt from 'markdown-it' import MarkdownIt from 'markdown-it'
@@ -13,6 +13,7 @@ const toast = useToast()
// Props // Props
const props = defineProps<{ const props = defineProps<{
messageId?: string
role: 'user' | 'assistant' | 'summary' role: 'user' | 'assistant' | 'summary'
content: string content: string
timestamp: number timestamp: number
@@ -21,6 +22,19 @@ const props = defineProps<{
contentBlocks?: ContentBlock[] contentBlocks?: ContentBlock[]
/** 是否显示截屏按钮(仅 AI 回复) */ /** 是否显示截屏按钮(仅 AI 回复) */
showCaptureButton?: boolean 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 isUser = computed(() => props.role === 'user')
const isSummary = computed(() => props.role === 'summary') const isSummary = computed(() => props.role === 'summary')
const isEditing = ref(false)
const editContent = ref(props.content)
const editTextareaRef = ref<HTMLTextAreaElement | null>(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 实例 // 创建 markdown-it 实例
const md = new MarkdownIt({ const md = new MarkdownIt({
@@ -77,6 +96,37 @@ const renderedContent = computed(() => {
return md.render(props.content) 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 visibleBlocks = computed(() => {
const blocks = props.contentBlocks || [] const blocks = props.contentBlocks || []
@@ -331,7 +381,28 @@ async function handleCopyMarkdown() {
<!-- 用户消息:简单气泡 --> <!-- 用户消息:简单气泡 -->
<template v-else-if="isUser"> <template v-else-if="isUser">
<div class="rounded-3xl bg-primary-50 px-5 py-3 text-gray-900 dark:bg-primary-500/50 dark:text-gray-100"> <div
v-if="isEditing"
class="rounded-2xl bg-primary-50 p-3 text-gray-900 dark:bg-primary-500/50 dark:text-gray-100"
>
<textarea
ref="editTextareaRef"
v-model="editContent"
class="max-h-64 min-h-28 w-full resize-y rounded-xl border border-primary-200 bg-white/90 px-3 py-2 text-sm leading-relaxed outline-none focus:border-primary-400 dark:border-primary-400/40 dark:bg-gray-900/70"
@keydown.esc.prevent="cancelEditing"
@keydown.ctrl.enter.prevent="submitEditing"
@keydown.meta.enter.prevent="submitEditing"
/>
<div class="mt-2 flex justify-end gap-2">
<UButton size="xs" variant="ghost" color="gray" @click="cancelEditing">
{{ t('common.cancel') }}
</UButton>
<UButton size="xs" color="primary" @click="submitEditing">
{{ t('ai.chat.message.edit.submit') }}
</UButton>
</div>
</div>
<div v-else class="rounded-3xl bg-primary-50 px-5 py-3 text-gray-900 dark:bg-primary-500/50 dark:text-gray-100">
<div class="prose prose-sm dark:prose-invert max-w-none leading-relaxed" v-html="renderedContent" /> <div class="prose prose-sm dark:prose-invert max-w-none leading-relaxed" v-html="renderedContent" />
</div> </div>
</template> </template>
@@ -480,6 +551,31 @@ async function handleCopyMarkdown() {
@click="handleCopyMarkdown" @click="handleCopyMarkdown"
/> />
</UTooltip> </UTooltip>
<UTooltip v-if="canEdit" :text="t('ai.chat.message.edit.tooltip')" class="no-capture">
<UButton icon="i-heroicons-pencil-square" variant="ghost" color="primary" size="xs" @click="startEditing" />
</UTooltip>
<div
v-if="hasBranchSwitcher"
class="no-capture flex items-center gap-1 rounded-full bg-gray-100 px-1 py-0.5 text-[11px] text-gray-500 dark:bg-gray-800 dark:text-gray-400"
>
<UButton
icon="i-heroicons-chevron-left"
variant="ghost"
color="gray"
size="xs"
:disabled="!branch?.prevMessageId"
@click="emit('branchPrev', branch?.prevMessageId ?? null)"
/>
<span class="min-w-8 text-center">{{ (branch?.index ?? 0) + 1 }} / {{ branch?.total ?? 1 }}</span>
<UButton
icon="i-heroicons-chevron-right"
variant="ghost"
color="gray"
size="xs"
:disabled="!branch?.nextMessageId"
@click="emit('branchNext', branch?.nextMessageId ?? null)"
/>
</div>
<!-- 截屏按钮(仅 AI 回复显示) --> <!-- 截屏按钮(仅 AI 回复显示) -->
<CaptureButton <CaptureButton
v-if="showCaptureButton && !isUser && !isStreaming" v-if="showCaptureButton && !isUser && !isStreaming"
+3
View File
@@ -66,6 +66,9 @@ export function useAIChat(
selectedAssistantId: toRef(state, 'selectedAssistantId'), selectedAssistantId: toRef(state, 'selectedAssistantId'),
sendMessage: (content: string, options?: { mentionedMembers?: MentionedMemberContext[] }) => sendMessage: (content: string, options?: { mentionedMembers?: MentionedMemberContext[] }) =>
aiChatStore.sendMessage(chatKey, content, options), aiChatStore.sendMessage(chatKey, content, options),
editMessageAndRegenerate: (messageId: string, content: string) =>
aiChatStore.editMessageAndRegenerate(chatKey, messageId, content),
switchMessageBranch: (messageId: string) => aiChatStore.switchMessageBranch(chatKey, messageId),
loadConversation: (conversationId: string) => aiChatStore.loadConversation(chatKey, conversationId), loadConversation: (conversationId: string) => aiChatStore.loadConversation(chatKey, conversationId),
startNewConversation: (welcomeMessage?: string) => aiChatStore.startNewConversation(chatKey, welcomeMessage), startNewConversation: (welcomeMessage?: string) => aiChatStore.startNewConversation(chatKey, welcomeMessage),
loadMoreSourceMessages: () => aiChatStore.loadMoreSourceMessages(), loadMoreSourceMessages: () => aiChatStore.loadMoreSourceMessages(),
+4
View File
@@ -53,6 +53,10 @@
"success": "Markdown copied to clipboard", "success": "Markdown copied to clipboard",
"failed": "Failed to copy Markdown" "failed": "Failed to copy Markdown"
}, },
"edit": {
"tooltip": "Edit and regenerate",
"submit": "Send"
},
"tools": { "tools": {
"get_chat_overview": "Get Chat Overview", "get_chat_overview": "Get Chat Overview",
"search_messages": "Search Messages", "search_messages": "Search Messages",
+4
View File
@@ -53,6 +53,10 @@
"success": "Markdown をクリップボードにコピーしました", "success": "Markdown をクリップボードにコピーしました",
"failed": "Markdown のコピーに失敗しました" "failed": "Markdown のコピーに失敗しました"
}, },
"edit": {
"tooltip": "編集して再生成",
"submit": "送信"
},
"tools": { "tools": {
"get_chat_overview": "チャット概要を取得", "get_chat_overview": "チャット概要を取得",
"search_messages": "チャット履歴を検索", "search_messages": "チャット履歴を検索",
+4
View File
@@ -53,6 +53,10 @@
"success": "Markdown 已复制到剪贴板", "success": "Markdown 已复制到剪贴板",
"failed": "复制 Markdown 失败" "failed": "复制 Markdown 失败"
}, },
"edit": {
"tooltip": "编辑并重新生成",
"submit": "发送"
},
"tools": { "tools": {
"get_chat_overview": "获取聊天概览", "get_chat_overview": "获取聊天概览",
"search_messages": "搜索聊天记录", "search_messages": "搜索聊天记录",
+4
View File
@@ -53,6 +53,10 @@
"success": "Markdown 已複製到剪貼簿", "success": "Markdown 已複製到剪貼簿",
"failed": "複製 Markdown 失敗" "failed": "複製 Markdown 失敗"
}, },
"edit": {
"tooltip": "編輯並重新生成",
"submit": "送出"
},
"tools": { "tools": {
"get_chat_overview": "取得聊天概覽", "get_chat_overview": "取得聊天概覽",
"search_messages": "搜尋聊天紀錄", "search_messages": "搜尋聊天紀錄",
+21
View File
@@ -5,6 +5,7 @@ import type {
AIMessageRole, AIMessageRole,
ContentBlock, ContentBlock,
TokenUsageData, TokenUsageData,
MessageBranchResult,
FilterResultWithPagination, FilterResultWithPagination,
ExportFilterParams, ExportFilterParams,
ExportProgress, ExportProgress,
@@ -63,6 +64,26 @@ export class ElectronAIAdapter implements AIAdapter {
) as Promise<AIMessage> ) as Promise<AIMessage>
} }
async createMessageBranch(
originalUserMessageId: string,
newUserContent: string,
assistantContent: string,
contentBlocks?: ContentBlock[],
tokenUsage?: TokenUsageData
): Promise<MessageBranchResult> {
return window.aiApi.createMessageBranch(
originalUserMessageId,
newUserContent,
assistantContent,
contentBlocks as any,
tokenUsage
) as Promise<MessageBranchResult>
}
async switchMessageBranch(conversationId: string, messageId: string): Promise<AIMessage[]> {
return window.aiApi.switchMessageBranch(conversationId, messageId) as Promise<AIMessage[]>
}
async getConversationTokenUsage(conversationId: string): Promise<TokenUsageData> { async getConversationTokenUsage(conversationId: string): Promise<TokenUsageData> {
return window.aiApi.getConversationTokenUsage(conversationId) return window.aiApi.getConversationTokenUsage(conversationId)
} }
+20
View File
@@ -12,6 +12,7 @@ import type {
AIMessageRole, AIMessageRole,
ContentBlock, ContentBlock,
TokenUsageData, TokenUsageData,
MessageBranchResult,
FilterResultWithPagination, FilterResultWithPagination,
ExportFilterParams, ExportFilterParams,
ExportProgress, ExportProgress,
@@ -76,6 +77,25 @@ export class FetchAIAdapter implements AIAdapter {
}) })
} }
async createMessageBranch(
originalUserMessageId: string,
newUserContent: string,
assistantContent: string,
contentBlocks?: ContentBlock[],
tokenUsage?: TokenUsageData
): Promise<MessageBranchResult> {
return post<MessageBranchResult>(`/ai/messages/${originalUserMessageId}/branches`, {
content: newUserContent,
assistantContent,
contentBlocks,
tokenUsage,
})
}
async switchMessageBranch(conversationId: string, messageId: string): Promise<AIMessage[]> {
return post<AIMessage[]>(`/ai/conversations/${conversationId}/branches/switch`, { messageId })
}
async getConversationTokenUsage(conversationId: string): Promise<TokenUsageData> { async getConversationTokenUsage(conversationId: string): Promise<TokenUsageData> {
return get<TokenUsageData>(`/ai/conversations/${conversationId}/token-usage`) return get<TokenUsageData>(`/ai/conversations/${conversationId}/token-usage`)
} }
+26
View File
@@ -5,12 +5,14 @@ export interface AIConversation {
sessionId: string sessionId: string
title: string | null title: string | null
assistantId: string assistantId: string
activeMessageId?: string | null
createdAt: number createdAt: number
updatedAt: number updatedAt: number
} }
export type ContentBlock = export type ContentBlock =
| { type: 'text'; text: string } | { type: 'text'; text: string }
| { type: 'think'; tag: string; text: string; durationMs?: number }
| { | {
type: 'tool' type: 'tool'
tool: { tool: {
@@ -21,6 +23,8 @@ export type ContentBlock =
} }
} }
| { type: 'skill'; skillId: string; skillName: string } | { type: 'skill'; skillId: string; skillName: string }
| { type: 'error'; error: { name: string | null; message: string; stack?: string | null } }
| { type: 'summary_meta'; bufferBoundaryTimestamp: number; compressedMessageCount: number }
export type AIMessageRole = 'user' | 'assistant' | 'summary' export type AIMessageRole = 'user' | 'assistant' | 'summary'
@@ -36,12 +40,26 @@ export interface AIMessage {
role: AIMessageRole role: AIMessageRole
content: string content: string
timestamp: number timestamp: number
parentId?: string | null
siblingGroupId?: string
branchIndex?: number
branch?: {
index: number
total: number
prevMessageId: string | null
nextMessageId: string | null
}
dataKeywords?: string[] dataKeywords?: string[]
dataMessageCount?: number dataMessageCount?: number
contentBlocks?: ContentBlock[] contentBlocks?: ContentBlock[]
tokenUsage?: TokenUsageData tokenUsage?: TokenUsageData
} }
export interface MessageBranchResult {
userMessage: AIMessage
assistantMessage: AIMessage
}
export interface DesensitizeRule { export interface DesensitizeRule {
id: string id: string
label: string label: string
@@ -162,6 +180,14 @@ export interface AIAdapter {
contentBlocks?: ContentBlock[], contentBlocks?: ContentBlock[],
tokenUsage?: TokenUsageData tokenUsage?: TokenUsageData
): Promise<AIMessage> ): Promise<AIMessage>
createMessageBranch(
originalUserMessageId: string,
newUserContent: string,
assistantContent: string,
contentBlocks?: ContentBlock[],
tokenUsage?: TokenUsageData
): Promise<MessageBranchResult>
switchMessageBranch(conversationId: string, messageId: string): Promise<AIMessage[]>
getConversationTokenUsage(conversationId: string): Promise<TokenUsageData> getConversationTokenUsage(conversationId: string): Promise<TokenUsageData>
estimateContextTokens( estimateContextTokens(
conversationId: string conversationId: string
+15
View File
@@ -5,6 +5,7 @@ import type {
AIMessageRole, AIMessageRole,
ContentBlock, ContentBlock,
TokenUsageData, TokenUsageData,
MessageBranchResult,
FilterResultWithPagination, FilterResultWithPagination,
ExportFilterParams, ExportFilterParams,
ExportProgress, ExportProgress,
@@ -65,6 +66,20 @@ export class WebAIAdapter implements AIAdapter {
throw new Error(NOT_AVAILABLE) throw new Error(NOT_AVAILABLE)
} }
async createMessageBranch(
_originalUserMessageId: string,
_newUserContent: string,
_assistantContent: string,
_contentBlocks?: ContentBlock[],
_tokenUsage?: TokenUsageData
): Promise<MessageBranchResult> {
throw new Error('AI message branching is not available in static web mode')
}
async switchMessageBranch(_conversationId: string, _messageId: string): Promise<AIMessage[]> {
throw new Error('AI message branching is not available in static web mode')
}
async getConversationTokenUsage(_conversationId: string): Promise<TokenUsageData> { async getConversationTokenUsage(_conversationId: string): Promise<TokenUsageData> {
return { promptTokens: 0, completionTokens: 0, totalTokens: 0 } return { promptTokens: 0, completionTokens: 0, totalTokens: 0 }
} }
+1
View File
@@ -314,6 +314,7 @@ async function installAiApiShims(): Promise<void> {
body: { body: {
userMessage, userMessage,
conversationId: context.conversationId, conversationId: context.conversationId,
historyLeafMessageId: context.historyLeafMessageId,
sessionId: context.sessionId, sessionId: context.sessionId,
chatType: chatType || 'group', chatType: chatType || 'group',
locale: locale || 'zh-CN', locale: locale || 'zh-CN',
+491 -18
View File
@@ -11,6 +11,7 @@ import { usePromptStore } from '@/stores/prompt'
import { useSessionStore } from '@/stores/session' import { useSessionStore } from '@/stores/session'
import { useSettingsStore } from '@/stores/settings' import { useSettingsStore } from '@/stores/settings'
import { useDataService, useAIService } from '@/services' import { useDataService, useAIService } from '@/services'
import type { AIMessage as PersistedAIMessage } from '@/services/ai/types'
import { useAssistantStore } from '@/stores/assistant' import { useAssistantStore } from '@/stores/assistant'
import { useSkillStore } from '@/stores/skill' import { useSkillStore } from '@/stores/skill'
import type { TokenUsage, AgentRuntimeStatus, SerializedErrorInfo } from '@electron/shared/types' import type { TokenUsage, AgentRuntimeStatus, SerializedErrorInfo } from '@electron/shared/types'
@@ -63,6 +64,15 @@ export interface ChatMessage {
role: 'user' | 'assistant' | 'summary' role: 'user' | 'assistant' | 'summary'
content: string content: string
timestamp: number timestamp: number
parentId?: string | null
siblingGroupId?: string
branchIndex?: number
branch?: {
index: number
total: number
prevMessageId: string | null
nextMessageId: string | null
}
dataSource?: { dataSource?: {
toolsUsed: string[] toolsUsed: string[]
toolRounds: number toolRounds: number
@@ -184,6 +194,20 @@ function createEmptyTokenUsage(): TokenUsage {
return { promptTokens: 0, completionTokens: 0, totalTokens: 0 } return { promptTokens: 0, completionTokens: 0, totalTokens: 0 }
} }
function toRuntimeMessage(msg: PersistedAIMessage): ChatMessage {
return {
id: msg.id,
role: msg.role,
content: msg.content,
timestamp: msg.timestamp * 1000,
parentId: msg.parentId,
siblingGroupId: msg.siblingGroupId,
branchIndex: msg.branchIndex,
branch: msg.branch,
contentBlocks: msg.contentBlocks as ContentBlock[] | undefined,
}
}
function createConversationBuffer(assistantId: string | null = null): ConversationBuffer { function createConversationBuffer(assistantId: string | null = null): ConversationBuffer {
return { return {
messages: [], messages: [],
@@ -455,17 +479,7 @@ export const useAIChatStore = defineStore('aiChatRuntime', () => {
useAIService().getMessages(conversationId), useAIService().getMessages(conversationId),
useAIService().getConversationTokenUsage(conversationId), useAIService().getConversationTokenUsage(conversationId),
]) ])
buffer.messages.splice( buffer.messages.splice(0, buffer.messages.length, ...history.map((msg) => toRuntimeMessage(msg)))
0,
buffer.messages.length,
...history.map((msg) => ({
id: msg.id,
role: msg.role,
content: msg.content,
timestamp: msg.timestamp * 1000,
contentBlocks: msg.contentBlocks as ContentBlock[] | undefined,
}))
)
buffer.sourceMessages.splice(0, buffer.sourceMessages.length) buffer.sourceMessages.splice(0, buffer.sourceMessages.length)
buffer.currentKeywords.splice(0, buffer.currentKeywords.length) buffer.currentKeywords.splice(0, buffer.currentKeywords.length)
buffer.sessionTokenUsage = tokenUsage buffer.sessionTokenUsage = tokenUsage
@@ -954,12 +968,20 @@ export const useAIChatStore = defineStore('aiChatRuntime', () => {
isStreaming: false, isStreaming: false,
} }
await saveConversation( const savedMessages = await saveConversation(
resolvedConversationId, resolvedConversationId,
userMessage, userMessage,
targetBuffer.messages[aiMessageIndex], targetBuffer.messages[aiMessageIndex],
lastDoneUsage lastDoneUsage
) )
if (savedMessages) {
Object.assign(userMessage, savedMessages.userMessage)
targetBuffer.messages[aiMessageIndex] = {
...targetBuffer.messages[aiMessageIndex],
...savedMessages.assistantMessage,
isStreaming: false,
}
}
} else if (!hasStreamError) { } else if (!hasStreamError) {
const blocks = targetBuffer.messages[aiMessageIndex].contentBlocks || [] const blocks = targetBuffer.messages[aiMessageIndex].contentBlocks || []
blocks.push({ blocks.push({
@@ -971,19 +993,35 @@ export const useAIChatStore = defineStore('aiChatRuntime', () => {
contentBlocks: [...blocks], contentBlocks: [...blocks],
isStreaming: false, isStreaming: false,
} }
await saveConversation( const savedMessages = await saveConversation(
resolvedConversationId, resolvedConversationId,
userMessage, userMessage,
targetBuffer.messages[aiMessageIndex], targetBuffer.messages[aiMessageIndex],
lastDoneUsage lastDoneUsage
) )
if (savedMessages) {
Object.assign(userMessage, savedMessages.userMessage)
targetBuffer.messages[aiMessageIndex] = {
...targetBuffer.messages[aiMessageIndex],
...savedMessages.assistantMessage,
isStreaming: false,
}
}
} else { } else {
await saveConversation( const savedMessages = await saveConversation(
resolvedConversationId, resolvedConversationId,
userMessage, userMessage,
targetBuffer.messages[aiMessageIndex], targetBuffer.messages[aiMessageIndex],
lastDoneUsage lastDoneUsage
) )
if (savedMessages) {
Object.assign(userMessage, savedMessages.userMessage)
targetBuffer.messages[aiMessageIndex] = {
...targetBuffer.messages[aiMessageIndex],
...savedMessages.assistantMessage,
isStreaming: false,
}
}
} }
return { success: true } return { success: true }
@@ -1027,17 +1065,17 @@ export const useAIChatStore = defineStore('aiChatRuntime', () => {
userMsg: ChatMessage, userMsg: ChatMessage,
aiMsg: ChatMessage, aiMsg: ChatMessage,
tokenUsage?: TokenUsage tokenUsage?: TokenUsage
): Promise<void> { ): Promise<{ userMessage: ChatMessage; assistantMessage: ChatMessage } | null> {
try { try {
if (!conversationId) { if (!conversationId) {
return return null
} }
await useAIService().addMessage(conversationId, 'user', userMsg.content) const savedUserMessage = await useAIService().addMessage(conversationId, 'user', userMsg.content)
const serializableContentBlocks = aiMsg.contentBlocks const serializableContentBlocks = aiMsg.contentBlocks
? JSON.parse(JSON.stringify(aiMsg.contentBlocks)) ? JSON.parse(JSON.stringify(aiMsg.contentBlocks))
: undefined : undefined
await useAIService().addMessage( const savedAssistantMessage = await useAIService().addMessage(
conversationId, conversationId,
'assistant', 'assistant',
aiMsg.content, aiMsg.content,
@@ -1046,8 +1084,441 @@ export const useAIChatStore = defineStore('aiChatRuntime', () => {
serializableContentBlocks, serializableContentBlocks,
tokenUsage tokenUsage
) )
return {
userMessage: toRuntimeMessage(savedUserMessage),
assistantMessage: toRuntimeMessage(savedAssistantMessage),
}
} catch (error) { } catch (error) {
console.error('[AI] 保存对话失败:', error) console.error('[AI] 保存对话失败:', error)
return null
}
}
function buildSerializablePreprocessConfig() {
const preprocessConfig = settingsStore.aiPreprocessConfig
const hasPreprocess =
preprocessConfig.dataCleaning ||
preprocessConfig.mergeConsecutive ||
preprocessConfig.blacklistKeywords.length > 0 ||
preprocessConfig.denoise ||
preprocessConfig.desensitize ||
preprocessConfig.anonymizeNames
if (!hasPreprocess) return undefined
return {
dataCleaning: preprocessConfig.dataCleaning,
mergeConsecutive: preprocessConfig.mergeConsecutive,
mergeWindowSeconds: preprocessConfig.mergeWindowSeconds,
blacklistKeywords: [...preprocessConfig.blacklistKeywords],
denoise: preprocessConfig.denoise,
desensitize: preprocessConfig.desensitize,
desensitizeRules: preprocessConfig.desensitizeRules.map((rule) => ({
...rule,
locales: [...rule.locales],
})),
anonymizeNames: preprocessConfig.anonymizeNames,
}
}
function normalizeMentionLookupText(value: string): string {
return value
.trim()
.replace(/^[\s"'“”‘’([{<]+|[\s"'“”‘’)\]}>.,!?;:,。!?;:、]+$/g, '')
.toLocaleLowerCase()
}
async function resolveMentionedMembersFromContent(
state: AIChatSessionState,
content: string
): Promise<MentionedMemberContext[]> {
const mentionTokens = new Set<string>()
for (const match of content.matchAll(/@([^\s@]+)/g)) {
const token = normalizeMentionLookupText(match[1] ?? '')
if (token) {
mentionTokens.add(token)
}
}
if (mentionTokens.size === 0) {
return []
}
try {
const members = await useDataService().getMembers(state.sessionId)
const displayNameCounts = new Map<string, number>()
members.forEach((member) => {
const displayName = member.groupNickname || member.accountName || member.platformId
displayNameCounts.set(displayName, (displayNameCounts.get(displayName) ?? 0) + 1)
})
const candidates = members.map((member) => {
const displayName = member.groupNickname || member.accountName || member.platformId
const insertName =
(displayNameCounts.get(displayName) ?? 0) > 1 ? `${displayName}·${member.platformId}` : displayName
const aliases = [...member.aliases]
const lookupValues = [
displayName,
member.groupNickname || '',
member.accountName || '',
member.platformId,
insertName,
...aliases,
]
.map(normalizeMentionLookupText)
.filter(Boolean)
return {
memberId: member.id,
platformId: member.platformId,
displayName,
aliases,
mentionText: `@${insertName}`,
lookupValues,
}
})
const selected: MentionedMemberContext[] = []
const selectedIds = new Set<number>()
for (const token of mentionTokens) {
const candidate = candidates.find(
(item) => !selectedIds.has(item.memberId) && item.lookupValues.includes(token)
)
if (!candidate) continue
selectedIds.add(candidate.memberId)
selected.push({
memberId: candidate.memberId,
platformId: candidate.platformId,
displayName: candidate.displayName,
aliases: candidate.aliases,
mentionText: candidate.mentionText,
})
}
return selected
} catch (error) {
console.error('[AI] Failed to resolve mentioned members for edited message:', error)
return []
}
}
async function editMessageAndRegenerate(
chatKey: string,
messageId: string,
newContent: string
): Promise<SendMessageResult> {
const state = getSessionState(chatKey)
const content = newContent.trim()
if (!state || !state.currentConversationId) return { success: false, reason: 'error' }
if (!content) return { success: false, reason: 'empty' }
if (state.isAIThinking || activeTask.value) {
return { success: false, reason: 'busy', activeTask: activeTask.value }
}
const targetBuffer = getOrCreateBuffer(state, state.currentConversationId, state.selectedAssistantId)
const editIndex = targetBuffer.messages.findIndex((message) => message.id === messageId)
const originalMessage = targetBuffer.messages[editIndex]
if (!originalMessage || originalMessage.role !== 'user' || originalMessage.isStreaming) {
return { success: false, reason: 'error' }
}
if (originalMessage.content.trim() === content) {
return { success: false, reason: 'empty' }
}
const thisRequestId = generateId('req')
let lastDoneUsage: TokenUsage | undefined
let hasStreamError = false
setActiveTaskMeta(chatKey, content, thisRequestId, state.currentConversationId)
applySessionAssistantSelection(chatKey)
void ensureOwnerInfo(chatKey)
state.isAIThinking = true
state.isLoadingSource = true
state.currentToolStatus = null
state.toolsUsedInCurrentRound = []
state.agentStatus = null
state.isAborted = false
state.currentRequestId = thisRequestId
state.currentAgentRequestId = ''
const userMessage: ChatMessage = {
id: generateId('user'),
role: 'user',
content,
timestamp: Date.now(),
parentId: originalMessage.parentId ?? null,
toolCalls: [],
}
const aiMessage: ChatMessage = {
id: generateId('ai'),
role: 'assistant',
content: '',
timestamp: Date.now(),
isStreaming: true,
contentBlocks: [],
}
const currentSkillId = skillStore.activeSkillId
const currentSkillName = skillStore.activeSkill?.name
if (currentSkillId && currentSkillName) {
aiMessage.contentBlocks!.push({ type: 'skill', skillId: currentSkillId, skillName: currentSkillName })
}
const previousTail = targetBuffer.messages.slice(editIndex)
targetBuffer.messages.splice(editIndex, targetBuffer.messages.length - editIndex, userMessage, aiMessage)
const aiMessageIndex = editIndex + 1
const restorePreviousTail = () => {
targetBuffer.messages.splice(editIndex, targetBuffer.messages.length - editIndex, ...previousTail)
}
const updateAIMessage = (updates: Partial<ChatMessage>) => {
targetBuffer.messages[aiMessageIndex] = {
...targetBuffer.messages[aiMessageIndex],
...updates,
}
}
const appendTextToBlocks = (text: string) => {
if (!text) return
const blocks = targetBuffer.messages[aiMessageIndex].contentBlocks || []
const lastBlock = blocks[blocks.length - 1]
if (text.trim().length === 0 && (!lastBlock || lastBlock.type !== 'text')) return
if (lastBlock && lastBlock.type === 'text') {
lastBlock.text += text
} else {
blocks.push({ type: 'text', text })
}
updateAIMessage({
contentBlocks: [...blocks],
content: targetBuffer.messages[aiMessageIndex].content + text,
})
}
const appendThinkToBlocks = (text: string, tag?: string, durationMs?: number) => {
if (!text && durationMs === undefined) return
const blocks = targetBuffer.messages[aiMessageIndex].contentBlocks || []
const thinkTag = tag || 'think'
const lastBlock = blocks[blocks.length - 1]
let targetBlock: ContentBlock | undefined = lastBlock
if (lastBlock && lastBlock.type === 'think' && lastBlock.tag === thinkTag) {
lastBlock.text += text
} else if (text.trim().length > 0) {
targetBlock = { type: 'think', tag: thinkTag, text }
blocks.push(targetBlock)
} else if (durationMs !== undefined) {
targetBlock = [...blocks].reverse().find((block) => block.type === 'think' && block.tag === thinkTag)
}
if (durationMs !== undefined && targetBlock && targetBlock.type === 'think') {
targetBlock.durationMs = durationMs
}
updateAIMessage({ contentBlocks: [...blocks] })
}
const addToolBlock = (toolName: string, params?: Record<string, unknown>) => {
const blocks = targetBuffer.messages[aiMessageIndex].contentBlocks || []
blocks.push({
type: 'tool',
tool: { name: toolName, displayName: toolName, status: 'running', params },
})
updateAIMessage({ contentBlocks: [...blocks] })
}
const updateToolBlockStatus = (toolName: string, status: 'done' | 'error') => {
const blocks = targetBuffer.messages[aiMessageIndex].contentBlocks || []
for (let index = blocks.length - 1; index >= 0; index--) {
const block = blocks[index]
if (block.type === 'tool' && block.tool.name === toolName && block.tool.status === 'running') {
block.tool.status = status
break
}
}
updateAIMessage({ contentBlocks: [...blocks] })
}
try {
const hasConfig = await window.llmApi.hasConfig()
if (!hasConfig) {
restorePreviousTail()
clearActiveTask(chatKey, thisRequestId)
return { success: false, reason: 'no_config' }
}
const currentAssistantId = targetBuffer.assistantId ?? getDefaultGeneralAssistantId(state.locale)
const currentMentionedMembers = await resolveMentionedMembersFromContent(state, content)
if (state.isAborted) {
restorePreviousTail()
clearActiveTask(chatKey, thisRequestId)
return { success: false, reason: 'aborted' }
}
if (thisRequestId !== state.currentRequestId) {
restorePreviousTail()
clearActiveTask(chatKey, thisRequestId)
return { success: false, reason: 'busy', activeTask: activeTask.value }
}
const context = {
sessionId: state.sessionId,
conversationId: state.currentConversationId,
historyLeafMessageId: originalMessage.parentId ?? null,
timeFilter: state.timeFilter ? { startTs: state.timeFilter.startTs, endTs: state.timeFilter.endTs } : undefined,
maxMessagesLimit: aiGlobalSettings.value.maxMessagesPerRequest,
ownerInfo: state.ownerInfo
? { platformId: state.ownerInfo.platformId, displayName: state.ownerInfo.displayName }
: undefined,
mentionedMembers: currentMentionedMembers.length > 0 ? currentMentionedMembers : undefined,
preprocessConfig: buildSerializablePreprocessConfig(),
searchContextBefore: aiGlobalSettings.value.searchContextBefore,
searchContextAfter: aiGlobalSettings.value.searchContextAfter,
}
const { requestId: agentReqId, promise: agentPromise } = window.agentApi.runStream(
content,
context,
(chunk) => {
if (state.isAborted || thisRequestId !== state.currentRequestId) return
switch (chunk.type) {
case 'content':
state.currentToolStatus = null
appendTextToBlocks(chunk.content || '')
break
case 'think':
if (chunk.content) appendThinkToBlocks(chunk.content, chunk.thinkTag)
else if (chunk.thinkDurationMs !== undefined)
appendThinkToBlocks('', chunk.thinkTag, chunk.thinkDurationMs)
break
case 'tool_start':
if (chunk.toolName) {
state.currentToolStatus = { name: chunk.toolName, displayName: chunk.toolName, status: 'running' }
state.toolsUsedInCurrentRound.push(chunk.toolName)
addToolBlock(chunk.toolName, chunk.toolParams as Record<string, unknown> | undefined)
}
break
case 'tool_result':
if (chunk.toolName) updateToolBlockStatus(chunk.toolName, 'done')
state.currentToolStatus = null
state.isLoadingSource = false
break
case 'status':
if (chunk.status && (!state.agentStatus || chunk.status.updatedAt >= state.agentStatus.updatedAt)) {
state.agentStatus = chunk.status
}
break
case 'done':
state.currentToolStatus = null
if (chunk.usage) lastDoneUsage = { ...chunk.usage }
setAgentPhase(state, 'completed', chunk.usage ? { totalUsage: chunk.usage } : undefined)
break
case 'error': {
hasStreamError = true
if (state.currentToolStatus) updateToolBlockStatus(state.currentToolStatus.name, 'error')
const blocks = targetBuffer.messages[aiMessageIndex].contentBlocks || []
blocks.push({ type: 'error', error: chunk.error || { name: null, message: '未知错误', stack: null } })
updateAIMessage({ contentBlocks: [...blocks], isStreaming: false })
setAgentPhase(state, 'error')
break
}
}
},
state.chatType,
state.locale,
currentAssistantId,
currentSkillId,
!currentSkillId ? (aiGlobalSettings.value.enableAutoSkill ?? true) : undefined,
{ enabled: false, tokenThresholdPercent: 75, bufferSizePercent: 20 }
)
state.currentAgentRequestId = agentReqId
setActiveTaskMeta(chatKey, content, agentReqId, state.currentConversationId)
const result = await agentPromise
if (state.isAborted) {
restorePreviousTail()
clearActiveTask(chatKey, agentReqId)
return { success: false, reason: 'aborted' }
}
if (thisRequestId !== state.currentRequestId) {
restorePreviousTail()
clearActiveTask(chatKey, agentReqId)
return { success: false, reason: 'busy', activeTask: activeTask.value }
}
if (result.success && result.result) {
updateAIMessage({
dataSource: { toolsUsed: result.result.toolsUsed, toolRounds: result.result.toolRounds },
isStreaming: false,
})
} else if (!hasStreamError) {
const blocks = targetBuffer.messages[aiMessageIndex].contentBlocks || []
blocks.push({ type: 'error', error: result.error || { name: null, message: '未知错误', stack: null } })
updateAIMessage({ contentBlocks: [...blocks], isStreaming: false })
}
const serializableContentBlocks = targetBuffer.messages[aiMessageIndex].contentBlocks
? JSON.parse(JSON.stringify(targetBuffer.messages[aiMessageIndex].contentBlocks))
: undefined
const branch = await useAIService().createMessageBranch(
messageId,
userMessage.content,
targetBuffer.messages[aiMessageIndex].content,
serializableContentBlocks,
lastDoneUsage
)
targetBuffer.messages.splice(
editIndex,
targetBuffer.messages.length - editIndex,
toRuntimeMessage(branch.userMessage),
{
...toRuntimeMessage(branch.assistantMessage),
dataSource: targetBuffer.messages[aiMessageIndex].dataSource,
isStreaming: false,
}
)
targetBuffer.sessionTokenUsage = await useAIService().getConversationTokenUsage(state.currentConversationId)
state.sessionTokenUsage = { ...targetBuffer.sessionTokenUsage }
return { success: true }
} catch (error) {
if (state.isAborted) {
restorePreviousTail()
return { success: false, reason: 'aborted' }
}
console.error('[AI] 编辑并重新生成失败:', error)
const blocks = targetBuffer.messages[aiMessageIndex].contentBlocks || []
blocks.push({
type: 'error',
error: {
name: error instanceof Error ? error.name : null,
message: error instanceof Error ? error.message : String(error),
stack: error instanceof Error ? (error.stack ?? null) : null,
},
})
updateAIMessage({ contentBlocks: [...blocks], isStreaming: false })
return { success: false, reason: 'error' }
} finally {
state.isAIThinking = false
state.isLoadingSource = false
state.currentToolStatus = null
state.isAborted = false
state.currentRequestId = ''
state.currentAgentRequestId = ''
clearActiveTask(chatKey)
}
}
async function switchMessageBranch(chatKey: string, messageId: string): Promise<boolean> {
const state = getSessionState(chatKey)
if (!state?.currentConversationId || state.isAIThinking || activeTask.value) return false
const buffer = getOrCreateBuffer(state, state.currentConversationId, state.selectedAssistantId)
try {
const messages = await useAIService().switchMessageBranch(state.currentConversationId, messageId)
const tokenUsage = await useAIService().getConversationTokenUsage(state.currentConversationId)
buffer.messages.splice(0, buffer.messages.length, ...messages.map((message) => toRuntimeMessage(message)))
buffer.sessionTokenUsage = tokenUsage
state.sessionTokenUsage = { ...tokenUsage }
return true
} catch (error) {
console.error('[AI] 切换消息分支失败:', error)
return false
} }
} }
@@ -1108,6 +1579,8 @@ export const useAIChatStore = defineStore('aiChatRuntime', () => {
loadMoreSourceMessages, loadMoreSourceMessages,
updateMaxMessages, updateMaxMessages,
sendMessage, sendMessage,
editMessageAndRegenerate,
switchMessageBranch,
stopGeneration, stopGeneration,
stopActiveTask, stopActiveTask,
} }