mirror of
https://github.com/hellodigua/ChatLab.git
synced 2026-10-26 02:04:37 +08:00
feat(ai): support editable message branches
This commit is contained in:
1 parent
3a8f61fdf0
commit
d3c62c4f9d
26 files changed
+1465
-141
No files matched your search
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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 []
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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
|
||||||
/** 时间过滤器 */
|
/** 时间过滤器 */
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Vendored
+27
-1
@@ -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,
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -32,6 +32,7 @@ export type {
|
|||||||
ContentBlock,
|
ContentBlock,
|
||||||
TokenUsageData,
|
TokenUsageData,
|
||||||
ConversationManagerLogger,
|
ConversationManagerLogger,
|
||||||
|
MessageBranchResult,
|
||||||
} from './conversations'
|
} from './conversations'
|
||||||
|
|
||||||
// Tokenizer
|
// Tokenizer
|
||||||
|
|||||||
@@ -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'
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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": "チャット履歴を検索",
|
||||||
|
|||||||
@@ -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": "搜索聊天记录",
|
||||||
|
|||||||
@@ -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": "搜尋聊天紀錄",
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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`)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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,
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in new issue
Block a user