diff --git a/electron/main.ts b/electron/main.ts index f6a873a..4c7a577 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -31,6 +31,10 @@ import { destroyNotificationWindow, registerNotificationHandlers, showNotificati import { httpService } from './services/httpService' import { messagePushService } from './services/messagePushService' import { insightService } from './services/insightService' +import { aiAnalysisService } from './services/aiAnalysisService' +import { aiAgentService } from './services/aiAgentService' +import { aiAssistantService } from './services/aiAssistantService' +import { aiSkillService } from './services/aiSkillService' import { bizService } from './services/bizService' // 配置自动更新 @@ -1598,6 +1602,14 @@ const runLegacySnsCacheMigration = async ( return { copied, skipped, totalFiles: total } } +async function ensureAiSqlLabConnected(): Promise<{ success: boolean; error?: string }> { + const connectResult = await chatService.connect() + if (!connectResult.success) { + return { success: false, error: connectResult.error || '数据库未连接' } + } + return { success: true } +} + // 注册 IPC 处理器 function registerIpcHandlers() { registerNotificationHandlers() @@ -1651,6 +1663,164 @@ function registerIpcHandlers() { return insightService.generateFootprintInsight(payload) }) + // ==================== AI Analysis V2 ==================== + ipcMain.handle('ai:listConversations', async (_, payload?: { page?: number; pageSize?: number }) => + aiAnalysisService.listConversations(payload?.page, payload?.pageSize) + ) + ipcMain.handle('ai:createConversation', async (_, payload?: { title?: string }) => + aiAnalysisService.createConversation(payload?.title || '') + ) + ipcMain.handle('ai:renameConversation', async (_, payload: { conversationId: string; title: string }) => + aiAnalysisService.renameConversation(payload.conversationId, payload.title) + ) + ipcMain.handle('ai:deleteConversation', async (_, conversationId: string) => + aiAnalysisService.deleteConversation(conversationId) + ) + ipcMain.handle('ai:listMessages', async (_, payload: { conversationId: string; limit?: number }) => + aiAnalysisService.listMessages(payload.conversationId, payload.limit) + ) + ipcMain.handle('ai:exportConversation', async (_, payload: { conversationId: string }) => + aiAnalysisService.exportConversation(payload.conversationId) + ) + ipcMain.handle('ai:getToolCatalog', async () => aiAnalysisService.getToolCatalog()) + ipcMain.handle('ai:executeTool', async (_, payload: { name: string; args?: Record }) => + aiAnalysisService.executeTool(payload.name, payload.args || {}) + ) + ipcMain.handle('ai:cancelToolTest', async (_, payload?: { taskId?: string }) => + aiAnalysisService.cancelToolTest(payload?.taskId) + ) + + ipcMain.handle('agent:runStream', async (event, payload: { + mode?: 'chat' | 'sql' + conversationId?: string + userInput: string + assistantId?: string + activeSkillId?: string + chatScope?: 'group' | 'private' + sqlContext?: { schemaText?: string; targetHint?: string } + }) => { + return aiAgentService.runStream(payload, { + onChunk: (chunk) => { + try { + event.sender.send('agent:stream', chunk) + } catch { + // ignore sender errors + } + } + }) + }) + ipcMain.handle('agent:abort', async (_, payload: { runId?: string; conversationId?: string }) => + aiAgentService.abort(payload || {}) + ) + + ipcMain.handle('assistant:getAll', async () => aiAssistantService.getAll()) + ipcMain.handle('assistant:getConfig', async (_, id: string) => aiAssistantService.getConfig(id)) + ipcMain.handle('assistant:create', async (_, payload: any) => aiAssistantService.create(payload || {})) + ipcMain.handle('assistant:update', async (_, payload: { id: string; updates: any }) => + aiAssistantService.update(payload.id, payload.updates || {}) + ) + ipcMain.handle('assistant:delete', async (_, id: string) => aiAssistantService.delete(id)) + ipcMain.handle('assistant:reset', async (_, id: string) => aiAssistantService.reset(id)) + ipcMain.handle('assistant:getBuiltinCatalog', async () => aiAssistantService.getBuiltinCatalog()) + ipcMain.handle('assistant:getBuiltinToolCatalog', async () => aiAssistantService.getBuiltinToolCatalog()) + ipcMain.handle('assistant:importFromMd', async (_, rawMd: string) => aiAssistantService.importFromMd(rawMd)) + + ipcMain.handle('skill:getAll', async () => aiSkillService.getAll()) + ipcMain.handle('skill:getConfig', async (_, id: string) => aiSkillService.getConfig(id)) + ipcMain.handle('skill:create', async (_, rawMd: string) => aiSkillService.create(rawMd)) + ipcMain.handle('skill:update', async (_, payload: { id: string; rawMd: string }) => + aiSkillService.update(payload.id, payload.rawMd) + ) + ipcMain.handle('skill:delete', async (_, id: string) => aiSkillService.delete(id)) + ipcMain.handle('skill:getBuiltinCatalog', async () => aiSkillService.getBuiltinCatalog()) + ipcMain.handle('skill:importFromMd', async (_, rawMd: string) => aiSkillService.importFromMd(rawMd)) + + ipcMain.handle('llm:getConfig', async () => ({ + success: true, + config: { + apiBaseUrl: String(configService?.get('aiModelApiBaseUrl') || ''), + apiKey: String(configService?.get('aiModelApiKey') || ''), + model: String(configService?.get('aiModelApiModel') || 'gpt-4o-mini') + } + })) + ipcMain.handle('llm:setConfig', async (_, payload: { apiBaseUrl?: string; apiKey?: string; model?: string }) => { + if (typeof payload?.apiBaseUrl === 'string') configService?.set('aiModelApiBaseUrl', payload.apiBaseUrl) + if (typeof payload?.apiKey === 'string') configService?.set('aiModelApiKey', payload.apiKey) + if (typeof payload?.model === 'string') configService?.set('aiModelApiModel', payload.model) + return { success: true } + }) + ipcMain.handle('llm:listModels', async () => ({ + success: true, + models: [ + { id: 'gpt-4o-mini', label: 'gpt-4o-mini' }, + { id: 'gpt-4o', label: 'gpt-4o' }, + { id: 'gpt-5-mini', label: 'gpt-5-mini' } + ] + })) + + ipcMain.handle('chat:getSchema', async (_, payload?: { sessionId?: string }) => { + const connectResult = await ensureAiSqlLabConnected() + if (!connectResult.success) { + return { success: false, error: connectResult.error || '数据库未连接' } + } + return wcdbService.sqlLabGetSchema(payload) + }) + ipcMain.handle('chat:executeSQL', async (_, payload: { + kind: 'message' | 'contact' | 'biz' + path?: string | null + sql: string + limit?: number + }) => { + const connectResult = await ensureAiSqlLabConnected() + if (!connectResult.success) { + return { success: false, error: connectResult.error || '数据库未连接' } + } + return wcdbService.sqlLabExecuteReadonly(payload) + }) + + // 兼容层:旧 aiAnalysis API 转调新实现 + ipcMain.handle('aiAnalysis:listConversations', async (_, payload?: { page?: number; pageSize?: number }) => + aiAnalysisService.listConversations(payload?.page, payload?.pageSize) + ) + ipcMain.handle('aiAnalysis:createConversation', async (_, payload?: { title?: string }) => + aiAnalysisService.createConversation(payload?.title || '') + ) + ipcMain.handle('aiAnalysis:deleteConversation', async (_, conversationId: string) => + aiAnalysisService.deleteConversation(conversationId) + ) + ipcMain.handle('aiAnalysis:listMessages', async (_, payload: { conversationId: string; limit?: number }) => + aiAnalysisService.listMessages(payload.conversationId, payload.limit) + ) + ipcMain.handle('aiAnalysis:sendMessage', async (event, payload: { + conversationId: string + userInput: string + options?: { parentMessageId?: string; persistUserMessage?: boolean; assistantId?: string; activeSkillId?: string } + }) => + aiAnalysisService.sendMessage(payload.conversationId, payload.userInput, payload.options, { + onRunEvent: (runEvent) => { + try { + event.sender.send('aiAnalysis:runEvent', runEvent) + } catch { + // ignore sender errors + } + } + }) + ) + ipcMain.handle('aiAnalysis:retryMessage', async (event, payload: { conversationId: string; userMessageId?: string }) => + aiAnalysisService.retryMessage(payload, { + onRunEvent: (runEvent) => { + try { + event.sender.send('aiAnalysis:runEvent', runEvent) + } catch { + // ignore sender errors + } + } + }) + ) + ipcMain.handle('aiAnalysis:abortRun', async (_, payload: { runId?: string; conversationId?: string }) => + aiAnalysisService.abortRun(payload || {}) + ) + ipcMain.handle('config:clear', async () => { if (isLaunchAtStartupSupported() && getSystemLaunchAtStartup()) { const result = setSystemLaunchAtStartup(false) diff --git a/electron/preload.ts b/electron/preload.ts index 838a305..6c4784e 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -276,6 +276,13 @@ contextBridge.exposeInMainWorld('electronAPI', { format: 'csv' | 'json', filePath: string ) => ipcRenderer.invoke('chat:exportMyFootprint', beginTimestamp, endTimestamp, format, filePath), + getSchema: (payload?: { sessionId?: string }) => ipcRenderer.invoke('chat:getSchema', payload), + executeSQL: (payload: { + kind: 'message' | 'contact' | 'biz' + path?: string | null + sql: string + limit?: number + }) => ipcRenderer.invoke('chat:executeSQL', payload), onWcdbChange: (callback: (event: any, data: { type: string; json: string }) => void) => { ipcRenderer.on('wcdb-change', callback) return () => ipcRenderer.removeListener('wcdb-change', callback) @@ -540,5 +547,174 @@ contextBridge.exposeInMainWorld('electronAPI', { privateSegments?: Array<{ displayName?: string; session_id?: string; incoming_count?: number; outgoing_count?: number; message_count?: number; replied?: boolean }> mentionGroups?: Array<{ displayName?: string; session_id?: string; count?: number }> }) => ipcRenderer.invoke('insight:generateFootprintInsight', payload) + }, + + aiApi: { + listConversations: (payload?: { page?: number; pageSize?: number }) => + ipcRenderer.invoke('ai:listConversations', payload), + createConversation: (payload?: { title?: string }) => + ipcRenderer.invoke('ai:createConversation', payload), + renameConversation: (payload: { conversationId: string; title: string }) => + ipcRenderer.invoke('ai:renameConversation', payload), + deleteConversation: (conversationId: string) => + ipcRenderer.invoke('ai:deleteConversation', conversationId), + listMessages: (payload: { conversationId: string; limit?: number }) => + ipcRenderer.invoke('ai:listMessages', payload), + exportConversation: (payload: { conversationId: string }) => + ipcRenderer.invoke('ai:exportConversation', payload), + getToolCatalog: () => ipcRenderer.invoke('ai:getToolCatalog'), + executeTool: (payload: { name: string; args?: Record }) => + ipcRenderer.invoke('ai:executeTool', payload), + cancelToolTest: (payload?: { taskId?: string }) => + ipcRenderer.invoke('ai:cancelToolTest', payload) + }, + + agentApi: { + runStream: (payload: { + mode?: 'chat' | 'sql' + conversationId?: string + userInput: string + assistantId?: string + activeSkillId?: string + chatScope?: 'group' | 'private' + sqlContext?: { schemaText?: string; targetHint?: string } + }) => ipcRenderer.invoke('agent:runStream', payload), + abort: (payload: { runId?: string; conversationId?: string }) => + ipcRenderer.invoke('agent:abort', payload), + onStream: (callback: (payload: any) => void) => { + const listener = (_: unknown, payload: any) => callback(payload) + ipcRenderer.on('agent:stream', listener) + return () => ipcRenderer.removeListener('agent:stream', listener) + } + }, + + assistantApi: { + getAll: () => ipcRenderer.invoke('assistant:getAll'), + getConfig: (id: string) => ipcRenderer.invoke('assistant:getConfig', id), + create: (payload: any) => ipcRenderer.invoke('assistant:create', payload), + update: (payload: { id: string; updates: any }) => ipcRenderer.invoke('assistant:update', payload), + delete: (id: string) => ipcRenderer.invoke('assistant:delete', id), + reset: (id: string) => ipcRenderer.invoke('assistant:reset', id), + getBuiltinCatalog: () => ipcRenderer.invoke('assistant:getBuiltinCatalog'), + getBuiltinToolCatalog: () => ipcRenderer.invoke('assistant:getBuiltinToolCatalog'), + importFromMd: (rawMd: string) => ipcRenderer.invoke('assistant:importFromMd', rawMd) + }, + + skillApi: { + getAll: () => ipcRenderer.invoke('skill:getAll'), + getConfig: (id: string) => ipcRenderer.invoke('skill:getConfig', id), + create: (rawMd: string) => ipcRenderer.invoke('skill:create', rawMd), + update: (payload: { id: string; rawMd: string }) => ipcRenderer.invoke('skill:update', payload), + delete: (id: string) => ipcRenderer.invoke('skill:delete', id), + getBuiltinCatalog: () => ipcRenderer.invoke('skill:getBuiltinCatalog'), + importFromMd: (rawMd: string) => ipcRenderer.invoke('skill:importFromMd', rawMd) + }, + + llmApi: { + getConfig: () => ipcRenderer.invoke('llm:getConfig'), + setConfig: (payload: { apiBaseUrl?: string; apiKey?: string; model?: string }) => + ipcRenderer.invoke('llm:setConfig', payload), + listModels: () => ipcRenderer.invoke('llm:listModels') + }, + + aiAnalysis: { + listConversations: (payload?: { page?: number; pageSize?: number }) => + ipcRenderer.invoke('aiAnalysis:listConversations', payload), + createConversation: (payload?: { title?: string }) => + ipcRenderer.invoke('aiAnalysis:createConversation', payload), + deleteConversation: (conversationId: string) => + ipcRenderer.invoke('aiAnalysis:deleteConversation', conversationId), + listMessages: (payload: { conversationId: string; limit?: number }) => + ipcRenderer.invoke('aiAnalysis:listMessages', payload), + sendMessage: (payload: { + conversationId: string + userInput: string + options?: { + parentMessageId?: string + persistUserMessage?: boolean + assistantId?: string + activeSkillId?: string + chatScope?: 'group' | 'private' + } + }) => ipcRenderer.invoke('aiAnalysis:sendMessage', payload), + retryMessage: (payload: { conversationId: string; userMessageId?: string }) => + ipcRenderer.invoke('aiAnalysis:retryMessage', payload), + abortRun: (payload: { runId?: string; conversationId?: string }) => + ipcRenderer.invoke('aiAnalysis:abortRun', payload), + onRunEvent: (callback: (payload: { + runId: string + conversationId: string + stage: string + ts: number + message: string + intent?: string + round?: number + toolName?: string + status?: string + durationMs?: number + data?: Record + }) => void) => { + const listener = (_: unknown, payload: any) => callback(payload) + ipcRenderer.on('aiAnalysis:runEvent', listener) + return () => ipcRenderer.removeListener('aiAnalysis:runEvent', listener) + } } }) + +contextBridge.exposeInMainWorld('aiApi', { + listConversations: (payload?: { page?: number; pageSize?: number }) => ipcRenderer.invoke('ai:listConversations', payload), + createConversation: (payload?: { title?: string }) => ipcRenderer.invoke('ai:createConversation', payload), + renameConversation: (payload: { conversationId: string; title: string }) => ipcRenderer.invoke('ai:renameConversation', payload), + deleteConversation: (conversationId: string) => ipcRenderer.invoke('ai:deleteConversation', conversationId), + listMessages: (payload: { conversationId: string; limit?: number }) => ipcRenderer.invoke('ai:listMessages', payload), + exportConversation: (payload: { conversationId: string }) => ipcRenderer.invoke('ai:exportConversation', payload), + getToolCatalog: () => ipcRenderer.invoke('ai:getToolCatalog'), + executeTool: (payload: { name: string; args?: Record }) => ipcRenderer.invoke('ai:executeTool', payload), + cancelToolTest: (payload?: { taskId?: string }) => ipcRenderer.invoke('ai:cancelToolTest', payload) +}) + +contextBridge.exposeInMainWorld('agentApi', { + runStream: (payload: { + mode?: 'chat' | 'sql' + conversationId?: string + userInput: string + assistantId?: string + activeSkillId?: string + chatScope?: 'group' | 'private' + sqlContext?: { schemaText?: string; targetHint?: string } + }) => ipcRenderer.invoke('agent:runStream', payload), + abort: (payload: { runId?: string; conversationId?: string }) => ipcRenderer.invoke('agent:abort', payload), + onStream: (callback: (payload: any) => void) => { + const listener = (_: unknown, payload: any) => callback(payload) + ipcRenderer.on('agent:stream', listener) + return () => ipcRenderer.removeListener('agent:stream', listener) + } +}) + +contextBridge.exposeInMainWorld('assistantApi', { + getAll: () => ipcRenderer.invoke('assistant:getAll'), + getConfig: (id: string) => ipcRenderer.invoke('assistant:getConfig', id), + create: (payload: any) => ipcRenderer.invoke('assistant:create', payload), + update: (payload: { id: string; updates: any }) => ipcRenderer.invoke('assistant:update', payload), + delete: (id: string) => ipcRenderer.invoke('assistant:delete', id), + reset: (id: string) => ipcRenderer.invoke('assistant:reset', id), + getBuiltinCatalog: () => ipcRenderer.invoke('assistant:getBuiltinCatalog'), + getBuiltinToolCatalog: () => ipcRenderer.invoke('assistant:getBuiltinToolCatalog'), + importFromMd: (rawMd: string) => ipcRenderer.invoke('assistant:importFromMd', rawMd) +}) + +contextBridge.exposeInMainWorld('skillApi', { + getAll: () => ipcRenderer.invoke('skill:getAll'), + getConfig: (id: string) => ipcRenderer.invoke('skill:getConfig', id), + create: (rawMd: string) => ipcRenderer.invoke('skill:create', rawMd), + update: (payload: { id: string; rawMd: string }) => ipcRenderer.invoke('skill:update', payload), + delete: (id: string) => ipcRenderer.invoke('skill:delete', id), + getBuiltinCatalog: () => ipcRenderer.invoke('skill:getBuiltinCatalog'), + importFromMd: (rawMd: string) => ipcRenderer.invoke('skill:importFromMd', rawMd) +}) + +contextBridge.exposeInMainWorld('llmApi', { + getConfig: () => ipcRenderer.invoke('llm:getConfig'), + setConfig: (payload: { apiBaseUrl?: string; apiKey?: string; model?: string }) => ipcRenderer.invoke('llm:setConfig', payload), + listModels: () => ipcRenderer.invoke('llm:listModels') +}) diff --git a/electron/services/aiAgentService.ts b/electron/services/aiAgentService.ts new file mode 100644 index 0000000..dc12579 --- /dev/null +++ b/electron/services/aiAgentService.ts @@ -0,0 +1,450 @@ +import http from 'http' +import https from 'https' +import { randomUUID } from 'crypto' +import { URL } from 'url' +import { ConfigService } from './config' +import { aiAnalysisService, type AiAnalysisRunEvent } from './aiAnalysisService' + +export interface TokenUsage { + promptTokens?: number + completionTokens?: number + totalTokens?: number +} + +export interface AgentRuntimeStatus { + phase: 'idle' | 'thinking' | 'tool_running' | 'responding' | 'completed' | 'error' | 'aborted' + round?: number + currentTool?: string + toolsUsed?: number + updatedAt: number + totalUsage?: TokenUsage +} + +export interface AgentStreamChunk { + runId: string + conversationId?: string + type: 'content' | 'think' | 'tool_start' | 'tool_result' | 'status' | 'done' | 'error' + content?: string + thinkTag?: string + thinkDurationMs?: number + toolName?: string + toolParams?: Record + toolResult?: unknown + error?: string + isFinished?: boolean + usage?: TokenUsage + status?: AgentRuntimeStatus +} + +export interface AgentRunPayload { + mode?: 'chat' | 'sql' + conversationId?: string + userInput: string + assistantId?: string + activeSkillId?: string + chatScope?: 'group' | 'private' + sqlContext?: { + schemaText?: string + targetHint?: string + } +} + +interface ActiveAgentRun { + runId: string + mode: 'chat' | 'sql' + conversationId?: string + innerRunId?: string + aborted: boolean +} + +function normalizeText(value: unknown, fallback = ''): string { + const text = String(value ?? '').trim() + return text || fallback +} + +function buildApiUrl(baseUrl: string, path: string): string { + const base = baseUrl.replace(/\/+$/, '') + const suffix = path.startsWith('/') ? path : `/${path}` + return `${base}${suffix}` +} + +function extractSqlText(raw: string): string { + const text = normalizeText(raw) + if (!text) return '' + const fenced = text.match(/```(?:sql)?\s*([\s\S]*?)```/i) + if (fenced?.[1]) return fenced[1].trim() + return text +} + +class AiAgentService { + private readonly config = ConfigService.getInstance() + private readonly runs = new Map() + + private getSharedModelConfig(): { apiBaseUrl: string; apiKey: string; model: string } { + return { + apiBaseUrl: normalizeText(this.config.get('aiModelApiBaseUrl')), + apiKey: normalizeText(this.config.get('aiModelApiKey')), + model: normalizeText(this.config.get('aiModelApiModel'), 'gpt-4o-mini') + } + } + + private emitStatus( + run: ActiveAgentRun, + onChunk: (chunk: AgentStreamChunk) => void, + phase: AgentRuntimeStatus['phase'], + extra?: Partial + ): void { + onChunk({ + runId: run.runId, + conversationId: run.conversationId, + type: 'status', + status: { + phase, + updatedAt: Date.now(), + ...extra + } + }) + } + + private mapRunEventToChunk( + run: ActiveAgentRun, + event: AiAnalysisRunEvent + ): AgentStreamChunk | null { + run.innerRunId = event.runId + run.conversationId = event.conversationId || run.conversationId + if (event.stage === 'llm_round_started') { + return { + runId: run.runId, + conversationId: run.conversationId, + type: 'think', + content: event.message, + thinkTag: 'round' + } + } + if (event.stage === 'tool_start') { + return { + runId: run.runId, + conversationId: run.conversationId, + type: 'tool_start', + toolName: event.toolName, + toolParams: (event.data || {}) as Record + } + } + if (event.stage === 'tool_done' || event.stage === 'tool_error') { + return { + runId: run.runId, + conversationId: run.conversationId, + type: 'tool_result', + toolName: event.toolName, + toolResult: event.data || { status: event.status, durationMs: event.durationMs } + } + } + if (event.stage === 'completed') { + return { + runId: run.runId, + conversationId: run.conversationId, + type: 'status', + status: { phase: 'completed', updatedAt: Date.now() } + } + } + if (event.stage === 'aborted') { + return { + runId: run.runId, + conversationId: run.conversationId, + type: 'status', + status: { phase: 'aborted', updatedAt: Date.now() } + } + } + if (event.stage === 'error') { + return { + runId: run.runId, + conversationId: run.conversationId, + type: 'status', + status: { phase: 'error', updatedAt: Date.now() } + } + } + return null + } + + private async callModel(payload: any, apiBaseUrl: string, apiKey: string): Promise { + const endpoint = buildApiUrl(apiBaseUrl, '/chat/completions') + const body = JSON.stringify(payload) + const urlObj = new URL(endpoint) + return new Promise((resolve, reject) => { + const requestFn = urlObj.protocol === 'https:' ? https.request : http.request + const req = requestFn({ + hostname: urlObj.hostname, + port: urlObj.port || (urlObj.protocol === 'https:' ? 443 : 80), + path: urlObj.pathname + urlObj.search, + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Content-Length': Buffer.byteLength(body).toString(), + Authorization: `Bearer ${apiKey}` + } + }, (res) => { + let data = '' + res.on('data', (chunk) => { data += String(chunk) }) + res.on('end', () => { + try { + resolve(JSON.parse(data || '{}')) + } catch (error) { + reject(new Error(`AI 响应解析失败: ${String(error)}`)) + } + }) + }) + req.setTimeout(45_000, () => { + req.destroy() + reject(new Error('AI 请求超时')) + }) + req.on('error', reject) + req.write(body) + req.end() + }) + } + + async runStream( + payload: AgentRunPayload, + runtime: { + onChunk: (chunk: AgentStreamChunk) => void + onFinished?: (result: { success: boolean; runId: string; conversationId?: string; error?: string }) => void + } + ): Promise<{ success: boolean; runId: string }> { + const runId = randomUUID() + const mode = payload.mode === 'sql' ? 'sql' : 'chat' + const run: ActiveAgentRun = { + runId, + mode, + conversationId: normalizeText(payload.conversationId) || undefined, + aborted: false + } + this.runs.set(runId, run) + + this.execute(run, payload, runtime).catch((error) => { + runtime.onChunk({ + runId, + conversationId: run.conversationId, + type: 'error', + error: String((error as Error)?.message || error), + isFinished: true + }) + runtime.onFinished?.({ + success: false, + runId, + conversationId: run.conversationId, + error: String((error as Error)?.message || error) + }) + this.runs.delete(runId) + }) + + return { success: true, runId } + } + + private async execute( + run: ActiveAgentRun, + payload: AgentRunPayload, + runtime: { + onChunk: (chunk: AgentStreamChunk) => void + onFinished?: (result: { success: boolean; runId: string; conversationId?: string; error?: string }) => void + } + ): Promise { + if (run.mode === 'sql') { + await this.executeSqlMode(run, payload, runtime) + return + } + this.emitStatus(run, runtime.onChunk, 'thinking') + const result = await aiAnalysisService.sendMessage( + normalizeText(payload.conversationId), + normalizeText(payload.userInput), + { + assistantId: normalizeText(payload.assistantId), + activeSkillId: normalizeText(payload.activeSkillId), + chatScope: payload.chatScope === 'group' ? 'group' : 'private' + }, + { + onRunEvent: (event) => { + const mapped = this.mapRunEventToChunk(run, event) + if (mapped) runtime.onChunk(mapped) + } + } + ) + if (run.aborted) { + runtime.onChunk({ + runId: run.runId, + conversationId: run.conversationId, + type: 'error', + error: '任务已取消', + isFinished: true + }) + runtime.onFinished?.({ + success: false, + runId: run.runId, + conversationId: run.conversationId, + error: '任务已取消' + }) + this.runs.delete(run.runId) + return + } + if (!result.success || !result.result) { + runtime.onChunk({ + runId: run.runId, + conversationId: run.conversationId, + type: 'error', + error: result.error || '执行失败', + isFinished: true + }) + runtime.onFinished?.({ + success: false, + runId: run.runId, + conversationId: run.conversationId, + error: result.error || '执行失败' + }) + this.runs.delete(run.runId) + return + } + + run.conversationId = result.result.conversationId || run.conversationId + runtime.onChunk({ + runId: run.runId, + conversationId: run.conversationId, + type: 'content', + content: result.result.assistantText + }) + runtime.onChunk({ + runId: run.runId, + conversationId: run.conversationId, + type: 'done', + usage: result.result.usage, + isFinished: true + }) + runtime.onFinished?.({ success: true, runId: run.runId, conversationId: run.conversationId }) + this.runs.delete(run.runId) + } + + private async executeSqlMode( + run: ActiveAgentRun, + payload: AgentRunPayload, + runtime: { + onChunk: (chunk: AgentStreamChunk) => void + onFinished?: (result: { success: boolean; runId: string; conversationId?: string; error?: string }) => void + } + ): Promise { + const { apiBaseUrl, apiKey, model } = this.getSharedModelConfig() + if (!apiBaseUrl || !apiKey) { + runtime.onChunk({ + runId: run.runId, + conversationId: run.conversationId, + type: 'error', + error: '请先在设置 > AI 通用中配置模型', + isFinished: true + }) + runtime.onFinished?.({ success: false, runId: run.runId, conversationId: run.conversationId, error: '模型未配置' }) + this.runs.delete(run.runId) + return + } + this.emitStatus(run, runtime.onChunk, 'thinking') + const schemaText = normalizeText(payload.sqlContext?.schemaText) + const targetHint = normalizeText(payload.sqlContext?.targetHint) + const systemPrompt = [ + '你是 WeFlow SQL Lab 助手。', + '只输出一段只读 SQL。', + '禁止输出解释、Markdown、注释、DML、DDL。' + ].join('\n') + const userPrompt = [ + targetHint ? `目标数据源: ${targetHint}` : '', + schemaText ? `可用 Schema:\n${schemaText}` : '', + `需求: ${normalizeText(payload.userInput)}` + ].filter(Boolean).join('\n\n') + + const res = await this.callModel({ + model, + messages: [ + { role: 'system', content: systemPrompt }, + { role: 'user', content: userPrompt } + ], + temperature: 0.1, + stream: false + }, apiBaseUrl, apiKey) + + if (run.aborted) { + runtime.onChunk({ + runId: run.runId, + conversationId: run.conversationId, + type: 'error', + error: '任务已取消', + isFinished: true + }) + runtime.onFinished?.({ success: false, runId: run.runId, conversationId: run.conversationId, error: '任务已取消' }) + this.runs.delete(run.runId) + return + } + + const rawContent = normalizeText(res?.choices?.[0]?.message?.content) + const sql = extractSqlText(rawContent) + const usage: TokenUsage = { + promptTokens: Number(res?.usage?.prompt_tokens || 0), + completionTokens: Number(res?.usage?.completion_tokens || 0), + totalTokens: Number(res?.usage?.total_tokens || 0) + } + if (!sql) { + runtime.onChunk({ + runId: run.runId, + conversationId: run.conversationId, + type: 'error', + error: 'SQL 生成失败', + isFinished: true + }) + runtime.onFinished?.({ success: false, runId: run.runId, conversationId: run.conversationId, error: 'SQL 生成失败' }) + this.runs.delete(run.runId) + return + } + for (let i = 0; i < sql.length; i += 36) { + if (run.aborted) break + runtime.onChunk({ + runId: run.runId, + conversationId: run.conversationId, + type: 'content', + content: sql.slice(i, i + 36) + }) + } + runtime.onChunk({ + runId: run.runId, + conversationId: run.conversationId, + type: 'done', + usage, + isFinished: true + }) + runtime.onFinished?.({ success: true, runId: run.runId, conversationId: run.conversationId }) + this.runs.delete(run.runId) + } + + async abort(payload: { runId?: string; conversationId?: string }): Promise<{ success: boolean }> { + const runId = normalizeText(payload.runId) + const conversationId = normalizeText(payload.conversationId) + if (runId) { + const run = this.runs.get(runId) + if (run) { + run.aborted = true + if (run.mode === 'chat') { + await aiAnalysisService.abortRun({ runId: run.innerRunId, conversationId: run.conversationId }) + } + } + return { success: true } + } + + if (conversationId) { + for (const run of this.runs.values()) { + if (run.conversationId !== conversationId) continue + run.aborted = true + if (run.mode === 'chat') { + await aiAnalysisService.abortRun({ runId: run.innerRunId, conversationId: run.conversationId }) + } + } + return { success: true } + } + return { success: true } + } +} + +export const aiAgentService = new AiAgentService() + diff --git a/electron/services/aiAnalysisService.ts b/electron/services/aiAnalysisService.ts new file mode 100644 index 0000000..06b111a --- /dev/null +++ b/electron/services/aiAnalysisService.ts @@ -0,0 +1,2673 @@ +import http from 'http' +import https from 'https' +import { randomUUID } from 'crypto' +import { mkdir, readFile, rm, writeFile } from 'fs/promises' +import { existsSync } from 'fs' +import { join } from 'path' +import { URL } from 'url' +import { chatService } from './chatService' +import { ConfigService } from './config' +import { wcdbService } from './wcdbService' +import { aiAssistantService } from './aiAssistantService' +import { aiSkillService } from './aiSkillService' + +type AiIntentType = 'query' | 'summary' | 'analysis' | 'timeline_recall' + +type AiToolStatus = 'ok' | 'error' | 'aborted' + +interface AiToolCallTrace { + toolName: string + args: Record + status: AiToolStatus + durationMs: number + error?: string +} + +interface AiRunState { + runId: string + conversationId: string + aborted: boolean +} + +interface AiResultComponentBase { + type: 'timeline' | 'summary' | 'source' +} + +interface TimelineComponent extends AiResultComponentBase { + type: 'timeline' + items: Array<{ + ts: number + sessionId: string + sessionName: string + sender: string + snippet: string + localId: number + createTime: number + }> +} + +interface SummaryComponent extends AiResultComponentBase { + type: 'summary' + title: string + bullets: string[] + conclusion: string +} + +interface SourceComponent extends AiResultComponentBase { + type: 'source' + range: { begin: number; end: number } + sessionCount: number + messageCount: number + dbRefs: string[] +} + +type AiResultComponent = TimelineComponent | SummaryComponent | SourceComponent + +interface SendMessageResult { + conversationId: string + messageId: string + assistantText: string + components: AiResultComponent[] + toolTrace: AiToolCallTrace[] + usage?: { + promptTokens?: number + completionTokens?: number + totalTokens?: number + } + error?: string + createdAt: number +} + +type AiRunEventStage = + | 'run_started' + | 'intent_identified' + | 'llm_round_started' + | 'llm_round_result' + | 'tool_start' + | 'tool_done' + | 'tool_error' + | 'assembling' + | 'completed' + | 'aborted' + | 'error' + +export interface AiAnalysisRunEvent { + runId: string + conversationId: string + stage: AiRunEventStage + ts: number + message: string + intent?: AiIntentType + round?: number + toolName?: string + status?: AiToolStatus + durationMs?: number + data?: Record +} + +interface LlmResponse { + content: string + toolCalls: Array<{ id: string; name: string; argumentsJson: string }> + usage?: { + promptTokens?: number + completionTokens?: number + totalTokens?: number + } +} + +interface ToolBundle { + activeSessions: any[] + sessionGlimpses: any[] + sessionCandidates: any[] + timelineRows: any[] + topicStats: any + sourceRefs: any + topContacts: any[] + messageBriefs: any[] + voiceCatalog: any[] + voiceTranscripts: any[] +} + +type ToolCategory = 'core' | 'analysis' + +type AssistantChatType = 'group' | 'private' + +interface SendMessageOptions { + parentMessageId?: string + persistUserMessage?: boolean + assistantId?: string + activeSkillId?: string + chatScope?: AssistantChatType +} + +const TOOL_CATEGORY_MAP: Record = { + ai_query_time_window_activity: 'core', + ai_query_session_glimpse: 'core', + ai_query_session_candidates: 'core', + ai_query_timeline: 'core', + ai_query_topic_stats: 'analysis', + ai_query_source_refs: 'analysis', + ai_query_top_contacts: 'analysis', + ai_fetch_message_briefs: 'core', + ai_list_voice_messages: 'core', + ai_transcribe_voice_messages: 'core', + activate_skill: 'analysis' +} + +const CORE_TOOL_NAMES = Object.entries(TOOL_CATEGORY_MAP) + .filter(([, category]) => category === 'core') + .map(([name]) => name) + +type SkillKey = + | 'base' + | 'context_compression' + | 'tool_time_window_activity' + | 'tool_session_glimpse' + | 'tool_session_candidates' + | 'tool_timeline' + | 'tool_topic_stats' + | 'tool_source_refs' + | 'tool_top_contacts' + | 'tool_message_briefs' + | 'tool_voice_list' + | 'tool_voice_transcribe' + +const AI_MODEL_TIMEOUT_MS = 45_000 +const MAX_TOOL_LOOPS = 8 +const FINAL_DONE_MARKER = '[[WF_DONE]]' +const CONTEXT_RECENT_LIMIT = 14 +const CONTEXT_COMPRESS_TRIGGER_COUNT = 34 +const CONTEXT_KEEP_AFTER_COMPRESS = 26 +const MAX_TOOL_RESULT_ROWS = 120 +const MIN_Glimpse_SESSIONS = 3 +const CONTEXT_SUMMARY_MAX_CHARS = 6_000 +const CONTEXT_RECENT_MAX_CHARS = 12_000 +const VOICE_TRANSCRIBE_BATCH_LIMIT = 5 + +type ToolResultDetailLevel = 'minimal' | 'standard' | 'full' + +function escSql(value: string): string { + return String(value || '').replace(/'/g, "''") +} + +function parseIntSafe(value: unknown, fallback = 0): number { + const n = Number(value) + return Number.isFinite(n) ? Math.floor(n) : fallback +} + +function normalizeText(value: unknown, fallback = ''): string { + const text = String(value ?? '').trim() + return text || fallback +} + +function parseStoredToolStep(content: string): null | { + toolName: string + status: string + durationMs: number + result: Record +} { + const raw = normalizeText(content) + if (!raw.startsWith('__wf_tool_step__')) return null + try { + const payload = JSON.parse(raw.slice('__wf_tool_step__'.length)) + return { + toolName: normalizeText(payload?.toolName), + status: normalizeText(payload?.status), + durationMs: parseIntSafe(payload?.durationMs), + result: payload?.result && typeof payload.result === 'object' ? payload.result : {} + } + } catch { + return null + } +} + +function buildApiUrl(baseUrl: string, path: string): string { + const base = baseUrl.replace(/\/+$/, '') + const suffix = path.startsWith('/') ? path : `/${path}` + return `${base}${suffix}` +} + +function defaultIntentType(): AiIntentType { + return 'analysis' +} + +function extractJsonStringField(json: string, key: string): string { + const needle = `"${key}"` + let pos = json.indexOf(needle) + if (pos < 0) return '' + pos = json.indexOf(':', pos + needle.length) + if (pos < 0) return '' + pos = json.indexOf('"', pos + 1) + if (pos < 0) return '' + pos += 1 + let out = '' + let escaped = false + for (; pos < json.length; pos += 1) { + const ch = json[pos] + if (escaped) { + out += ch + escaped = false + continue + } + if (ch === '\\') { + escaped = true + continue + } + if (ch === '"') break + out += ch + } + return out +} + +function resolveDetailLevel(args: Record): ToolResultDetailLevel { + const detailLevel = normalizeText(args.detailLevel).toLowerCase() + if (detailLevel === 'full') return 'full' + if (detailLevel === 'standard') return 'standard' + if (args.verbose === true) return 'full' + return 'minimal' +} + +function normalizeTimestampSeconds(value: unknown): number { + const numeric = Number(value || 0) + if (!Number.isFinite(numeric) || numeric <= 0) return 0 + return numeric > 1e12 ? Math.floor(numeric / 1000) : Math.floor(numeric) +} + +function resolveNamedTimeWindow(period: string): { begin: number; end: number } | null { + const now = new Date() + const lower = normalizeText(period).toLowerCase() + const mkSec = (d: Date) => Math.floor(d.getTime() / 1000) + + if (!lower || lower === 'custom') return null + if (lower === 'today_dawn' || lower === '凌晨') { + const begin = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 0, 0, 0, 0) + const end = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 6, 0, 0, 0) + return { begin: mkSec(begin), end: mkSec(end) } + } + if (lower === 'today') { + const begin = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 0, 0, 0, 0) + const end = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 23, 59, 59, 999) + return { begin: mkSec(begin), end: mkSec(end) } + } + if (lower === 'yesterday') { + const begin = new Date(now.getFullYear(), now.getMonth(), now.getDate() - 1, 0, 0, 0, 0) + const end = new Date(now.getFullYear(), now.getMonth(), now.getDate() - 1, 23, 59, 59, 999) + return { begin: mkSec(begin), end: mkSec(end) } + } + if (lower === 'last_7_days') { + const begin = new Date(now.getFullYear(), now.getMonth(), now.getDate() - 6, 0, 0, 0, 0) + return { begin: mkSec(begin), end: mkSec(now) } + } + return null +} + +function isTimeWindowIntent(input: string): boolean { + const text = normalizeText(input) + return /(凌晨|昨晚|今天|昨日|昨夜|最近|本周|这周|这个月|时间段)/.test(text) +} + +function isContactRecallIntent(input: string): boolean { + const text = normalizeText(input) + if (!text) return false + return /(我和|跟).{0,24}(聊了什么|都聊了什么|说了什么|最近聊|聊啥|聊过什么)/.test(text) +} + +function resolveImplicitRecentRange(input: string): { beginTimestamp: number; endTimestamp: number } | null { + const text = normalizeText(input).toLowerCase() + const now = Math.floor(Date.now() / 1000) + if (/(最近|近期|lately|recent)/i.test(text)) { + return { beginTimestamp: now - 30 * 86400, endTimestamp: now } + } + if (/(今天|today)/i.test(text)) { + const d = new Date() + const begin = new Date(d.getFullYear(), d.getMonth(), d.getDate(), 0, 0, 0, 0) + return { beginTimestamp: Math.floor(begin.getTime() / 1000), endTimestamp: now } + } + if (/(昨晚|昨天|yesterday)/i.test(text)) { + const d = new Date() + const begin = new Date(d.getFullYear(), d.getMonth(), d.getDate() - 1, 0, 0, 0, 0) + const end = new Date(d.getFullYear(), d.getMonth(), d.getDate() - 1, 23, 59, 59, 999) + return { beginTimestamp: Math.floor(begin.getTime() / 1000), endTimestamp: Math.floor(end.getTime() / 1000) } + } + return null +} + +function extractContactHint(input: string): string { + const text = normalizeText(input) + if (!text) return '' + const match = text.match(/(?:我和|跟)\s*([^\s,。!??,]{1,24})/) + const explicit = normalizeText(match?.[1]) + if (explicit) return explicit + if (/^[\u4e00-\u9fa5a-zA-Z0-9_]{1,16}$/.test(text)) return text + return '' +} + +function normalizeLookupToken(value: unknown): string { + return normalizeText(value) + .toLowerCase() + .replace(/[\s_\-.@]/g, '') +} + +function getLatinInitials(value: unknown): string { + const text = normalizeText(value).toLowerCase() + if (!text) return '' + const parts = text.match(/[a-z0-9]+/g) || [] + return parts.map((part) => part[0]).join('') +} + +function isLikelyContactOnlyInput(input: string): boolean { + const text = normalizeText(input) + if (!text) return false + if (!/^[\u4e00-\u9fa5a-zA-Z0-9_]{1,16}$/.test(text)) return false + return !/(聊|什么|怎么|为何|为什么|是否|吗|呢|?|\?)/.test(text) +} + +class AiAnalysisService { + private readonly config = ConfigService.getInstance() + private readonly activeRuns = new Map() + private readonly skillCache = new Map() + + private getSharedModelConfig(): { apiBaseUrl: string; apiKey: string; model: string } { + const apiBaseUrl = normalizeText(this.config.get('aiModelApiBaseUrl')) + const apiKey = normalizeText(this.config.get('aiModelApiKey')) + const model = normalizeText(this.config.get('aiModelApiModel'), 'gpt-4o-mini') + return { apiBaseUrl, apiKey, model } + } + + private getSkillDirCandidates(): string[] { + return [ + join(__dirname, 'aiAnalysisSkills'), + join(process.cwd(), 'electron', 'services', 'aiAnalysisSkills'), + join(process.cwd(), 'dist-electron', 'services', 'aiAnalysisSkills') + ] + } + + private getBuiltinSkill(skill: SkillKey): string { + const builtin: Record = { + base: [ + '你是 WeFlow 的 AI 分析助手。', + '优先使用本地工具获得事实,禁止编造数据。', + '输出简洁中文,结论与证据一致。', + '当 ai_query_top_contacts 返回非空 items 时,必须直接给出“前N名+消息数”的明确结论,不得回复“未命中”。', + '除非用户明确提到“群/群聊/公众号”,联系人排行默认按个人联系人口径(排除群聊与公众号)。', + '用户提到“最近/近期/lately/recent”但未给时间窗时,默认按近30天口径检索并在结论中写明口径。', + '默认优先调用 detailLevel=minimal,证据不足时再升级到 standard/full。', + '当用户目标不够清晰时,先做小规模探索,再主动提出 1 个澄清问题继续多轮对话。', + '面对“看一下凌晨聊天/今天记录”这类请求,先扫描时间窗活跃会话,再按会话逐个抽样阅读,不要只调用一次工具就结束。', + '在证据不足时先说明不足,再建议下一步。', + '语音消息必须先请求“语音ID列表”,再指定ID进行转写,不可臆测语音内容。', + `结束协议:仅在任务完成时输出 ${FINAL_DONE_MARKER},并附带 最终回答。`, + '若未完成,请继续调用工具,不要提前结束。' + ].join('\n'), + context_compression: [ + '你会收到 conversation_summary 作为历史压缩摘要。', + '当摘要与最近消息冲突时,以最近消息为准。', + '若用户追问很早历史,可主动调用工具重新检索,不依赖陈旧记忆。' + ].join('\n'), + tool_time_window_activity: [ + '工具 ai_query_time_window_activity 用于按时间窗找活跃会话。', + '处理“今天凌晨/昨晚/本周”时优先调用,先拿候选会话池。', + '默认 minimal,小范围快速扫描;需要时再增大 scanLimit。' + ].join('\n'), + tool_session_glimpse: [ + '工具 ai_query_session_glimpse 用于按会话抽样阅读消息。', + '拿到活跃会话后,逐个会话先读 6~20 条快速建立上下文。', + '若抽样后仍不确定用户目标,先追问 1 个关键澄清问题。' + ].join('\n'), + tool_session_candidates: [ + '工具 ai_query_session_candidates 用于先缩小会话范围。', + '默认先查候选会话,再查时间轴,能明显减少 token 和耗时。', + '如果用户已给出明确联系人/会话,可跳过候选直接查时间轴。' + ].join('\n'), + tool_timeline: [ + '工具 ai_query_timeline 返回按时间倒序的消息事件。', + '需要回忆经过、做时间轴时优先调用。', + '默认返回精简字段;只有用户明确要细节时才请求 verbose。' + ].join('\n'), + tool_topic_stats: [ + '工具 ai_query_topic_stats 提供跨会话统计聚合。', + '适合回答“多少、趋势、占比、对比”问题。', + '若只是复盘事件,不要先做重统计。' + ].join('\n'), + tool_source_refs: [ + '工具 ai_query_source_refs 用于生成可解释来源卡。', + '总结/分析完成后补一次来源引用即可。', + '优先返回范围、会话数、消息数和数据库引用。' + ].join('\n'), + tool_top_contacts: [ + '工具 ai_query_top_contacts 用于回答“谁联系最密切/谁聊得最多”。', + '这是该类问题的首选工具,优先于时间轴检索。', + '默认 minimal 即可得到排名;需要更多字段再升 detailLevel。' + ].join('\n'), + tool_message_briefs: [ + '工具 ai_fetch_message_briefs 按 sessionId+localId 精确读取消息。', + '用于核对关键原文证据,避免大范围全文拉取。', + '默认最小字段,只有需要时才请求 full 明细。' + ].join('\n'), + tool_voice_list: [ + '工具 ai_list_voice_messages 用于语音清单检索。', + '先列出可用语音ID,再让你决定转写哪几条。', + '默认只返回 IDs,减少 token;需要详情再提升 detailLevel。' + ].join('\n'), + tool_voice_transcribe: [ + '工具 ai_transcribe_voice_messages 根据语音ID进行自动解密并转写。', + '只能转写你明确指定的ID,单次最多 5 条。', + '若用户未点名具体ID,先调用语音清单工具返回 ID 再继续。', + '收到转写后再做总结,禁止未转写先下结论。' + ].join('\n') + } + return builtin[skill] + } + + private async loadSkill(skill: SkillKey): Promise { + const cached = this.skillCache.get(skill) + if (cached) return cached + + const fileName = `${skill}.md` + for (const dir of this.getSkillDirCandidates()) { + const filePath = join(dir, fileName) + if (!existsSync(filePath)) continue + try { + const content = (await readFile(filePath, 'utf8')).trim() + if (content) { + this.skillCache.set(skill, content) + return content + } + } catch { + // ignore and fallback + } + } + + const fallback = this.getBuiltinSkill(skill) + this.skillCache.set(skill, fallback) + return fallback + } + + private resolveAllowedToolNames(allowedBuiltinTools?: string[]): string[] { + const whitelist = Array.isArray(allowedBuiltinTools) + ? allowedBuiltinTools.map((item) => normalizeText(item)).filter(Boolean) + : [] + const allowedSet = new Set(CORE_TOOL_NAMES) + if (whitelist.length === 0) { + for (const [name, category] of Object.entries(TOOL_CATEGORY_MAP)) { + if (category === 'analysis') allowedSet.add(name) + } + } else { + for (const toolName of whitelist) { + if (TOOL_CATEGORY_MAP[toolName]) allowedSet.add(toolName) + } + } + allowedSet.add('activate_skill') + return Array.from(allowedSet) + } + + private resolveChatType(options?: SendMessageOptions): AssistantChatType { + if (options?.chatScope === 'group' || options?.chatScope === 'private') return options.chatScope + return 'private' + } + + async getToolCatalog(): Promise> { + return this.getToolDefinitions().map((entry) => { + const toolName = normalizeText(entry?.function?.name) + return { + name: toolName, + category: TOOL_CATEGORY_MAP[toolName] || 'analysis', + description: normalizeText(entry?.function?.description), + parameters: entry?.function?.parameters || {} + } + }) + } + + async executeTool( + name: string, + args: Record + ): Promise<{ success: boolean; result?: any; error?: string }> { + try { + const toolName = normalizeText(name) + if (!toolName) return { success: false, error: '缺少工具名' } + const result = await this.runTool(toolName, args || {}) + return { success: true, result } + } catch (error) { + return { success: false, error: (error as Error).message } + } + } + + async cancelToolTest(_taskId?: string): Promise<{ success: boolean }> { + return { success: true } + } + + private async ensureAiDbPath(): Promise<{ dbPath: string; wxid: string }> { + const dbRoot = normalizeText(this.config.get('dbPath')) + const wxid = normalizeText(this.config.get('myWxid')) + if (!dbRoot) throw new Error('未配置数据库路径,请先在设置中完成数据库连接') + if (!wxid) throw new Error('未识别当前账号,请先完成账号配置') + const aiDir = join(dbRoot, wxid, 'db_storage', 'wf_ai_v2') + await mkdir(aiDir, { recursive: true }) + const markerPath = join(aiDir, '.storage_v2_initialized') + const dbPath = join(aiDir, 'ai_analysis_v2.db') + if (!existsSync(markerPath)) { + try { + await rm(dbPath, { force: true }) + } catch { + // ignore + } + try { + await rm(join(dbRoot, wxid, 'db_storage', 'wf_ai', 'ai_analysis.db'), { force: true }) + } catch { + // ignore + } + await writeFile(markerPath, JSON.stringify({ version: 2, initializedAt: Date.now() }), 'utf8') + } + return { + dbPath, + wxid + } + } + + private async ensureConnected(): Promise { + const connected = await chatService.connect() + if (!connected.success) { + throw new Error(connected.error || '数据库连接失败') + } + } + + private async ensureSchema(aiDbPath: string): Promise { + const sqlList = [ + `CREATE TABLE IF NOT EXISTS ai_conversations ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + conversation_id TEXT NOT NULL UNIQUE, + title TEXT NOT NULL DEFAULT '', + summary_text TEXT NOT NULL DEFAULT '', + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + last_message_at INTEGER NOT NULL + )`, + `CREATE TABLE IF NOT EXISTS ai_messages ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + message_id TEXT NOT NULL UNIQUE, + conversation_id TEXT NOT NULL, + role TEXT NOT NULL, + content TEXT NOT NULL DEFAULT '', + intent_type TEXT NOT NULL DEFAULT '', + components_json TEXT NOT NULL DEFAULT '[]', + tool_trace_json TEXT NOT NULL DEFAULT '[]', + usage_json TEXT NOT NULL DEFAULT '{}', + error TEXT NOT NULL DEFAULT '', + parent_message_id TEXT NOT NULL DEFAULT '', + created_at INTEGER NOT NULL + )`, + `CREATE TABLE IF NOT EXISTS ai_tool_runs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + run_id TEXT NOT NULL, + conversation_id TEXT NOT NULL, + message_id TEXT NOT NULL, + tool_name TEXT NOT NULL, + tool_args_json TEXT NOT NULL DEFAULT '{}', + tool_result_json TEXT NOT NULL DEFAULT '{}', + status TEXT NOT NULL DEFAULT 'ok', + duration_ms INTEGER NOT NULL DEFAULT 0, + error TEXT NOT NULL DEFAULT '', + created_at INTEGER NOT NULL + )`, + 'CREATE INDEX IF NOT EXISTS idx_ai_messages_conversation_created ON ai_messages(conversation_id, created_at)', + 'CREATE INDEX IF NOT EXISTS idx_ai_tool_runs_run_id ON ai_tool_runs(run_id)' + ] + + for (const sql of sqlList) { + const result = await wcdbService.execQuery('biz', aiDbPath, sql) + if (!result.success) { + throw new Error(result.error || 'AI 分析数据库初始化失败') + } + } + + // 兼容旧表结构 + await wcdbService.execQuery('biz', aiDbPath, `ALTER TABLE ai_conversations ADD COLUMN summary_text TEXT NOT NULL DEFAULT ''`) + } + + private async ensureReady(): Promise<{ dbPath: string; wxid: string }> { + await this.ensureConnected() + const aiInfo = await this.ensureAiDbPath() + await this.ensureSchema(aiInfo.dbPath) + return aiInfo + } + + private async queryRows(aiDbPath: string, sql: string): Promise { + const result = await wcdbService.execQuery('biz', aiDbPath, sql) + if (!result.success) throw new Error(result.error || '查询失败') + return Array.isArray(result.rows) ? result.rows : [] + } + + private async callModel(payload: any, apiBaseUrl: string, apiKey: string): Promise { + const endpoint = buildApiUrl(apiBaseUrl, '/chat/completions') + const body = JSON.stringify(payload) + const urlObj = new URL(endpoint) + return new Promise((resolve, reject) => { + const requestFn = urlObj.protocol === 'https:' ? https.request : http.request + const req = requestFn({ + hostname: urlObj.hostname, + port: urlObj.port || (urlObj.protocol === 'https:' ? 443 : 80), + path: urlObj.pathname + urlObj.search, + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Content-Length': Buffer.byteLength(body).toString(), + Authorization: `Bearer ${apiKey}` + } + }, (res) => { + let data = '' + res.on('data', (chunk) => { data += String(chunk) }) + res.on('end', () => { + try { + resolve(JSON.parse(data || '{}')) + } catch (error) { + reject(new Error(`AI 响应解析失败: ${String(error)}`)) + } + }) + }) + + req.setTimeout(AI_MODEL_TIMEOUT_MS, () => { + req.destroy() + reject(new Error('AI 请求超时')) + }) + req.on('error', reject) + req.write(body) + req.end() + }) + } + + private getToolDefinitions(allowedToolNames?: string[]) { + const tools = [ + { + type: 'function', + function: { + name: 'ai_query_time_window_activity', + description: '按时间窗扫描活跃会话(例如今天凌晨)', + parameters: { + type: 'object', + properties: { + period: { type: 'string', description: 'today_dawn|today|yesterday|last_7_days|custom' }, + beginTimestamp: { type: 'number' }, + endTimestamp: { type: 'number' }, + scanLimit: { type: 'number' }, + topN: { type: 'number' }, + includeGroups: { type: 'boolean' }, + includeOfficial: { type: 'boolean' }, + detailLevel: { type: 'string', enum: ['minimal', 'standard', 'full'] } + } + } + } + }, + { + type: 'function', + function: { + name: 'ai_query_session_glimpse', + description: '按会话抽样读取消息(先读一点建立上下文)', + parameters: { + type: 'object', + properties: { + sessionId: { type: 'string' }, + beginTimestamp: { type: 'number' }, + endTimestamp: { type: 'number' }, + limit: { type: 'number' }, + offset: { type: 'number' }, + ascending: { type: 'boolean' }, + detailLevel: { type: 'string', enum: ['minimal', 'standard', 'full'] } + }, + required: ['sessionId'] + } + } + }, + { + type: 'function', + function: { + name: 'ai_query_session_candidates', + description: '按关键词快速定位候选会话(默认最小字段)', + parameters: { + type: 'object', + properties: { + keyword: { type: 'string' }, + limit: { type: 'number' }, + beginTimestamp: { type: 'number' }, + endTimestamp: { type: 'number' }, + detailLevel: { type: 'string', enum: ['minimal', 'standard', 'full'] } + }, + required: ['keyword'] + } + } + }, + { + type: 'function', + function: { + name: 'ai_query_timeline', + description: '按会话+关键词检索时间轴事件(支持分页,默认最小字段)', + parameters: { + type: 'object', + properties: { + sessionId: { type: 'string' }, + keyword: { type: 'string' }, + limit: { type: 'number' }, + offset: { type: 'number' }, + beginTimestamp: { type: 'number' }, + endTimestamp: { type: 'number' }, + detailLevel: { type: 'string', enum: ['minimal', 'standard', 'full'] } + }, + required: ['keyword'] + } + } + }, + { + type: 'function', + function: { + name: 'ai_query_topic_stats', + description: '获取会话聚合统计(总量/趋势/分布)', + parameters: { + type: 'object', + properties: { + sessionIds: { type: 'array', items: { type: 'string' } }, + beginTimestamp: { type: 'number' }, + endTimestamp: { type: 'number' }, + detailLevel: { type: 'string', enum: ['minimal', 'standard', 'full'] } + }, + required: ['sessionIds'] + } + } + }, + { + type: 'function', + function: { + name: 'ai_query_source_refs', + description: '返回可解释的数据来源信息(用于来源卡)', + parameters: { + type: 'object', + properties: { + sessionIds: { type: 'array', items: { type: 'string' } }, + beginTimestamp: { type: 'number' }, + endTimestamp: { type: 'number' }, + detailLevel: { type: 'string', enum: ['minimal', 'standard', 'full'] } + }, + required: ['sessionIds'] + } + } + }, + { + type: 'function', + function: { + name: 'ai_query_top_contacts', + description: '查询联系最密切/聊天最频繁的联系人排名(高优先级)', + parameters: { + type: 'object', + properties: { + limit: { type: 'number' }, + beginTimestamp: { type: 'number' }, + endTimestamp: { type: 'number' }, + includeGroups: { type: 'boolean' }, + includeOfficial: { type: 'boolean' }, + scanLimit: { type: 'number' }, + detailLevel: { type: 'string', enum: ['minimal', 'standard', 'full'] } + } + } + } + }, + { + type: 'function', + function: { + name: 'ai_fetch_message_briefs', + description: '按 sessionId+localId 精确读取少量消息原文,用于证据核对', + parameters: { + type: 'object', + properties: { + items: { + type: 'array', + items: { + type: 'object', + properties: { + sessionId: { type: 'string' }, + localId: { type: 'number' } + }, + required: ['sessionId', 'localId'] + } + }, + detailLevel: { type: 'string', enum: ['minimal', 'standard', 'full'] } + }, + required: ['items'] + } + } + }, + { + type: 'function', + function: { + name: 'ai_list_voice_messages', + description: '列出语音消息ID清单(先拿ID,再点名转写)', + parameters: { + type: 'object', + properties: { + sessionId: { type: 'string' }, + beginTimestamp: { type: 'number' }, + endTimestamp: { type: 'number' }, + limit: { type: 'number' }, + offset: { type: 'number' }, + detailLevel: { type: 'string', enum: ['minimal', 'standard', 'full'] } + } + } + } + }, + { + type: 'function', + function: { + name: 'ai_transcribe_voice_messages', + description: '根据语音ID列表执行自动解密+转写,返回文本', + parameters: { + type: 'object', + properties: { + ids: { + type: 'array', + items: { type: 'string' }, + description: '格式 sessionId:localId[:createTime]' + }, + items: { + type: 'array', + items: { + type: 'object', + properties: { + sessionId: { type: 'string' }, + localId: { type: 'number' }, + createTime: { type: 'number' } + }, + required: ['sessionId', 'localId'] + } + }, + verbose: { type: 'boolean' }, + detailLevel: { type: 'string', enum: ['minimal', 'standard', 'full'] } + } + } + } + }, + { + type: 'function', + function: { + name: 'activate_skill', + description: '激活一个技能并返回技能手册内容', + parameters: { + type: 'object', + properties: { + skill_id: { type: 'string', description: '技能 ID' } + }, + required: ['skill_id'] + } + } + } + ] + if (!allowedToolNames || allowedToolNames.length === 0) return tools + const whitelist = new Set(allowedToolNames) + return tools.filter((entry: any) => whitelist.has(normalizeText(entry?.function?.name))) + } + + private async requestLlmStep( + messages: any[], + model: string, + apiBaseUrl: string, + apiKey: string, + allowedToolNames?: string[] + ): Promise { + const res = await this.callModel({ + model, + messages, + tools: this.getToolDefinitions(allowedToolNames), + tool_choice: 'auto', + temperature: 0.2, + stream: false + }, apiBaseUrl, apiKey) + + const choice = res?.choices?.[0]?.message || {} + const toolCalls = Array.isArray(choice.tool_calls) + ? choice.tool_calls.map((item: any) => ({ + id: String(item?.id || randomUUID()), + name: String(item?.function?.name || ''), + argumentsJson: String(item?.function?.arguments || '{}') + })) + : [] + return { + content: normalizeText(choice?.content), + toolCalls: toolCalls.filter((t: any) => t.name), + usage: { + promptTokens: parseIntSafe(res?.usage?.prompt_tokens), + completionTokens: parseIntSafe(res?.usage?.completion_tokens), + totalTokens: parseIntSafe(res?.usage?.total_tokens) + } + } + } + + private parseFinalDelivery(content: string): { done: boolean; answer: string } { + const raw = normalizeText(content) + if (!raw) return { done: false, answer: '' } + if (!raw.includes(FINAL_DONE_MARKER)) return { done: false, answer: '' } + + const afterMarker = raw.slice(raw.indexOf(FINAL_DONE_MARKER) + FINAL_DONE_MARKER.length).trim() + const tagMatch = afterMarker.match(/([\s\S]*?)<\/final_answer>/i) + const answer = normalizeText(tagMatch?.[1] || afterMarker) + return { done: true, answer } + } + + private stripFinalMarker(content: string): string { + const raw = normalizeText(content) + if (!raw) return '' + return normalizeText( + raw + .replace(FINAL_DONE_MARKER, '') + .replace(/<\/?final_answer>/ig, '') + ) + } + + private compactRows(rows: any[], detailLevel: ToolResultDetailLevel = 'minimal'): any[] { + if (detailLevel === 'full') return rows.slice(0, MAX_TOOL_RESULT_ROWS) + if (detailLevel === 'standard') { + return rows.slice(0, MAX_TOOL_RESULT_ROWS).map((row) => ({ + _session_id: normalizeText(row._session_id), + local_id: parseIntSafe(row.local_id), + create_time: parseIntSafe(row.create_time), + sender_username: normalizeText(row.sender_username), + local_type: parseIntSafe(row.local_type), + content: normalizeText(row.content || row.parsedContent).slice(0, 320) + })) + } + return rows.slice(0, MAX_TOOL_RESULT_ROWS).map((row) => { + const content = normalizeText(row.content || row.parsedContent) + return { + _session_id: normalizeText(row._session_id), + local_id: parseIntSafe(row.local_id), + create_time: parseIntSafe(row.create_time), + sender_username: normalizeText(row.sender_username), + content: content.slice(0, 160) + } + }) + } + + private compactStats(stats: any, detailLevel: ToolResultDetailLevel = 'minimal'): any { + if (detailLevel === 'full') return stats + if (!stats || typeof stats !== 'object') return {} + if (detailLevel === 'standard') { + return { + total: parseIntSafe(stats.total), + sent: parseIntSafe(stats.sent), + received: parseIntSafe(stats.received), + firstTime: parseIntSafe(stats.firstTime), + lastTime: parseIntSafe(stats.lastTime), + typeCounts: stats.typeCounts || {}, + sessions: stats.sessions || {} + } + } + return { + total: parseIntSafe(stats.total), + sent: parseIntSafe(stats.sent), + received: parseIntSafe(stats.received), + firstTime: parseIntSafe(stats.firstTime), + lastTime: parseIntSafe(stats.lastTime), + typeCounts: stats.typeCounts || {}, + topSessions: (() => { + const sessions = stats.sessions && typeof stats.sessions === 'object' ? stats.sessions : {} + const arr = Object.entries(sessions).map(([sessionId, val]: any) => ({ + sessionId, + total: parseIntSafe(val?.total), + sent: parseIntSafe(val?.sent), + received: parseIntSafe(val?.received), + lastTime: parseIntSafe(val?.lastTime) + })) + arr.sort((a, b) => b.total - a.total) + return arr.slice(0, 12) + })() + } + } + + private parseVoiceIds(ids: string[]): Array<{ sessionId: string; localId: number; createTime?: number }> { + const requests: Array<{ sessionId: string; localId: number; createTime?: number }> = [] + for (const id of ids || []) { + const raw = normalizeText(id) + if (!raw) continue + const parts = raw.split(':') + if (parts.length < 2) continue + const sessionId = normalizeText(parts[0]) + const localId = parseIntSafe(parts[1]) + const createTime = parts.length >= 3 ? parseIntSafe(parts[2]) : 0 + if (!sessionId || localId <= 0) continue + requests.push({ sessionId, localId, createTime: createTime > 0 ? createTime : undefined }) + } + return requests + } + + private async runTool(name: string, args: Record, context?: { userInput?: string }): Promise { + const detailLevel = resolveDetailLevel(args) + const maxMessagesPerRequest = Math.max( + 20, + Math.min(500, parseIntSafe(this.config.get('aiAgentMaxMessagesPerRequest'), 120)) + ) + + if (name === 'ai_query_time_window_activity') { + const namedWindow = resolveNamedTimeWindow(normalizeText(args.period)) + const beginTimestamp = namedWindow?.begin || normalizeTimestampSeconds(args.beginTimestamp) + const endTimestamp = namedWindow?.end || normalizeTimestampSeconds(args.endTimestamp) + if (beginTimestamp <= 0 || endTimestamp <= 0 || endTimestamp < beginTimestamp) { + return { success: false, error: '请提供有效时间窗(period 或 beginTimestamp/endTimestamp)' } + } + + const includeGroups = typeof args.includeGroups === 'boolean' + ? args.includeGroups + : true + const includeOfficial = typeof args.includeOfficial === 'boolean' + ? args.includeOfficial + : false + const scanLimit = Math.max(20, Math.min(1000, parseIntSafe(args.scanLimit, 260))) + const topN = Math.max(1, Math.min(60, parseIntSafe(args.topN, 24))) + + const sessionsRes = await chatService.getSessions() + if (!sessionsRes.success || !Array.isArray(sessionsRes.sessions)) { + return { success: false, error: sessionsRes.error || '会话列表获取失败' } + } + + const scannedSessions = sessionsRes.sessions + .filter((session: any) => { + const sessionId = normalizeText(session.username) + if (!sessionId) return false + const isGroup = sessionId.endsWith('@chatroom') + const isOfficial = sessionId.startsWith('gh_') + if (!includeGroups && isGroup) return false + if (!includeOfficial && isOfficial) return false + return true + }) + .sort((a: any, b: any) => parseIntSafe(b.sortTimestamp || b.lastTimestamp) - parseIntSafe(a.sortTimestamp || a.lastTimestamp)) + .slice(0, scanLimit) + + const sessionIds = scannedSessions.map((session: any) => normalizeText(session.username)).filter(Boolean) + if (sessionIds.length === 0) { + return { success: true, beginTimestamp, endTimestamp, totalScanned: 0, activeCount: 0, items: [] } + } + + const statsRes = await wcdbService.getSessionMessageTypeStatsBatch(sessionIds, { + beginTimestamp, + endTimestamp, + quickMode: true, + includeGroupSenderCount: false + }) + if (!statsRes.success || !statsRes.data) { + return { success: false, error: statsRes.error || '时间窗活跃扫描失败' } + } + + const items = scannedSessions.map((session: any) => { + const sessionId = normalizeText(session.username) + const row = (statsRes.data as any)?.[sessionId] || {} + return { + sessionId, + sessionName: normalizeText(session.displayName || sessionId), + messageCount: Math.max(0, parseIntSafe(row.totalMessages ?? row.total_messages ?? row.total)), + sentCount: Math.max(0, parseIntSafe(row.sentMessages ?? row.sent_messages ?? row.sent)), + receivedCount: Math.max(0, parseIntSafe(row.receivedMessages ?? row.received_messages ?? row.received)), + latestTime: parseIntSafe(session.lastTimestamp || session.sortTimestamp), + isGroup: sessionId.endsWith('@chatroom') + } + }) + .filter((item) => item.messageCount > 0) + .sort((a, b) => b.messageCount - a.messageCount || b.latestTime - a.latestTime) + + const top = items.slice(0, topN) + if (detailLevel === 'full') { + return { + success: true, + beginTimestamp, + endTimestamp, + totalScanned: scannedSessions.length, + activeCount: items.length, + items: top + } + } + if (detailLevel === 'standard') { + return { + success: true, + beginTimestamp, + endTimestamp, + totalScanned: scannedSessions.length, + activeCount: items.length, + items: top.map((item) => ({ + sessionId: item.sessionId, + sessionName: item.sessionName, + messageCount: item.messageCount, + sentCount: item.sentCount, + receivedCount: item.receivedCount, + isGroup: item.isGroup + })) + } + } + return { + success: true, + beginTimestamp, + endTimestamp, + totalScanned: scannedSessions.length, + activeCount: items.length, + items: top.map((item) => ({ + sessionId: item.sessionId, + sessionName: item.sessionName, + messageCount: item.messageCount + })) + } + } + + if (name === 'ai_query_session_glimpse') { + const sessionId = normalizeText(args.sessionId) + if (!sessionId) return { success: false, error: 'sessionId 不能为空' } + const limit = Math.max(1, Math.min(maxMessagesPerRequest, parseIntSafe(args.limit, 12))) + const offset = Math.max(0, parseIntSafe(args.offset, 0)) + const beginTimestamp = normalizeTimestampSeconds(args.beginTimestamp) + const endTimestamp = normalizeTimestampSeconds(args.endTimestamp) + const ascending = args.ascending !== false + + const result = await chatService.getMessages( + sessionId, + offset, + limit, + beginTimestamp, + endTimestamp, + ascending + ) + if (!result.success) { + return { success: false, error: result.error || '会话抽样读取失败' } + } + const messages = Array.isArray(result.messages) ? result.messages : [] + const rows = messages.map((message: any) => ({ + sessionId, + localId: parseIntSafe(message.localId), + createTime: parseIntSafe(message.createTime), + sender: normalizeText(message.senderUsername || (message.isSend === 1 ? '我' : '对方')), + localType: parseIntSafe(message.localType), + content: normalizeText(message.parsedContent || message.rawContent) + })) + const compactRows = detailLevel === 'full' + ? rows + : rows.map((row) => ({ + sessionId: row.sessionId, + localId: row.localId, + createTime: row.createTime, + sender: row.sender, + localType: row.localType, + snippet: row.content.slice(0, detailLevel === 'standard' ? 260 : 140) + })) + return { + success: true, + sessionId, + count: rows.length, + hasMore: result.hasMore === true, + nextOffset: parseIntSafe(result.nextOffset), + rows: compactRows + } + } + + if (name === 'ai_query_session_candidates') { + const result = await wcdbService.aiQuerySessionCandidates({ + keyword: normalizeText(args.keyword), + limit: parseIntSafe(args.limit, 12), + beginTimestamp: parseIntSafe(args.beginTimestamp), + endTimestamp: parseIntSafe(args.endTimestamp) + }) + if (!result.success) return result + const rows = Array.isArray(result.rows) ? result.rows : [] + const compactRows = detailLevel === 'full' + ? rows + : rows.slice(0, 24).map((row: any) => ({ + sessionId: normalizeText(row.session_id || row._session_id || row.sessionId), + sessionName: normalizeText(row.session_name || row.display_name || row.sessionName), + hitCount: parseIntSafe(row.hit_count || row.count), + latestTime: parseIntSafe(row.latest_time || row.latestTime) + })) + return { + success: true, + rows: compactRows, + count: rows.length + } + } + + if (name === 'ai_query_timeline') { + const result = await wcdbService.aiQueryTimeline({ + sessionId: normalizeText(args.sessionId), + keyword: normalizeText(args.keyword), + limit: Math.max(1, Math.min(maxMessagesPerRequest, parseIntSafe(args.limit, 120))), + offset: parseIntSafe(args.offset), + beginTimestamp: parseIntSafe(args.beginTimestamp), + endTimestamp: parseIntSafe(args.endTimestamp) + }) + if (!result.success) return result + const rows = Array.isArray(result.rows) ? result.rows : [] + return { + success: true, + rows: this.compactRows(rows, detailLevel), + count: rows.length + } + } + + if (name === 'ai_query_topic_stats') { + const sessionIds = Array.isArray(args.sessionIds) + ? args.sessionIds.map((value: any) => normalizeText(value)).filter(Boolean) + : [] + const result = await wcdbService.aiQueryTopicStats({ + sessionIds, + beginTimestamp: parseIntSafe(args.beginTimestamp), + endTimestamp: parseIntSafe(args.endTimestamp) + }) + if (!result.success) return result + return { + success: true, + data: this.compactStats(result.data || {}, detailLevel) + } + } + + if (name === 'ai_query_source_refs') { + const sessionIds = Array.isArray(args.sessionIds) + ? args.sessionIds.map((value: any) => normalizeText(value)).filter(Boolean) + : [] + const result = await wcdbService.aiQuerySourceRefs({ + sessionIds, + beginTimestamp: parseIntSafe(args.beginTimestamp), + endTimestamp: parseIntSafe(args.endTimestamp) + }) + if (!result.success) return result + if (detailLevel === 'full') return result + return { + success: true, + data: { + range: result.data?.range || { begin: 0, end: 0 }, + session_count: parseIntSafe(result.data?.session_count), + message_count: parseIntSafe(result.data?.message_count), + db_refs: Array.isArray(result.data?.db_refs) + ? result.data.db_refs.slice(0, detailLevel === 'standard' ? 32 : 16) + : [] + } + } + } + + if (name === 'ai_query_top_contacts') { + const limit = Math.max(1, Math.min(30, parseIntSafe(args.limit, 8))) + const scanLimit = Math.max(limit, Math.min(800, parseIntSafe(args.scanLimit, 320))) + let beginTimestamp = normalizeTimestampSeconds(args.beginTimestamp) + let endTimestamp = normalizeTimestampSeconds(args.endTimestamp) + const includeGroups = args.includeGroups === true + const includeOfficial = args.includeOfficial === true + + const sessionsRes = await chatService.getSessions() + if (!sessionsRes.success || !Array.isArray(sessionsRes.sessions)) { + return { success: false, error: sessionsRes.error || '会话列表获取失败' } + } + + const candidates = sessionsRes.sessions + .filter((session: any) => { + const username = normalizeText(session.username) + if (!username) return false + const isGroup = username.endsWith('@chatroom') + const isOfficial = username.startsWith('gh_') + if (!includeGroups && isGroup) return false + if (!includeOfficial && isOfficial) return false + return true + }) + .sort((a: any, b: any) => parseIntSafe(b.sortTimestamp || b.lastTimestamp) - parseIntSafe(a.sortTimestamp || a.lastTimestamp)) + .slice(0, scanLimit) + + if (candidates.length === 0) { + return { success: true, items: [], total: 0 } + } + + const sessionIds = candidates.map((item: any) => normalizeText(item.username)).filter(Boolean) + const countMap: Record = {} + const hasRange = beginTimestamp > 0 || endTimestamp > 0 + if (hasRange) { + const statsRes = await wcdbService.getSessionMessageTypeStatsBatch(sessionIds, { + beginTimestamp, + endTimestamp, + quickMode: true, + includeGroupSenderCount: false + }) + if (statsRes.success && statsRes.data) { + for (const sessionId of sessionIds) { + const row: any = (statsRes.data as any)?.[sessionId] || {} + countMap[sessionId] = Math.max(0, parseIntSafe(row.totalMessages ?? row.total_messages ?? row.total)) + } + } else { + const countRes = await chatService.getSessionMessageCounts(sessionIds, { preferHintCache: true }) + if (!countRes.success || !countRes.counts) { + return { success: false, error: countRes.error || '消息计数失败' } + } + Object.assign(countMap, countRes.counts) + } + } else { + const countRes = await chatService.getSessionMessageCounts(sessionIds, { preferHintCache: true }) + if (!countRes.success || !countRes.counts) { + return { success: false, error: countRes.error || '消息计数失败' } + } + Object.assign(countMap, countRes.counts) + } + + const nowSec = Math.floor(Date.now() / 1000) + const rows = candidates.map((session: any) => { + const sessionId = normalizeText(session.username) + const messageCount = Math.max(0, parseIntSafe(countMap[sessionId])) + const lastTime = parseIntSafe(session.lastTimestamp || session.sortTimestamp) + const daysSinceLast = lastTime > 0 ? Math.max(0, Math.floor((nowSec - lastTime) / 86400)) : 9999 + const recencyBoost = Math.max(0, 30 - Math.min(30, daysSinceLast)) + const score = messageCount * 100 + recencyBoost + return { + sessionId, + displayName: normalizeText(session.displayName || sessionId), + messageCount, + lastTime, + isGroup: sessionId.endsWith('@chatroom'), + score + } + }) + + rows.sort((a, b) => b.score - a.score || b.messageCount - a.messageCount || b.lastTime - a.lastTime) + const top = rows.slice(0, limit) + + if (detailLevel === 'full') { + return { + success: true, + total: rows.length, + beginTimestamp, + endTimestamp, + items: top + } + } + if (detailLevel === 'standard') { + return { + success: true, + total: rows.length, + beginTimestamp, + endTimestamp, + items: top.map((item) => ({ + sessionId: item.sessionId, + displayName: item.displayName, + messageCount: item.messageCount, + lastTime: item.lastTime, + isGroup: item.isGroup, + score: item.score + })) + } + } + return { + success: true, + total: rows.length, + items: top.map((item) => ({ + sessionId: item.sessionId, + displayName: item.displayName, + messageCount: item.messageCount + })) + } + } + + if (name === 'ai_fetch_message_briefs') { + const items = Array.isArray(args.items) + ? args.items + .map((item: any) => ({ + sessionId: normalizeText(item?.sessionId), + localId: parseIntSafe(item?.localId) + })) + .filter((item) => item.sessionId && item.localId > 0) + : [] + const requests = items.slice(0, 20) + if (requests.length === 0) { + return { success: false, error: '请提供 items: [{sessionId, localId}]' } + } + + const rows: any[] = [] + for (const item of requests) { + const result = await chatService.getMessageById(item.sessionId, item.localId) + if (!result.success || !result.message) { + rows.push({ + sessionId: item.sessionId, + localId: item.localId, + success: false, + error: normalizeText(result.error, '消息不存在') + }) + continue + } + const message = result.message + const base = { + sessionId: item.sessionId, + localId: item.localId, + createTime: parseIntSafe(message.createTime), + sender: normalizeText(message.senderUsername), + localType: parseIntSafe(message.localType), + parsedContent: normalizeText(message.parsedContent) + } + if (detailLevel === 'full') { + rows.push({ + ...base, + rawContent: normalizeText(message.rawContent), + serverId: message.serverIdRaw || message.serverId || '', + isSend: parseIntSafe(message.isSend), + appMsgKind: normalizeText(message.appMsgKind), + fileName: normalizeText(message.fileName) + }) + } else if (detailLevel === 'standard') { + rows.push({ + ...base, + rawContent: normalizeText(message.rawContent).slice(0, 320) + }) + } else { + rows.push({ + sessionId: base.sessionId, + localId: base.localId, + createTime: base.createTime, + sender: base.sender, + snippet: base.parsedContent.slice(0, 200) + }) + } + } + + return { + success: true, + count: rows.length, + rows + } + } + + if (name === 'ai_list_voice_messages') { + const sessionId = normalizeText(args.sessionId) + const list = await chatService.getResourceMessages({ + sessionId: sessionId || undefined, + types: ['voice'], + beginTimestamp: parseIntSafe(args.beginTimestamp), + endTimestamp: parseIntSafe(args.endTimestamp), + limit: Math.max(1, Math.min(maxMessagesPerRequest, parseIntSafe(args.limit, 80))), + offset: parseIntSafe(args.offset) + }) + if (!list.success) { + return { success: false, error: list.error || '语音清单检索失败' } + } + const items = (list.items || []).map((item: any) => ({ + id: `${normalizeText(item.sessionId)}:${parseIntSafe(item.localId)}:${parseIntSafe(item.createTime)}`, + sessionId: normalizeText(item.sessionId), + sessionName: normalizeText(item.sessionDisplayName || item.sessionId), + localId: parseIntSafe(item.localId), + createTime: parseIntSafe(item.createTime), + sender: normalizeText(item.senderUsername), + durationSec: parseIntSafe(item.voiceDurationSeconds), + hint: normalizeText(item.parsedContent || item.rawContent).slice(0, 80) + })) + if (detailLevel === 'minimal') { + return { + success: true, + total: parseIntSafe(list.total, items.length), + hasMore: list.hasMore === true, + ids: items.slice(0, 50).map((item) => item.id), + note: '先选择要转写的语音ID,再调用 ai_transcribe_voice_messages' + } + } + return { + success: true, + total: parseIntSafe(list.total, items.length), + hasMore: list.hasMore === true, + items: detailLevel === 'full' ? items : items.slice(0, 40) + } + } + + if (name === 'ai_transcribe_voice_messages') { + const requestsFromIds = this.parseVoiceIds(Array.isArray(args.ids) ? args.ids : []) + const requestsFromItems = Array.isArray(args.items) + ? args.items.map((item: any) => ({ + sessionId: normalizeText(item?.sessionId), + localId: parseIntSafe(item?.localId), + createTime: parseIntSafe(item?.createTime) || undefined + })).filter((item) => item.sessionId && item.localId > 0) + : [] + + const merged = [...requestsFromIds, ...requestsFromItems] + const dedupMap = new Map() + for (const item of merged) { + const key = `${item.sessionId}:${item.localId}:${item.createTime || 0}` + if (!dedupMap.has(key)) dedupMap.set(key, item) + } + const requests = Array.from(dedupMap.values()).slice(0, VOICE_TRANSCRIBE_BATCH_LIMIT) + if (requests.length === 0) { + return { + success: false, + error: '请先调用 ai_list_voice_messages 获取 IDs,再指定要转写的语音ID(sessionId:localId[:createTime])' + } + } + + const results: Array<{ + id: string + sessionId: string + localId: number + createTime?: number + success: boolean + transcript?: string + error?: string + }> = [] + + for (const req of requests) { + const transcript = await chatService.getVoiceTranscript( + req.sessionId, + String(req.localId), + req.createTime + ) + const id = `${req.sessionId}:${req.localId}:${req.createTime || 0}` + if (transcript.success) { + results.push({ + id, + sessionId: req.sessionId, + localId: req.localId, + createTime: req.createTime, + success: true, + transcript: normalizeText(transcript.transcript) + }) + } else { + results.push({ + id, + sessionId: req.sessionId, + localId: req.localId, + createTime: req.createTime, + success: false, + error: normalizeText(transcript.error, '转写失败') + }) + } + } + + return { + success: true, + requested: requests.length, + successCount: results.filter((item) => item.success).length, + results: detailLevel === 'full' + ? results + : results.map((item) => ({ + id: item.id, + success: item.success, + transcript: item.transcript + ? item.transcript.slice(0, detailLevel === 'standard' ? 380 : 220) + : undefined, + error: item.error + })) + } + } + + if (name === 'activate_skill') { + const skillId = normalizeText((args as any)?.skill_id) + if (!skillId) return { success: false, error: '缺少 skill_id' } + const skill = await aiSkillService.getConfig(skillId) + if (!skill) return { success: false, error: `技能不存在: ${skillId}` } + return { + success: true, + skillId: skill.id, + name: skill.name, + description: skill.description, + prompt: skill.prompt, + tools: skill.tools + } + } + + return { success: false, error: `未知工具: ${name}` } + } + + private async recordToolRun( + aiDbPath: string, + runId: string, + conversationId: string, + messageId: string, + trace: AiToolCallTrace, + result: unknown + ): Promise { + const sql = `INSERT INTO ai_tool_runs ( + run_id, conversation_id, message_id, tool_name, tool_args_json, tool_result_json, status, duration_ms, error, created_at + ) VALUES ( + '${escSql(runId)}', + '${escSql(conversationId)}', + '${escSql(messageId)}', + '${escSql(trace.toolName)}', + '${escSql(JSON.stringify(trace.args || {}))}', + '${escSql(JSON.stringify(result ?? {}))}', + '${escSql(trace.status)}', + ${parseIntSafe(trace.durationMs)}, + '${escSql(trace.error || '')}', + ${Date.now()} + )` + await this.queryRows(aiDbPath, sql) + } + + private async appendToolStepMessage( + aiDbPath: string, + conversationId: string, + intent: AiIntentType, + trace: AiToolCallTrace, + toolResult: any + ): Promise { + const payload = { + type: 'tool_step', + toolName: trace.toolName, + status: trace.status, + durationMs: trace.durationMs, + args: trace.args || {}, + result: this.compactToolResultForStep(toolResult) + } + let raw = JSON.stringify(payload) + if (raw.length > 2800) { + raw = JSON.stringify({ + ...payload, + result: { + ...(payload.result || {}), + truncated: true + } + }) + } + const content = `__wf_tool_step__${raw}` + + await this.queryRows( + aiDbPath, + `INSERT INTO ai_messages ( + message_id,conversation_id,role,content,intent_type,components_json,tool_trace_json,usage_json,error,parent_message_id,created_at + ) VALUES ( + '${escSql(randomUUID())}', + '${escSql(conversationId)}', + 'tool', + '${escSql(content)}', + '${escSql(intent)}', + '[]', + '${escSql(JSON.stringify([trace]))}', + '{}', + '', + '', + ${Date.now()} + )` + ) + } + + private emitRunEvent( + callback: ((event: AiAnalysisRunEvent) => void) | undefined, + payload: AiAnalysisRunEvent + ): void { + if (!callback) return + try { + callback(payload) + } catch { + // ignore emitter errors + } + } + + private compactToolResultForStep(result: any): Record { + if (!result || typeof result !== 'object') return {} + const data: Record = {} + if ('success' in result) data.success = Boolean(result.success) + if ('count' in result) data.count = parseIntSafe((result as any).count) + if ('total' in result) data.total = parseIntSafe((result as any).total) + if ('activeCount' in result) data.activeCount = parseIntSafe((result as any).activeCount) + if ('requested' in result) data.requested = parseIntSafe((result as any).requested) + if ('successCount' in result) data.successCount = parseIntSafe((result as any).successCount) + if ('hasMore' in result) data.hasMore = Boolean((result as any).hasMore) + if ((result as any).error) data.error = normalizeText((result as any).error) + if (Array.isArray((result as any).ids)) data.ids = (result as any).ids.slice(0, 8) + if (Array.isArray((result as any).items)) data.itemsPreview = (result as any).items.slice(0, 2) + if (Array.isArray((result as any).rows)) data.rowsPreview = (result as any).rows.slice(0, 2) + if ((result as any).nextOffset) data.nextOffset = parseIntSafe((result as any).nextOffset) + return data + } + + private buildComponents( + intent: AiIntentType, + userText: string, + tools: ToolBundle + ): AiResultComponent[] { + const sessionNameMap = new Map() + for (const row of Array.isArray(tools.sessionCandidates) ? tools.sessionCandidates : []) { + const sessionId = normalizeText(row.sessionId || row.session_id || row._session_id) + const sessionName = normalizeText(row.sessionName || row.session_name || row.display_name) + if (sessionId && sessionName && !sessionNameMap.has(sessionId)) { + sessionNameMap.set(sessionId, sessionName) + } + } + + const timelineItemsRaw = Array.isArray(tools.timelineRows) ? tools.timelineRows : [] + const timelineItems = timelineItemsRaw.slice(0, 120).map((row: any) => ({ + ts: parseIntSafe(row.create_time), + sessionId: normalizeText(row._session_id), + sessionName: normalizeText(row.session_name || sessionNameMap.get(normalizeText(row._session_id)) || row._session_id), + sender: normalizeText(row.sender_username || '未知'), + snippet: normalizeText(row.content).slice(0, 200), + localId: parseIntSafe(row.local_id), + createTime: parseIntSafe(row.create_time) + })) + + const sessionIdsFromTimeline = Array.from(new Set(timelineItems.map((item) => item.sessionId).filter(Boolean))) + const sourceData = tools.sourceRefs && typeof tools.sourceRefs === 'object' ? tools.sourceRefs : {} + + const summaryBullets = [ + `识别任务类型:${intent}`, + `命中会话数:${sessionIdsFromTimeline.length || parseIntSafe(sourceData.session_count)}`, + `时间轴事件数:${timelineItems.length}` + ] + if (timelineItems.length > 0) { + const first = timelineItems[0] + summaryBullets.push(`最近事件:${first.sessionName || first.sessionId} / ${first.snippet.slice(0, 30)}`) + } + if (tools.activeSessions.length > 0) { + summaryBullets.push(`时间窗活跃会话:${tools.activeSessions.length} 个`) + } + if (tools.sessionGlimpses.length > 0) { + summaryBullets.push(`抽样阅读消息:${tools.sessionGlimpses.length} 条`) + } + if (tools.topContacts.length > 0) { + const top = tools.topContacts[0] + summaryBullets.push(`高频联系人Top1:${normalizeText(top.displayName || top.sessionId)}(${parseIntSafe(top.messageCount)}条)`) + } + if (tools.messageBriefs.length > 0) { + summaryBullets.push(`关键证据消息:${tools.messageBriefs.length} 条`) + } + if (tools.voiceCatalog.length > 0) { + summaryBullets.push(`语音候选ID:${tools.voiceCatalog.length} 条`) + } + if (tools.voiceTranscripts.length > 0) { + summaryBullets.push(`语音转写成功:${tools.voiceTranscripts.filter((item: any) => item.success).length}/${tools.voiceTranscripts.length}`) + } + if (normalizeText(userText).includes('去年')) { + summaryBullets.push('已按“去年”语义优先检索相关时间范围') + } + + const summary: SummaryComponent = { + type: 'summary', + title: 'AI 分析总结', + bullets: summaryBullets, + conclusion: timelineItems.length > 0 + ? '已完成检索与归纳,可继续追问“按月份展开”或“只看某个联系人”。' + : '当前条件未检索到足够事件,建议补充关键词或时间范围。' + } + + const timeline: TimelineComponent = { + type: 'timeline', + items: timelineItems + } + + const source: SourceComponent = { + type: 'source', + range: { + begin: parseIntSafe(sourceData?.range?.begin), + end: parseIntSafe(sourceData?.range?.end) + }, + sessionCount: parseIntSafe(sourceData?.session_count, sessionIdsFromTimeline.length), + messageCount: parseIntSafe(sourceData?.message_count), + dbRefs: Array.isArray(sourceData?.db_refs) ? sourceData.db_refs.map((item: any) => normalizeText(item)).filter(Boolean).slice(0, 24) : [] + } + + return [timeline, summary, source] + } + + private isRunAborted(runId: string): boolean { + const state = this.activeRuns.get(runId) + return Boolean(state?.aborted) + } + + private async upsertConversationTitle(aiDbPath: string, conversationId: string, fallbackInput: string): Promise { + const rows = await this.queryRows(aiDbPath, `SELECT title FROM ai_conversations WHERE conversation_id='${escSql(conversationId)}' LIMIT 1`) + const currentTitle = normalizeText(rows?.[0]?.title) + if (currentTitle) return + const title = normalizeText(fallbackInput).slice(0, 40) || '新的 AI 对话' + await this.queryRows( + aiDbPath, + `UPDATE ai_conversations SET title='${escSql(title)}', updated_at=${Date.now()} WHERE conversation_id='${escSql(conversationId)}'` + ) + } + + private async maybeCompressContext(aiDbPath: string, conversationId: string): Promise { + const countRows = await this.queryRows(aiDbPath, `SELECT COUNT(1) AS cnt FROM ai_messages WHERE conversation_id='${escSql(conversationId)}'`) + const count = parseIntSafe(countRows?.[0]?.cnt) + if (count <= CONTEXT_COMPRESS_TRIGGER_COUNT) return + + const oldRows = await this.queryRows( + aiDbPath, + `SELECT id,role,content,created_at FROM ai_messages + WHERE conversation_id='${escSql(conversationId)}' + ORDER BY created_at ASC + LIMIT ${Math.max(1, count - CONTEXT_KEEP_AFTER_COMPRESS)}` + ) + if (!oldRows.length) return + + const summaryLines: string[] = [] + for (const row of oldRows.slice(-120)) { + const role = normalizeText(row.role) + if (role !== 'user' && role !== 'assistant') continue + const createdAt = parseIntSafe(row.created_at) + const content = normalizeText(row.content).replace(/\s+/g, ' ').slice(0, 100) + if (!content) continue + summaryLines.push(`- [${createdAt}] ${role}: ${content}`) + } + + const prevSummaryRows = await this.queryRows( + aiDbPath, + `SELECT summary_text FROM ai_conversations WHERE conversation_id='${escSql(conversationId)}' LIMIT 1` + ) + const prevSummary = normalizeText(prevSummaryRows?.[0]?.summary_text) + const nextSummary = [ + prevSummary ? `历史摘要(旧):\n${prevSummary.slice(-2000)}` : '', + '历史压缩补充:', + ...summaryLines.slice(-80) + ].filter(Boolean).join('\n') + + await this.queryRows( + aiDbPath, + `UPDATE ai_conversations + SET summary_text='${escSql(nextSummary.slice(-CONTEXT_SUMMARY_MAX_CHARS))}', updated_at=${Date.now()} + WHERE conversation_id='${escSql(conversationId)}'` + ) + + const removeIds = oldRows.map((row) => parseIntSafe(row.id)).filter((id) => id > 0) + if (removeIds.length > 0) { + await this.queryRows( + aiDbPath, + `DELETE FROM ai_messages WHERE id IN (${removeIds.join(',')})` + ) + } + } + + private async buildModelMessages( + aiDbPath: string, + conversationId: string, + userInput: string, + options?: { + assistantSystemPrompt?: string + manualSkillPrompt?: string + autoSkillMenu?: string + } + ): Promise { + await this.maybeCompressContext(aiDbPath, conversationId) + const historyLimit = Math.max( + 4, + Math.min(60, parseIntSafe(this.config.get('aiAgentMaxHistoryRounds'), CONTEXT_RECENT_LIMIT)) + ) + + const summaryRows = await this.queryRows( + aiDbPath, + `SELECT summary_text FROM ai_conversations WHERE conversation_id='${escSql(conversationId)}' LIMIT 1` + ) + const summaryText = normalizeText(summaryRows?.[0]?.summary_text) + + const rows = await this.queryRows( + aiDbPath, + `SELECT role,content FROM ai_messages + WHERE conversation_id='${escSql(conversationId)}' + ORDER BY created_at DESC + LIMIT ${historyLimit * 2}` + ) + + const recentTurns = rows + .reverse() + .filter((row) => { + const role = normalizeText(row.role) + return role === 'user' || role === 'assistant' + }) + .slice(-historyLimit) + .map((row) => ({ role: normalizeText(row.role), content: normalizeText(row.content) })) + + const baseSkill = await this.loadSkill('base') + + const messages: any[] = [ + { role: 'system', content: baseSkill } + ] + messages.push({ + role: 'system', + content: `完成任务时请输出 ${FINAL_DONE_MARKER},并用 ... 包裹最终回答。` + }) + + if (options?.assistantSystemPrompt) { + messages.push({ + role: 'system', + content: `assistant_system_prompt:\n${options.assistantSystemPrompt}` + }) + } + + if (summaryText) { + const compressionSkill = await this.loadSkill('context_compression') + messages.push({ role: 'system', content: `skill(context_compression):\n${compressionSkill}` }) + messages.push({ role: 'system', content: `conversation_summary:\n${summaryText}` }) + } + + if (options?.manualSkillPrompt) { + messages.push({ + role: 'system', + content: `active_skill_manual:\n${options.manualSkillPrompt}` + }) + } else if (options?.autoSkillMenu) { + messages.push({ + role: 'system', + content: `auto_skill_menu:\n${options.autoSkillMenu}` + }) + } + + const preprocessConfig = { + clean: this.config.get('aiAgentPreprocessClean') !== false, + merge: this.config.get('aiAgentPreprocessMerge') !== false, + denoise: this.config.get('aiAgentPreprocessDenoise') !== false, + desensitize: this.config.get('aiAgentPreprocessDesensitize') === true, + anonymize: this.config.get('aiAgentPreprocessAnonymize') === true + } + const searchContextBefore = Math.max(0, Math.min(20, parseIntSafe(this.config.get('aiAgentSearchContextBefore'), 3))) + const searchContextAfter = Math.max(0, Math.min(20, parseIntSafe(this.config.get('aiAgentSearchContextAfter'), 3))) + messages.push({ + role: 'system', + content: `tool_search_context: before=${searchContextBefore}, after=${searchContextAfter}; preprocess=${JSON.stringify(preprocessConfig)}` + }) + + let recentTotalChars = 0 + const boundedRecentTurns = recentTurns + .slice() + .reverse() + .filter((turn) => { + const content = normalizeText(turn.content) + if (!content) return false + const cost = content.length + if (recentTotalChars + cost > CONTEXT_RECENT_MAX_CHARS) return false + recentTotalChars += cost + return true + }) + .reverse() + + messages.push(...boundedRecentTurns) + messages.push({ role: 'user', content: userInput }) + return messages + } + + async listConversations(page = 1, pageSize = 20): Promise<{ success: boolean; conversations?: any[]; error?: string }> { + try { + const { dbPath } = await this.ensureReady() + const p = Math.max(1, page) + const size = Math.max(1, Math.min(100, pageSize)) + const offset = (p - 1) * size + const rows = await this.queryRows( + dbPath, + `SELECT conversation_id,title,created_at,updated_at,last_message_at FROM ai_conversations + ORDER BY updated_at DESC LIMIT ${size} OFFSET ${offset}` + ) + return { + success: true, + conversations: rows.map((row) => ({ + conversationId: normalizeText(row.conversation_id), + title: normalizeText(row.title, '新的 AI 对话'), + createdAt: parseIntSafe(row.created_at), + updatedAt: parseIntSafe(row.updated_at), + lastMessageAt: parseIntSafe(row.last_message_at) + })) + } + } catch (error) { + return { success: false, error: (error as Error).message } + } + } + + async createConversation(title = ''): Promise<{ success: boolean; conversationId?: string; error?: string }> { + try { + const { dbPath } = await this.ensureReady() + const conversationId = randomUUID() + const now = Date.now() + const safeTitle = normalizeText(title, '新的 AI 对话').slice(0, 80) + await this.queryRows( + dbPath, + `INSERT INTO ai_conversations (conversation_id,title,summary_text,created_at,updated_at,last_message_at) + VALUES ('${escSql(conversationId)}','${escSql(safeTitle)}','',${now},${now},${now})` + ) + return { success: true, conversationId } + } catch (error) { + return { success: false, error: (error as Error).message } + } + } + + async deleteConversation(conversationId: string): Promise<{ success: boolean; error?: string }> { + try { + const { dbPath } = await this.ensureReady() + const safeId = escSql(conversationId) + await this.queryRows(dbPath, `DELETE FROM ai_messages WHERE conversation_id='${safeId}'`) + await this.queryRows(dbPath, `DELETE FROM ai_tool_runs WHERE conversation_id='${safeId}'`) + await this.queryRows(dbPath, `DELETE FROM ai_conversations WHERE conversation_id='${safeId}'`) + return { success: true } + } catch (error) { + return { success: false, error: (error as Error).message } + } + } + + async renameConversation(conversationId: string, title: string): Promise<{ success: boolean; error?: string }> { + try { + const { dbPath } = await this.ensureReady() + const safeId = escSql(conversationId) + const safeTitle = normalizeText(title, '新的 AI 对话').slice(0, 80) + await this.queryRows( + dbPath, + `UPDATE ai_conversations SET title='${escSql(safeTitle)}', updated_at=${Date.now()} WHERE conversation_id='${safeId}'` + ) + return { success: true } + } catch (error) { + return { success: false, error: (error as Error).message } + } + } + + async exportConversation(conversationId: string): Promise<{ + success: boolean + conversation?: { conversationId: string; title: string; updatedAt: number } + markdown?: string + error?: string + }> { + try { + const { dbPath } = await this.ensureReady() + const safeId = escSql(conversationId) + const convoRows = await this.queryRows( + dbPath, + `SELECT conversation_id,title,updated_at FROM ai_conversations WHERE conversation_id='${safeId}' LIMIT 1` + ) + if (!convoRows.length) return { success: false, error: '会话不存在' } + const messageRows = await this.queryRows( + dbPath, + `SELECT role,content,created_at FROM ai_messages WHERE conversation_id='${safeId}' ORDER BY created_at ASC LIMIT 2000` + ) + const headerTitle = normalizeText(convoRows[0]?.title, 'AI 对话') + const lines = [ + `# ${headerTitle}`, + '', + `导出时间: ${new Date().toISOString()}`, + '' + ] + for (const row of messageRows) { + const role = normalizeText(row.role, 'assistant') + if (role === 'tool') continue + const content = normalizeText(row.content) + if (!content) continue + const roleText = role === 'user' ? '用户' : role === 'assistant' ? '助手' : role + lines.push(`## ${roleText} (${new Date(parseIntSafe(row.created_at)).toLocaleString('zh-CN')})`) + lines.push('') + lines.push(content) + lines.push('') + } + return { + success: true, + conversation: { + conversationId: normalizeText(convoRows[0]?.conversation_id), + title: headerTitle, + updatedAt: parseIntSafe(convoRows[0]?.updated_at) + }, + markdown: lines.join('\n') + } + } catch (error) { + return { success: false, error: (error as Error).message } + } + } + + async listMessages(conversationId: string, limit = 200): Promise<{ success: boolean; messages?: any[]; error?: string }> { + try { + const { dbPath } = await this.ensureReady() + const rows = await this.queryRows( + dbPath, + `SELECT message_id,conversation_id,role,content,intent_type,components_json,tool_trace_json,usage_json,error,parent_message_id,created_at + FROM ai_messages WHERE conversation_id='${escSql(conversationId)}' + ORDER BY created_at ASC LIMIT ${Math.max(1, Math.min(1000, limit))}` + ) + return { + success: true, + messages: rows.map((row) => ({ + ...(function () { + const role = normalizeText(row.role) + const rawContent = normalizeText(row.content) + if (role !== 'tool') { + return { role, content: rawContent } + } + const parsed = parseStoredToolStep(rawContent) + if (!parsed) { + return { role, content: rawContent } + } + const compact = Object.entries(parsed.result || {}) + .slice(0, 4) + .map(([key, value]) => `${key}=${String(value)}`) + .join(',') + const suffix = compact ? `,${compact}` : '' + return { + role, + content: `工具 ${parsed.toolName || 'unknown'} (${parsed.status || 'unknown'}, ${parsed.durationMs}ms)${suffix}` + } + })(), + messageId: normalizeText(row.message_id), + conversationId: normalizeText(row.conversation_id), + intentType: normalizeText(row.intent_type), + components: (() => { try { return JSON.parse(normalizeText(row.components_json, '[]')) } catch { return [] } })(), + toolTrace: (() => { try { return JSON.parse(normalizeText(row.tool_trace_json, '[]')) } catch { return [] } })(), + usage: (() => { try { return JSON.parse(normalizeText(row.usage_json, '{}')) } catch { return {} } })(), + error: normalizeText(row.error), + parentMessageId: normalizeText(row.parent_message_id), + createdAt: parseIntSafe(row.created_at) + })) + } + } catch (error) { + return { success: false, error: (error as Error).message } + } + } + + async abortRun(payload: { runId?: string; conversationId?: string }): Promise<{ success: boolean }> { + const runId = normalizeText(payload?.runId) + const conversationId = normalizeText(payload?.conversationId) + if (runId && this.activeRuns.has(runId)) { + const state = this.activeRuns.get(runId)! + state.aborted = true + return { success: true } + } + if (conversationId) { + for (const state of this.activeRuns.values()) { + if (state.conversationId === conversationId) state.aborted = true + } + } + return { success: true } + } + + async retryMessage(payload: { + conversationId: string + userMessageId?: string + }, runtime?: { + onRunEvent?: (event: AiAnalysisRunEvent) => void + }): Promise<{ success: boolean; result?: SendMessageResult; error?: string }> { + try { + const { dbPath } = await this.ensureReady() + const conversationId = normalizeText(payload.conversationId) + const userMessageId = normalizeText(payload.userMessageId) + let rows: any[] = [] + if (userMessageId) { + rows = await this.queryRows( + dbPath, + `SELECT message_id,content FROM ai_messages WHERE conversation_id='${escSql(conversationId)}' AND message_id='${escSql(userMessageId)}' AND role='user' LIMIT 1` + ) + } + if (!rows.length) { + rows = await this.queryRows( + dbPath, + `SELECT message_id,content FROM ai_messages WHERE conversation_id='${escSql(conversationId)}' AND role='user' ORDER BY created_at DESC LIMIT 1` + ) + } + if (!rows.length) return { success: false, error: '未找到可重试的用户消息' } + const row = rows[0] + const result = await this.sendMessage(conversationId, normalizeText(row.content), { + parentMessageId: normalizeText(row.message_id), + persistUserMessage: false + }, runtime) + if (!result.success) return { success: false, error: result.error } + return { success: true, result: result.result } + } catch (error) { + return { success: false, error: (error as Error).message } + } + } + + private async ensureToolSkillInjected( + toolName: string, + injectedSkills: Set, + modelMessages: any[] + ): Promise { + const map: Record = { + ai_query_time_window_activity: 'tool_time_window_activity', + ai_query_session_glimpse: 'tool_session_glimpse', + ai_query_session_candidates: 'tool_session_candidates', + ai_query_timeline: 'tool_timeline', + ai_query_topic_stats: 'tool_topic_stats', + ai_query_source_refs: 'tool_source_refs', + ai_query_top_contacts: 'tool_top_contacts', + ai_fetch_message_briefs: 'tool_message_briefs', + ai_list_voice_messages: 'tool_voice_list', + ai_transcribe_voice_messages: 'tool_voice_transcribe' + } + const skill = map[toolName] + if (!skill || injectedSkills.has(skill)) return + injectedSkills.add(skill) + const skillText = await this.loadSkill(skill) + modelMessages.push({ role: 'system', content: `skill(${toolName}):\n${skillText}` }) + } + + async sendMessage( + conversationId: string, + userInput: string, + options?: SendMessageOptions, + runtime?: { + onRunEvent?: (event: AiAnalysisRunEvent) => void + } + ): Promise<{ success: boolean; result?: SendMessageResult; error?: string }> { + const now = Date.now() + const runId = randomUUID() + const aiRun: AiRunState = { + runId, + conversationId, + aborted: false + } + this.activeRuns.set(runId, aiRun) + + try { + const { apiBaseUrl, apiKey, model } = this.getSharedModelConfig() + if (!apiBaseUrl || !apiKey) { + return { success: false, error: '请先在设置 > AI通用 中填写 Base URL 和 API Key' } + } + + const { dbPath } = await this.ensureReady() + const convId = normalizeText(conversationId) + if (!convId) { + const created = await this.createConversation() + if (!created.success || !created.conversationId) { + return { success: false, error: created.error || '创建会话失败' } + } + conversationId = created.conversationId + } else { + const existingConv = await this.queryRows(dbPath, `SELECT conversation_id FROM ai_conversations WHERE conversation_id='${escSql(convId)}' LIMIT 1`) + if (!existingConv.length) { + const created = await this.createConversation() + if (!created.success || !created.conversationId) { + return { success: false, error: created.error || '创建会话失败' } + } + conversationId = created.conversationId + } else { + conversationId = convId + } + } + aiRun.conversationId = conversationId + + await this.upsertConversationTitle(dbPath, conversationId, userInput) + + const chatType = this.resolveChatType(options) + const preferredAssistantId = normalizeText(options?.assistantId, 'general_cn') + const selectedAssistant = + await aiAssistantService.getConfig(preferredAssistantId) || + await aiAssistantService.getConfig('general_cn') + const assistantSystemPrompt = normalizeText(selectedAssistant?.systemPrompt) + const allowedToolNames = this.resolveAllowedToolNames(selectedAssistant?.allowedBuiltinTools) + const allowedToolSet = new Set(allowedToolNames) + + let manualSkillPrompt = '' + const manualSkillId = normalizeText(options?.activeSkillId) + if (manualSkillId) { + const manualSkill = await aiSkillService.getConfig(manualSkillId) + if (manualSkill) { + const scopeMatched = manualSkill.chatScope === 'all' || manualSkill.chatScope === chatType + const missingTools = manualSkill.tools.filter((toolName) => !allowedToolSet.has(toolName)) + if (scopeMatched && missingTools.length === 0) { + manualSkillPrompt = normalizeText(manualSkill.prompt) + } + } + } + const enableAutoSkill = this.config.get('aiAgentEnableAutoSkill') === true + const autoSkillMenu = !manualSkillPrompt && enableAutoSkill + ? await aiSkillService.getAutoSkillMenu(chatType, allowedToolNames) + : null + + const userMessageId = randomUUID() + const persistUserMessage = options?.persistUserMessage !== false + const intent = defaultIntentType() + this.emitRunEvent(runtime?.onRunEvent, { + runId, + conversationId, + stage: 'run_started', + ts: Date.now(), + message: `开始分析请求(助手:${selectedAssistant?.name || '通用分析助手'})` + }) + this.emitRunEvent(runtime?.onRunEvent, { + runId, + conversationId, + stage: 'intent_identified', + ts: Date.now(), + message: '意图由 AI 在推理中自主判断(本地不预匹配)', + intent + }) + if (persistUserMessage) { + await this.queryRows( + dbPath, + `INSERT INTO ai_messages (message_id,conversation_id,role,content,intent_type,created_at,parent_message_id) + VALUES ('${escSql(userMessageId)}','${escSql(conversationId)}','user','${escSql(userInput)}','${escSql(intent)}',${now},'${escSql(options?.parentMessageId || '')}')` + ) + } + + const modelMessages = await this.buildModelMessages(dbPath, conversationId, userInput, { + assistantSystemPrompt, + manualSkillPrompt, + autoSkillMenu: autoSkillMenu || undefined + }) + const injectedSkills = new Set(['base']) + + const toolTrace: AiToolCallTrace[] = [] + const toolBundle: ToolBundle = { + activeSessions: [], + sessionGlimpses: [], + sessionCandidates: [], + timelineRows: [], + topicStats: null, + sourceRefs: null, + topContacts: [], + messageBriefs: [], + voiceCatalog: [], + voiceTranscripts: [] + } + + let finalText = '' + let usage: SendMessageResult['usage'] = {} + let lastAssistantText = '' + let hasToolExecution = false + let protocolViolationCount = 0 + + for (let loop = 0; loop < MAX_TOOL_LOOPS; loop += 1) { + if (this.isRunAborted(runId)) { + this.emitRunEvent(runtime?.onRunEvent, { + runId, + conversationId, + stage: 'aborted', + ts: Date.now(), + message: '任务已取消' + }) + return { success: false, error: '任务已取消' } + } + + this.emitRunEvent(runtime?.onRunEvent, { + runId, + conversationId, + stage: 'llm_round_started', + ts: Date.now(), + round: loop + 1, + message: `第 ${loop + 1} 轮推理开始` + }) + const llmRes = await this.requestLlmStep(modelMessages, model, apiBaseUrl, apiKey, allowedToolNames) + usage = llmRes.usage + this.emitRunEvent(runtime?.onRunEvent, { + runId, + conversationId, + stage: 'llm_round_result', + ts: Date.now(), + round: loop + 1, + message: llmRes.toolCalls.length > 0 + ? `第 ${loop + 1} 轮返回 ${llmRes.toolCalls.length} 个工具调用` + : `第 ${loop + 1} 轮直接产出答案`, + data: { + toolCalls: llmRes.toolCalls.length + } + }) + + if (!llmRes.toolCalls.length) { + const cleanedAssistant = this.stripFinalMarker(llmRes.content) + if (cleanedAssistant) { + lastAssistantText = cleanedAssistant + } + if (!hasToolExecution) { + finalText = cleanedAssistant + break + } + const delivery = this.parseFinalDelivery(llmRes.content) + if (delivery.done && delivery.answer) { + finalText = delivery.answer + break + } + + if (!cleanedAssistant && loop < MAX_TOOL_LOOPS - 1) { + protocolViolationCount += 1 + this.emitRunEvent(runtime?.onRunEvent, { + runId, + conversationId, + stage: 'llm_round_result', + ts: Date.now(), + round: loop + 1, + message: `模型返回空响应,触发协议重试(${protocolViolationCount})`, + data: { protocolViolationCount } + }) + modelMessages.push({ + role: 'system', + content: [ + '协议约束:你不能输出空内容。', + `下一步必须二选一:1) 继续调用工具;2) 输出 ${FINAL_DONE_MARKER} + ...。`, + '若证据不足,请先工具检索,不要停在中间状态。' + ].join('\n') + }) + continue + } + + if (!delivery.done && loop < MAX_TOOL_LOOPS - 1) { + this.emitRunEvent(runtime?.onRunEvent, { + runId, + conversationId, + stage: 'llm_round_result', + ts: Date.now(), + round: loop + 1, + message: 'AI 尚未输出结束标记,继续执行协议回合', + data: { protocolReminder: true } + }) + if (cleanedAssistant) { + modelMessages.push({ + role: 'assistant', + content: cleanedAssistant + }) + } + modelMessages.push({ + role: 'system', + content: [ + `协议提醒:当任务完成时,必须输出 ${FINAL_DONE_MARKER} 并给出 ...。`, + '如果信息不足,不要结束,继续调用工具。' + ].join('\n') + }) + continue + } + finalText = cleanedAssistant + break + } + + protocolViolationCount = 0 + hasToolExecution = true + modelMessages.push({ + role: 'assistant', + content: llmRes.content || '', + tool_calls: llmRes.toolCalls.map((call) => ({ + id: call.id, + type: 'function', + function: { + name: call.name, + arguments: call.argumentsJson + } + })) + }) + + for (const call of llmRes.toolCalls) { + if (this.isRunAborted(runId)) { + this.emitRunEvent(runtime?.onRunEvent, { + runId, + conversationId, + stage: 'aborted', + ts: Date.now(), + message: '任务已取消' + }) + return { success: false, error: '任务已取消' } + } + + await this.ensureToolSkillInjected(call.name, injectedSkills, modelMessages) + + const started = Date.now() + let args: Record = {} + try { + args = JSON.parse(call.argumentsJson || '{}') + } catch { + args = {} + } + + const trace: AiToolCallTrace = { + toolName: call.name, + args, + status: 'ok', + durationMs: 0 + } + this.emitRunEvent(runtime?.onRunEvent, { + runId, + conversationId, + stage: 'tool_start', + ts: Date.now(), + round: loop + 1, + toolName: call.name, + message: `开始调用工具 ${call.name}`, + data: { args } + }) + + let toolResult: any = {} + try { + if (!allowedToolSet.has(call.name)) { + toolResult = { success: false, error: `当前助手未授权工具: ${call.name}` } + } else { + toolResult = await this.runTool(call.name, args, { userInput }) + } + if (!toolResult?.success) { + trace.status = 'error' + trace.error = normalizeText(toolResult?.error, '工具执行失败') + } else { + if (call.name === 'ai_query_time_window_activity') { + toolBundle.activeSessions = Array.isArray(toolResult.items) ? toolResult.items : [] + } else if (call.name === 'ai_query_session_glimpse') { + const rows = Array.isArray(toolResult.rows) ? toolResult.rows : [] + if (rows.length > 0) { + const merged = [...toolBundle.sessionGlimpses, ...rows] + const dedup = new Map() + for (const row of merged) { + const key = `${normalizeText(row.sessionId || row._session_id)}:${parseIntSafe(row.localId || row.local_id)}:${parseIntSafe(row.createTime || row.create_time)}` + if (!dedup.has(key)) dedup.set(key, row) + } + toolBundle.sessionGlimpses = Array.from(dedup.values()).slice(0, MAX_TOOL_RESULT_ROWS) + } + } else if (call.name === 'ai_query_session_candidates') { + toolBundle.sessionCandidates = Array.isArray(toolResult.rows) ? toolResult.rows : [] + } else if (call.name === 'ai_query_timeline') { + const rows = Array.isArray(toolResult.rows) ? toolResult.rows : [] + if (rows.length > 0) { + const merged = [...toolBundle.timelineRows, ...rows] + const dedup = new Map() + for (const row of merged) { + const key = `${normalizeText(row._session_id)}:${parseIntSafe(row.local_id)}:${parseIntSafe(row.create_time)}` + if (!dedup.has(key)) dedup.set(key, row) + } + toolBundle.timelineRows = Array.from(dedup.values()).slice(0, MAX_TOOL_RESULT_ROWS) + } + } else if (call.name === 'ai_query_topic_stats') { + toolBundle.topicStats = toolResult.data || {} + } else if (call.name === 'ai_query_source_refs') { + toolBundle.sourceRefs = toolResult.data || {} + } else if (call.name === 'ai_query_top_contacts') { + toolBundle.topContacts = Array.isArray(toolResult.items) ? toolResult.items : [] + } else if (call.name === 'ai_fetch_message_briefs') { + toolBundle.messageBriefs = Array.isArray(toolResult.rows) ? toolResult.rows : [] + } else if (call.name === 'ai_list_voice_messages') { + if (Array.isArray(toolResult.items)) { + toolBundle.voiceCatalog = toolResult.items + } else if (Array.isArray(toolResult.ids)) { + toolBundle.voiceCatalog = toolResult.ids.map((id: string) => ({ id })) + } else { + toolBundle.voiceCatalog = [] + } + } else if (call.name === 'ai_transcribe_voice_messages') { + toolBundle.voiceTranscripts = Array.isArray(toolResult.results) ? toolResult.results : [] + } + } + } catch (error) { + trace.status = 'error' + trace.error = (error as Error).message + toolResult = { success: false, error: trace.error } + } + + trace.durationMs = Date.now() - started + toolTrace.push(trace) + await this.recordToolRun(dbPath, runId, conversationId, userMessageId, trace, toolResult) + await this.appendToolStepMessage(dbPath, conversationId, intent, trace, toolResult) + this.emitRunEvent(runtime?.onRunEvent, { + runId, + conversationId, + stage: trace.status === 'ok' ? 'tool_done' : 'tool_error', + ts: Date.now(), + round: loop + 1, + toolName: call.name, + status: trace.status, + durationMs: trace.durationMs, + message: trace.status === 'ok' + ? `工具 ${call.name} 完成` + : `工具 ${call.name} 执行失败`, + data: { + args, + result: this.compactToolResultForStep(toolResult), + ...(trace.error ? { error: trace.error } : {}) + } + }) + + modelMessages.push({ + role: 'tool', + tool_call_id: call.id, + content: JSON.stringify(toolResult || {}) + }) + if (call.name === 'activate_skill' && toolResult?.success && normalizeText(toolResult?.prompt)) { + modelMessages.push({ + role: 'system', + content: `active_skill_from_tool:\n${normalizeText(toolResult.prompt)}` + }) + } + } + } + + if (!finalText) { + finalText = lastAssistantText + } + if (!finalText) { + finalText = '模型未返回可交付文本。我会保留上下文,你可以直接继续追问,我将继续执行工具链直到交付结果。' + } + + this.emitRunEvent(runtime?.onRunEvent, { + runId, + conversationId, + stage: 'assembling', + ts: Date.now(), + message: '正在组装结构化结果组件' + }) + const components = this.buildComponents(intent, userInput, toolBundle) + const assistantMessageId = randomUUID() + const createdAt = Date.now() + await this.queryRows( + dbPath, + `INSERT INTO ai_messages ( + message_id,conversation_id,role,content,intent_type,components_json,tool_trace_json,usage_json,error,parent_message_id,created_at + ) VALUES ( + '${escSql(assistantMessageId)}', + '${escSql(conversationId)}', + 'assistant', + '${escSql(finalText)}', + '${escSql(intent)}', + '${escSql(JSON.stringify(components))}', + '${escSql(JSON.stringify(toolTrace))}', + '${escSql(JSON.stringify(usage || {}))}', + '', + '${escSql(options?.parentMessageId || userMessageId)}', + ${createdAt} + )` + ) + + await this.queryRows( + dbPath, + `UPDATE ai_conversations + SET updated_at=${createdAt}, last_message_at=${createdAt} + WHERE conversation_id='${escSql(conversationId)}'` + ) + + this.emitRunEvent(runtime?.onRunEvent, { + runId, + conversationId, + stage: 'completed', + ts: Date.now(), + message: '分析完成并已写入会话记录' + }) + + return { + success: true, + result: { + conversationId, + messageId: assistantMessageId, + assistantText: finalText, + components, + toolTrace, + usage, + createdAt + } + } + } catch (error) { + this.emitRunEvent(runtime?.onRunEvent, { + runId, + conversationId: normalizeText(conversationId), + stage: 'error', + ts: Date.now(), + message: `分析失败:${(error as Error).message}` + }) + return { success: false, error: (error as Error).message } + } finally { + this.activeRuns.delete(runId) + } + } +} + +export const aiAnalysisService = new AiAnalysisService() diff --git a/electron/services/aiAnalysisSkills/base.md b/electron/services/aiAnalysisSkills/base.md new file mode 100644 index 0000000..4293375 --- /dev/null +++ b/electron/services/aiAnalysisSkills/base.md @@ -0,0 +1,30 @@ +你是 WeFlow 的 AI 分析助手。 + +目标: +- 精准完成用户在聊天数据上的查询、总结、分析、回忆任务。 +- 优先使用本地工具获取证据,禁止猜测或捏造。 +- 默认输出简洁中文,先给结论,再给关键依据。 + +工作原则: +- Token 节约优先:默认只请求必要字段,只有用户明确需要或证据不足时再升级 detailLevel。 +- 先范围后细节:优先定位会话/时间范围,再拉取具体时间轴或消息。 +- 可解释性:最终结论尽量附带来源范围与统计口径。 +- 语音消息不能臆测:必须先拿语音 ID,再点名转写,再总结。 +- 联系人排行题(“谁聊得最多/最常联系”)命中 ai_query_top_contacts 后,必须直接给出“前N名+消息数”。 +- 除非用户明确要求,联系人排行默认不包含群聊和公众号。 +- 用户提到“最近/近期/lately/recent”但未给时间窗时,默认按近30天口径统计并写明口径。 +- 用户提到联系人简称(如“lr”)时,先把它当联系人缩写处理,优先命中个人会话,不要默认落到群聊。 +- 用户问“我和X聊了什么”时必须交付“主题总结”,不要贴原始逐条聊天流水。 + +Agent执行要求: +- 用户输入直接进入推理,本地不做关键词分流,你自主决定工具计划。 +- 当用户说“今天凌晨/昨晚/某段时间的聊天”,优先调用 ai_query_time_window_activity。 +- 拿到活跃会话后,调用 ai_query_session_glimpse 对多个会话逐个抽样阅读,不要只读一个会话就停止。 +- 如果初步探索后用户目标仍模糊,主动提出 1 个关键澄清问题继续多轮对话。 +- 仅当你确认任务完成时,输出结束标记 `[[WF_DONE]]`,并紧跟 `...`。 +- 若还未完成,不要输出结束标记,继续调用工具。 + +语音处理硬规则: +- 当用户涉及“语音内容”时,先调用 ai_list_voice_messages。 +- 让系统返回候选 ID 后,再调用 ai_transcribe_voice_messages 指定 ID。 +- 未转写成功的语音不可作为事实依据。 diff --git a/electron/services/aiAnalysisSkills/context_compression.md b/electron/services/aiAnalysisSkills/context_compression.md new file mode 100644 index 0000000..168949a --- /dev/null +++ b/electron/services/aiAnalysisSkills/context_compression.md @@ -0,0 +1,6 @@ +你会收到 conversation_summary(历史压缩摘要)。 + +使用方式: +- 默认把摘要作为历史背景,不逐字复述。 +- 若摘要与最近消息冲突,以最近消息为准。 +- 若用户追问很久之前的细节,优先重新调用工具检索,不依赖旧记忆。 diff --git a/electron/services/aiAnalysisSkills/tool_message_briefs.md b/electron/services/aiAnalysisSkills/tool_message_briefs.md new file mode 100644 index 0000000..428e4df --- /dev/null +++ b/electron/services/aiAnalysisSkills/tool_message_briefs.md @@ -0,0 +1,8 @@ +工具:ai_fetch_message_briefs + +何时用: +- 需要核对少量关键消息原文,避免全量展开。 + +调用建议: +- 只传必要 items(sessionId + localId),每次少量(<=20)。 +- 默认 minimal;需要上下文再用 standard/full。 diff --git a/electron/services/aiAnalysisSkills/tool_session_candidates.md b/electron/services/aiAnalysisSkills/tool_session_candidates.md new file mode 100644 index 0000000..46a1930 --- /dev/null +++ b/electron/services/aiAnalysisSkills/tool_session_candidates.md @@ -0,0 +1,9 @@ +工具:ai_query_session_candidates + +何时用: +- 用户未明确具体会话,但给了关键词/关系词(如“老婆”“买车”)。 + +调用建议: +- 首次调用 detailLevel=minimal。 +- 默认 limit 8~12,避免拉太多候选。 +- 当候选歧义较大时再升级 detailLevel=standard/full。 diff --git a/electron/services/aiAnalysisSkills/tool_session_glimpse.md b/electron/services/aiAnalysisSkills/tool_session_glimpse.md new file mode 100644 index 0000000..3445b93 --- /dev/null +++ b/electron/services/aiAnalysisSkills/tool_session_glimpse.md @@ -0,0 +1,9 @@ +工具:ai_query_session_glimpse + +何时用: +- 已确定候选会话,需要“先看一点”理解上下文。 + +Agent策略: +- 每个候选会话先抽样 6~20 条,按时间顺序阅读。 +- 不要只读一个会话就结束;优先覆盖多会话后再总结。 +- 如果出现明显分歧场景(工作/家庭/感情)需主动向用户确认分析目标。 diff --git a/electron/services/aiAnalysisSkills/tool_source_refs.md b/electron/services/aiAnalysisSkills/tool_source_refs.md new file mode 100644 index 0000000..ea0a0ee --- /dev/null +++ b/electron/services/aiAnalysisSkills/tool_source_refs.md @@ -0,0 +1,8 @@ +工具:ai_query_source_refs + +何时用: +- 输出总结或分析后,用于来源说明与可解释卡片。 + +调用建议: +- 默认 minimal 即可,输出 range/session_count/message_count/db_refs。 +- 只有排错或审计时再请求 full。 diff --git a/electron/services/aiAnalysisSkills/tool_time_window_activity.md b/electron/services/aiAnalysisSkills/tool_time_window_activity.md new file mode 100644 index 0000000..d6ed088 --- /dev/null +++ b/electron/services/aiAnalysisSkills/tool_time_window_activity.md @@ -0,0 +1,9 @@ +工具:ai_query_time_window_activity + +何时用: +- 用户提到“今天凌晨/昨晚/某个时间段”的聊天分析。 + +Agent策略: +- 第一步必须先扫时间窗活跃会话,不要直接下结论。 +- 拿到活跃会话后,再调用 ai_query_session_glimpse 逐个会话抽样阅读。 +- 若用户目标仍不清晰,先追问 1 个关键澄清问题再继续。 diff --git a/electron/services/aiAnalysisSkills/tool_timeline.md b/electron/services/aiAnalysisSkills/tool_timeline.md new file mode 100644 index 0000000..1fd0c14 --- /dev/null +++ b/electron/services/aiAnalysisSkills/tool_timeline.md @@ -0,0 +1,9 @@ +工具:ai_query_timeline + +何时用: +- 回忆事件经过、梳理时间线、提取关键节点。 + +调用建议: +- 默认 detailLevel=minimal。 +- 先小批次 limit(40~120),不够再分页 offset。 +- 需要引用原文证据时,可搭配 ai_fetch_message_briefs。 diff --git a/electron/services/aiAnalysisSkills/tool_top_contacts.md b/electron/services/aiAnalysisSkills/tool_top_contacts.md new file mode 100644 index 0000000..21086f8 --- /dev/null +++ b/electron/services/aiAnalysisSkills/tool_top_contacts.md @@ -0,0 +1,9 @@ +工具:ai_query_top_contacts + +何时用: +- 用户问“谁联系最密切”“谁聊得最多”“最常联系的是谁”。 + +调用建议: +- 该问题优先调用本工具,而不是先跑时间轴。 +- 默认 detailLevel=minimal,limit 5~10。 +- 需要区分群聊时再设置 includeGroups=true。 diff --git a/electron/services/aiAnalysisSkills/tool_topic_stats.md b/electron/services/aiAnalysisSkills/tool_topic_stats.md new file mode 100644 index 0000000..1b3d3dc --- /dev/null +++ b/electron/services/aiAnalysisSkills/tool_topic_stats.md @@ -0,0 +1,8 @@ +工具:ai_query_topic_stats + +何时用: +- 用户问“多少、占比、趋势、对比”。 + +调用建议: +- 仅在统计问题时调用,避免无谓聚合。 +- 默认 detailLevel=minimal;有统计追问再升到 standard/full。 diff --git a/electron/services/aiAnalysisSkills/tool_voice_list.md b/electron/services/aiAnalysisSkills/tool_voice_list.md new file mode 100644 index 0000000..9c8e6a1 --- /dev/null +++ b/electron/services/aiAnalysisSkills/tool_voice_list.md @@ -0,0 +1,8 @@ +工具:ai_list_voice_messages + +何时用: +- 用户提到“语音里说了什么”。 + +调用建议: +- 第一步先拿 ID 清单,默认 detailLevel=minimal(仅 IDs)。 +- 如用户需要挑选依据,再用 standard/full 查看更多元数据。 diff --git a/electron/services/aiAnalysisSkills/tool_voice_transcribe.md b/electron/services/aiAnalysisSkills/tool_voice_transcribe.md new file mode 100644 index 0000000..3f29f08 --- /dev/null +++ b/electron/services/aiAnalysisSkills/tool_voice_transcribe.md @@ -0,0 +1,9 @@ +工具:ai_transcribe_voice_messages + +何时用: +- 已明确拿到语音 ID,且用户需要读取语音内容。 + +调用建议: +- 必须显式传 ids 或 items。 +- 单次控制在小批次(建议 <=5),失败可重试。 +- 转写成功后再参与总结;失败项单独标注,不混入结论。 diff --git a/electron/services/aiAssistantService.ts b/electron/services/aiAssistantService.ts new file mode 100644 index 0000000..f0075f0 --- /dev/null +++ b/electron/services/aiAssistantService.ts @@ -0,0 +1,444 @@ +import { randomUUID } from 'crypto' +import { existsSync } from 'fs' +import { mkdir, readdir, readFile, rm, writeFile } from 'fs/promises' +import { join } from 'path' +import { ConfigService } from './config' + +export type AssistantChatType = 'group' | 'private' +export type AssistantToolCategory = 'core' | 'analysis' + +export interface AssistantSummary { + id: string + name: string + systemPrompt: string + presetQuestions: string[] + allowedBuiltinTools?: string[] + builtinId?: string + applicableChatTypes?: AssistantChatType[] + supportedLocales?: string[] +} + +export interface AssistantConfigFull extends AssistantSummary {} + +export interface BuiltinAssistantInfo { + id: string + name: string + systemPrompt: string + applicableChatTypes?: AssistantChatType[] + supportedLocales?: string[] + imported: boolean +} + +const GENERAL_CN_MD = `--- +id: general_cn +name: 通用分析助手 +supportedLocales: + - zh +presetQuestions: + - 最近都在聊什么? + - 谁是最活跃的人? + - 帮我总结一下最近一周的重要聊天 + - 帮我找一下关于“旅游”的讨论 +allowedBuiltinTools: + - ai_query_time_window_activity + - ai_query_session_candidates + - ai_query_session_glimpse + - ai_query_timeline + - ai_fetch_message_briefs + - ai_list_voice_messages + - ai_transcribe_voice_messages + - ai_query_topic_stats + - ai_query_source_refs + - ai_query_top_contacts +--- + +你是 WeFlow 的全局聊天分析助手。请使用工具获取证据,给出简洁、准确、可执行的结论。 + +输出要求: +1. 先结论,再证据。 +2. 若证据不足,明确说明不足并建议下一步。 +3. 涉及语音内容时,必须先列语音 ID,再按 ID 转写。 +4. 默认中文输出,除非用户明确指定其他语言。` + +const GENERAL_EN_MD = `--- +id: general_en +name: General Analysis Assistant +supportedLocales: + - en +presetQuestions: + - What have people been discussing recently? + - Who are the most active contacts? + - Summarize my key chat topics this week +allowedBuiltinTools: + - ai_query_time_window_activity + - ai_query_session_candidates + - ai_query_session_glimpse + - ai_query_timeline + - ai_fetch_message_briefs + - ai_list_voice_messages + - ai_transcribe_voice_messages + - ai_query_topic_stats + - ai_query_source_refs + - ai_query_top_contacts +--- + +You are WeFlow's global chat analysis assistant. +Always ground your answers in tool evidence, stay concise, and clearly call out uncertainty when data is insufficient.` + +const GENERAL_JA_MD = `--- +id: general_ja +name: 汎用分析アシスタント +supportedLocales: + - ja +presetQuestions: + - 最近どんな話題が多い? + - 一番アクティブな相手は誰? + - 今週の重要な会話を要約して +allowedBuiltinTools: + - ai_query_time_window_activity + - ai_query_session_candidates + - ai_query_session_glimpse + - ai_query_timeline + - ai_fetch_message_briefs + - ai_list_voice_messages + - ai_transcribe_voice_messages + - ai_query_topic_stats + - ai_query_source_refs + - ai_query_top_contacts +--- + +あなたは WeFlow のグローバルチャット分析アシスタントです。 +ツールから得た根拠に基づき、簡潔かつ正確に回答してください。` + +const BUILTIN_ASSISTANTS = [ + { id: 'general_cn', raw: GENERAL_CN_MD }, + { id: 'general_en', raw: GENERAL_EN_MD }, + { id: 'general_ja', raw: GENERAL_JA_MD } +] as const + +function normalizeText(value: unknown, fallback = ''): string { + const text = String(value ?? '').trim() + return text || fallback +} + +function parseInlineList(text: string): string[] { + const raw = normalizeText(text) + if (!raw) return [] + return raw + .split(',') + .map((item) => item.trim()) + .filter(Boolean) +} + +function splitFrontmatter(raw: string): { frontmatter: string; body: string } { + const normalized = String(raw || '') + if (!normalized.startsWith('---')) { + return { frontmatter: '', body: normalized.trim() } + } + const end = normalized.indexOf('\n---', 3) + if (end < 0) return { frontmatter: '', body: normalized.trim() } + return { + frontmatter: normalized.slice(3, end).trim(), + body: normalized.slice(end + 4).trim() + } +} + +function parseAssistantMarkdown(raw: string): AssistantConfigFull { + const { frontmatter, body } = splitFrontmatter(raw) + const lines = frontmatter ? frontmatter.split('\n') : [] + const data: Record = {} + let currentArrayKey = '' + for (const line of lines) { + const trimmed = line.trim() + if (!trimmed) continue + const kv = trimmed.match(/^([A-Za-z0-9_]+)\s*:\s*(.*)$/) + if (kv) { + const key = kv[1] + const value = kv[2] + if (!value) { + data[key] = [] + currentArrayKey = key + } else { + data[key] = value + currentArrayKey = '' + } + continue + } + const arr = trimmed.match(/^- (.+)$/) + if (arr && currentArrayKey) { + const next = Array.isArray(data[currentArrayKey]) ? data[currentArrayKey] as string[] : [] + next.push(arr[1].trim()) + data[currentArrayKey] = next + } + } + + const id = normalizeText(data.id) + const name = normalizeText(data.name, id || 'assistant') + const applicableChatTypes = Array.isArray(data.applicableChatTypes) + ? (data.applicableChatTypes as string[]).filter((item): item is AssistantChatType => item === 'group' || item === 'private') + : parseInlineList(String(data.applicableChatTypes || '')).filter((item): item is AssistantChatType => item === 'group' || item === 'private') + const supportedLocales = Array.isArray(data.supportedLocales) + ? (data.supportedLocales as string[]).map((item) => item.trim()).filter(Boolean) + : parseInlineList(String(data.supportedLocales || '')) + const presetQuestions = Array.isArray(data.presetQuestions) + ? (data.presetQuestions as string[]).map((item) => item.trim()).filter(Boolean) + : parseInlineList(String(data.presetQuestions || '')) + const allowedBuiltinTools = Array.isArray(data.allowedBuiltinTools) + ? (data.allowedBuiltinTools as string[]).map((item) => item.trim()).filter(Boolean) + : parseInlineList(String(data.allowedBuiltinTools || '')) + const builtinId = normalizeText(data.builtinId) + + return { + id, + name, + systemPrompt: body, + presetQuestions, + allowedBuiltinTools, + builtinId: builtinId || undefined, + applicableChatTypes, + supportedLocales + } +} + +function toMarkdown(config: AssistantConfigFull): string { + const lines = [ + '---', + `id: ${config.id}`, + `name: ${config.name}` + ] + if (config.builtinId) lines.push(`builtinId: ${config.builtinId}`) + if (config.supportedLocales && config.supportedLocales.length > 0) { + lines.push('supportedLocales:') + config.supportedLocales.forEach((item) => lines.push(` - ${item}`)) + } + if (config.applicableChatTypes && config.applicableChatTypes.length > 0) { + lines.push('applicableChatTypes:') + config.applicableChatTypes.forEach((item) => lines.push(` - ${item}`)) + } + if (config.presetQuestions && config.presetQuestions.length > 0) { + lines.push('presetQuestions:') + config.presetQuestions.forEach((item) => lines.push(` - ${item}`)) + } + if (config.allowedBuiltinTools && config.allowedBuiltinTools.length > 0) { + lines.push('allowedBuiltinTools:') + config.allowedBuiltinTools.forEach((item) => lines.push(` - ${item}`)) + } + lines.push('---') + lines.push('') + lines.push(config.systemPrompt || '') + return lines.join('\n') +} + +function defaultBuiltinToolCatalog(): Array<{ name: string; category: AssistantToolCategory }> { + return [ + { name: 'ai_query_time_window_activity', category: 'core' }, + { name: 'ai_query_session_candidates', category: 'core' }, + { name: 'ai_query_session_glimpse', category: 'core' }, + { name: 'ai_query_timeline', category: 'core' }, + { name: 'ai_fetch_message_briefs', category: 'core' }, + { name: 'ai_list_voice_messages', category: 'core' }, + { name: 'ai_transcribe_voice_messages', category: 'core' }, + { name: 'ai_query_topic_stats', category: 'analysis' }, + { name: 'ai_query_source_refs', category: 'analysis' }, + { name: 'ai_query_top_contacts', category: 'analysis' }, + { name: 'activate_skill', category: 'analysis' } + ] +} + +class AiAssistantService { + private readonly config = ConfigService.getInstance() + private initialized = false + private readonly cache = new Map() + + private getRootDirCandidates(): string[] { + const dbPath = normalizeText(this.config.get('dbPath')) + const wxid = normalizeText(this.config.get('myWxid')) + const roots: string[] = [] + if (dbPath && wxid) { + roots.push(join(dbPath, wxid, 'db_storage', 'wf_ai_v2')) + roots.push(join(dbPath, wxid, 'db_storage', 'wf_ai')) + } + roots.push(join(process.cwd(), 'data', 'wf_ai_v2')) + return roots + } + + private async getRootDir(): Promise { + const roots = this.getRootDirCandidates() + const dir = roots[0] + await mkdir(dir, { recursive: true }) + return dir + } + + private async getAssistantsDir(): Promise { + const root = await this.getRootDir() + const dir = join(root, 'assistants') + await mkdir(dir, { recursive: true }) + return dir + } + + private async ensureInitialized(): Promise { + if (this.initialized) return + const dir = await this.getAssistantsDir() + + for (const builtin of BUILTIN_ASSISTANTS) { + const filePath = join(dir, `${builtin.id}.md`) + if (!existsSync(filePath)) { + const parsed = parseAssistantMarkdown(builtin.raw) + const config: AssistantConfigFull = { + ...parsed, + builtinId: parsed.id + } + await writeFile(filePath, toMarkdown(config), 'utf8') + } + } + + this.cache.clear() + const files = await readdir(dir) + for (const fileName of files) { + if (!fileName.endsWith('.md')) continue + const filePath = join(dir, fileName) + try { + const raw = await readFile(filePath, 'utf8') + const parsed = parseAssistantMarkdown(raw) + if (!parsed.id) continue + this.cache.set(parsed.id, parsed) + } catch { + // ignore broken file + } + } + this.initialized = true + } + + async getAll(): Promise { + await this.ensureInitialized() + return Array.from(this.cache.values()) + .sort((a, b) => a.name.localeCompare(b.name, 'zh-Hans-CN')) + .map((assistant) => ({ ...assistant })) + } + + async getConfig(id: string): Promise { + await this.ensureInitialized() + const key = normalizeText(id) + const config = this.cache.get(key) + return config ? { ...config } : null + } + + async create( + payload: Omit & { id?: string } + ): Promise<{ success: boolean; id?: string; error?: string }> { + await this.ensureInitialized() + const id = normalizeText(payload.id, `custom_${randomUUID().replace(/-/g, '').slice(0, 12)}`) + if (this.cache.has(id)) return { success: false, error: '助手 ID 已存在' } + const config: AssistantConfigFull = { + id, + name: normalizeText(payload.name, '新助手'), + systemPrompt: normalizeText(payload.systemPrompt), + presetQuestions: Array.isArray(payload.presetQuestions) ? payload.presetQuestions.map((item) => normalizeText(item)).filter(Boolean) : [], + allowedBuiltinTools: Array.isArray(payload.allowedBuiltinTools) ? payload.allowedBuiltinTools.map((item) => normalizeText(item)).filter(Boolean) : [], + builtinId: normalizeText(payload.builtinId) || undefined, + applicableChatTypes: Array.isArray(payload.applicableChatTypes) ? payload.applicableChatTypes : [], + supportedLocales: Array.isArray(payload.supportedLocales) ? payload.supportedLocales.map((item) => normalizeText(item)).filter(Boolean) : [] + } + const dir = await this.getAssistantsDir() + await writeFile(join(dir, `${id}.md`), toMarkdown(config), 'utf8') + this.cache.set(id, config) + return { success: true, id } + } + + async update( + id: string, + updates: Partial + ): Promise<{ success: boolean; error?: string }> { + await this.ensureInitialized() + const key = normalizeText(id) + const existing = this.cache.get(key) + if (!existing) return { success: false, error: '助手不存在' } + const next: AssistantConfigFull = { + ...existing, + ...updates, + id: key, + name: normalizeText(updates.name, existing.name), + systemPrompt: updates.systemPrompt == null ? existing.systemPrompt : normalizeText(updates.systemPrompt), + presetQuestions: Array.isArray(updates.presetQuestions) ? updates.presetQuestions.map((item) => normalizeText(item)).filter(Boolean) : existing.presetQuestions, + allowedBuiltinTools: Array.isArray(updates.allowedBuiltinTools) ? updates.allowedBuiltinTools.map((item) => normalizeText(item)).filter(Boolean) : existing.allowedBuiltinTools, + applicableChatTypes: Array.isArray(updates.applicableChatTypes) ? updates.applicableChatTypes : existing.applicableChatTypes, + supportedLocales: Array.isArray(updates.supportedLocales) ? updates.supportedLocales.map((item) => normalizeText(item)).filter(Boolean) : existing.supportedLocales + } + const dir = await this.getAssistantsDir() + await writeFile(join(dir, `${key}.md`), toMarkdown(next), 'utf8') + this.cache.set(key, next) + return { success: true } + } + + async delete(id: string): Promise<{ success: boolean; error?: string }> { + await this.ensureInitialized() + const key = normalizeText(id) + if (key === 'general_cn' || key === 'general_en' || key === 'general_ja') { + return { success: false, error: '默认助手不可删除' } + } + const dir = await this.getAssistantsDir() + const filePath = join(dir, `${key}.md`) + if (existsSync(filePath)) { + await rm(filePath, { force: true }) + } + this.cache.delete(key) + return { success: true } + } + + async reset(id: string): Promise<{ success: boolean; error?: string }> { + await this.ensureInitialized() + const key = normalizeText(id) + const existing = this.cache.get(key) + if (!existing?.builtinId) { + return { success: false, error: '该助手不支持重置' } + } + const builtin = BUILTIN_ASSISTANTS.find((item) => item.id === existing.builtinId) + if (!builtin) return { success: false, error: '内置模板不存在' } + const parsed = parseAssistantMarkdown(builtin.raw) + const config: AssistantConfigFull = { + ...parsed, + id: key, + builtinId: existing.builtinId + } + const dir = await this.getAssistantsDir() + await writeFile(join(dir, `${key}.md`), toMarkdown(config), 'utf8') + this.cache.set(key, config) + return { success: true } + } + + async getBuiltinCatalog(): Promise { + await this.ensureInitialized() + return BUILTIN_ASSISTANTS.map((builtin) => { + const parsed = parseAssistantMarkdown(builtin.raw) + const imported = Array.from(this.cache.values()).some((config) => config.builtinId === builtin.id || config.id === builtin.id) + return { + id: parsed.id, + name: parsed.name, + systemPrompt: parsed.systemPrompt, + applicableChatTypes: parsed.applicableChatTypes, + supportedLocales: parsed.supportedLocales, + imported + } + }) + } + + async getBuiltinToolCatalog(): Promise> { + return defaultBuiltinToolCatalog() + } + + async importFromMd(rawMd: string): Promise<{ success: boolean; id?: string; error?: string }> { + try { + const parsed = parseAssistantMarkdown(rawMd) + if (!parsed.id) return { success: false, error: '缺少 id' } + if (this.cache.has(parsed.id)) return { success: false, error: '助手 ID 已存在' } + const dir = await this.getAssistantsDir() + await writeFile(join(dir, `${parsed.id}.md`), toMarkdown(parsed), 'utf8') + this.cache.set(parsed.id, parsed) + return { success: true, id: parsed.id } + } catch (error) { + return { success: false, error: String((error as Error)?.message || error) } + } + } +} + +export const aiAssistantService = new AiAssistantService() diff --git a/electron/services/aiSkillService.ts b/electron/services/aiSkillService.ts new file mode 100644 index 0000000..884438e --- /dev/null +++ b/electron/services/aiSkillService.ts @@ -0,0 +1,395 @@ +import { existsSync } from 'fs' +import { mkdir, readdir, readFile, rm, writeFile } from 'fs/promises' +import { join } from 'path' +import { ConfigService } from './config' + +export type SkillChatScope = 'all' | 'group' | 'private' + +export interface SkillSummary { + id: string + name: string + description: string + tags: string[] + chatScope: SkillChatScope + tools: string[] + builtinId?: string +} + +export interface SkillDef extends SkillSummary { + prompt: string +} + +export interface BuiltinSkillInfo extends SkillSummary { + imported: boolean +} + +const SKILL_DEEP_TIMELINE_MD = `--- +id: deep_timeline +name: 深度时间线追踪 +description: 适合还原某段时间内发生了什么,强调事件顺序与证据引用。 +tags: + - timeline + - evidence +chatScope: all +tools: + - ai_query_time_window_activity + - ai_query_session_candidates + - ai_query_session_glimpse + - ai_query_timeline + - ai_fetch_message_briefs + - ai_query_source_refs +--- +你是“深度时间线追踪”技能。 +执行步骤: +1. 先按时间窗扫描活跃会话,必要时补关键词筛选候选会话。 +2. 对候选会话先抽样,再拉取时间轴。 +3. 对关键节点用 ai_fetch_message_briefs 校对原文。 +4. 最后输出“结论 + 关键节点 + 来源范围”。` + +const SKILL_CONTACT_FOCUS_MD = `--- +id: contact_focus +name: 联系人关系聚焦 +description: 用于“我和谁聊得最多/关系变化”这类问题,强调联系人维度。 +tags: + - contacts + - relation +chatScope: private +tools: + - ai_query_top_contacts + - ai_query_topic_stats + - ai_query_session_glimpse + - ai_query_timeline + - ai_query_source_refs +--- +你是“联系人关系聚焦”技能。 +执行步骤: +1. 优先调用 ai_query_top_contacts 得到候选联系人排名。 +2. 针对 Top 联系人读取抽样消息并补充时间轴。 +3. 如果用户问题涉及“变化趋势”,补 ai_query_topic_stats。 +4. 输出时必须给出对比口径(时间窗、样本范围、消息数量)。` + +const SKILL_VOICE_AUDIT_MD = `--- +id: voice_audit +name: 语音证据审计 +description: 对语音消息进行“先列ID再转写再总结”的合规分析。 +tags: + - voice + - audit +chatScope: all +tools: + - ai_list_voice_messages + - ai_transcribe_voice_messages + - ai_query_source_refs +--- +你是“语音证据审计”技能。 +硬规则: +1. 必须先调用 ai_list_voice_messages 获取语音 ID 清单。 +2. 仅能转写用户明确指定的 ID,单轮最多 5 条。 +3. 未转写成功的语音不得作为事实。 +4. 输出包含“已转写 / 失败 / 待确认”三段。` + +const BUILTIN_SKILLS = [ + { id: 'deep_timeline', raw: SKILL_DEEP_TIMELINE_MD }, + { id: 'contact_focus', raw: SKILL_CONTACT_FOCUS_MD }, + { id: 'voice_audit', raw: SKILL_VOICE_AUDIT_MD } +] as const + +function normalizeText(value: unknown, fallback = ''): string { + const text = String(value ?? '').trim() + return text || fallback +} + +function parseInlineList(text: string): string[] { + const raw = normalizeText(text) + if (!raw) return [] + return raw + .split(',') + .map((item) => item.trim()) + .filter(Boolean) +} + +function splitFrontmatter(raw: string): { frontmatter: string; body: string } { + const normalized = String(raw || '') + if (!normalized.startsWith('---')) { + return { frontmatter: '', body: normalized.trim() } + } + const end = normalized.indexOf('\n---', 3) + if (end < 0) return { frontmatter: '', body: normalized.trim() } + return { + frontmatter: normalized.slice(3, end).trim(), + body: normalized.slice(end + 4).trim() + } +} + +function normalizeChatScope(value: unknown): SkillChatScope { + const scope = normalizeText(value).toLowerCase() + if (scope === 'group' || scope === 'private') return scope + return 'all' +} + +function parseSkillMarkdown(raw: string): SkillDef { + const { frontmatter, body } = splitFrontmatter(raw) + const lines = frontmatter ? frontmatter.split('\n') : [] + const data: Record = {} + let currentArrayKey = '' + for (const line of lines) { + const trimmed = line.trim() + if (!trimmed) continue + const kv = trimmed.match(/^([A-Za-z0-9_]+)\s*:\s*(.*)$/) + if (kv) { + const key = kv[1] + const value = kv[2] + if (!value) { + data[key] = [] + currentArrayKey = key + } else { + data[key] = value + currentArrayKey = '' + } + continue + } + const arr = trimmed.match(/^- (.+)$/) + if (arr && currentArrayKey) { + const next = Array.isArray(data[currentArrayKey]) ? data[currentArrayKey] as string[] : [] + next.push(arr[1].trim()) + data[currentArrayKey] = next + } + } + + const id = normalizeText(data.id) + const name = normalizeText(data.name, id || 'skill') + const description = normalizeText(data.description) + const tags = Array.isArray(data.tags) + ? (data.tags as string[]).map((item) => item.trim()).filter(Boolean) + : parseInlineList(String(data.tags || '')) + const tools = Array.isArray(data.tools) + ? (data.tools as string[]).map((item) => item.trim()).filter(Boolean) + : parseInlineList(String(data.tools || '')) + const chatScope = normalizeChatScope(data.chatScope) + const builtinId = normalizeText(data.builtinId) + + return { + id, + name, + description, + tags, + chatScope, + tools, + prompt: body, + builtinId: builtinId || undefined + } +} + +function serializeSkillMarkdown(skill: SkillDef): string { + const lines = [ + '---', + `id: ${skill.id}`, + `name: ${skill.name}`, + `description: ${skill.description}`, + `chatScope: ${skill.chatScope}` + ] + if (skill.builtinId) lines.push(`builtinId: ${skill.builtinId}`) + if (skill.tags.length > 0) { + lines.push('tags:') + skill.tags.forEach((tag) => lines.push(` - ${tag}`)) + } + if (skill.tools.length > 0) { + lines.push('tools:') + skill.tools.forEach((tool) => lines.push(` - ${tool}`)) + } + lines.push('---') + lines.push('') + lines.push(skill.prompt || '') + return lines.join('\n') +} + +class AiSkillService { + private readonly config = ConfigService.getInstance() + private initialized = false + private readonly cache = new Map() + + private getRootDirCandidates(): string[] { + const dbPath = normalizeText(this.config.get('dbPath')) + const wxid = normalizeText(this.config.get('myWxid')) + const roots: string[] = [] + if (dbPath && wxid) { + roots.push(join(dbPath, wxid, 'db_storage', 'wf_ai_v2')) + roots.push(join(dbPath, wxid, 'db_storage', 'wf_ai')) + } + roots.push(join(process.cwd(), 'data', 'wf_ai_v2')) + return roots + } + + private async getRootDir(): Promise { + const roots = this.getRootDirCandidates() + const dir = roots[0] + await mkdir(dir, { recursive: true }) + return dir + } + + private async getSkillsDir(): Promise { + const root = await this.getRootDir() + const dir = join(root, 'skills') + await mkdir(dir, { recursive: true }) + return dir + } + + private async ensureInitialized(): Promise { + if (this.initialized) return + const dir = await this.getSkillsDir() + + for (const builtin of BUILTIN_SKILLS) { + const filePath = join(dir, `${builtin.id}.md`) + if (!existsSync(filePath)) { + const parsed = parseSkillMarkdown(builtin.raw) + const config: SkillDef = { + ...parsed, + builtinId: parsed.id + } + await writeFile(filePath, serializeSkillMarkdown(config), 'utf8') + continue + } + try { + const raw = await readFile(filePath, 'utf8') + const parsed = parseSkillMarkdown(raw) + if (!parsed.builtinId) { + parsed.builtinId = builtin.id + await writeFile(filePath, serializeSkillMarkdown(parsed), 'utf8') + } + } catch { + // ignore broken file + } + } + + this.cache.clear() + const files = await readdir(dir) + for (const fileName of files) { + if (!fileName.endsWith('.md')) continue + const filePath = join(dir, fileName) + try { + const raw = await readFile(filePath, 'utf8') + const parsed = parseSkillMarkdown(raw) + if (!parsed.id) continue + this.cache.set(parsed.id, parsed) + } catch { + // ignore broken file + } + } + this.initialized = true + } + + async getAll(): Promise { + await this.ensureInitialized() + return Array.from(this.cache.values()) + .sort((a, b) => a.name.localeCompare(b.name, 'zh-Hans-CN')) + .map((skill) => ({ + id: skill.id, + name: skill.name, + description: skill.description, + tags: [...skill.tags], + chatScope: skill.chatScope, + tools: [...skill.tools], + builtinId: skill.builtinId + })) + } + + async getConfig(id: string): Promise { + await this.ensureInitialized() + const key = normalizeText(id) + const value = this.cache.get(key) + return value ? { + ...value, + tags: [...value.tags], + tools: [...value.tools] + } : null + } + + async create(rawMd: string): Promise<{ success: boolean; id?: string; error?: string }> { + await this.ensureInitialized() + try { + const parsed = parseSkillMarkdown(rawMd) + if (!parsed.id) return { success: false, error: '缺少 id' } + if (this.cache.has(parsed.id)) return { success: false, error: '技能 ID 已存在' } + const dir = await this.getSkillsDir() + await writeFile(join(dir, `${parsed.id}.md`), serializeSkillMarkdown(parsed), 'utf8') + this.cache.set(parsed.id, parsed) + return { success: true, id: parsed.id } + } catch (error) { + return { success: false, error: String((error as Error)?.message || error) } + } + } + + async update(id: string, rawMd: string): Promise<{ success: boolean; error?: string }> { + await this.ensureInitialized() + const key = normalizeText(id) + const existing = this.cache.get(key) + if (!existing) return { success: false, error: '技能不存在' } + try { + const parsed = parseSkillMarkdown(rawMd) + parsed.id = key + if (existing.builtinId && !parsed.builtinId) parsed.builtinId = existing.builtinId + const dir = await this.getSkillsDir() + await writeFile(join(dir, `${key}.md`), serializeSkillMarkdown(parsed), 'utf8') + this.cache.set(key, parsed) + return { success: true } + } catch (error) { + return { success: false, error: String((error as Error)?.message || error) } + } + } + + async delete(id: string): Promise<{ success: boolean; error?: string }> { + await this.ensureInitialized() + const key = normalizeText(id) + const dir = await this.getSkillsDir() + const filePath = join(dir, `${key}.md`) + if (existsSync(filePath)) { + await rm(filePath, { force: true }) + } + this.cache.delete(key) + return { success: true } + } + + async getBuiltinCatalog(): Promise { + await this.ensureInitialized() + return BUILTIN_SKILLS.map((builtin) => { + const parsed = parseSkillMarkdown(builtin.raw) + const imported = Array.from(this.cache.values()).some((skill) => skill.builtinId === parsed.id || skill.id === parsed.id) + return { + id: parsed.id, + name: parsed.name, + description: parsed.description, + tags: parsed.tags, + chatScope: parsed.chatScope, + tools: parsed.tools, + imported + } + }) + } + + async importFromMd(rawMd: string): Promise<{ success: boolean; id?: string; error?: string }> { + return this.create(rawMd) + } + + async getAutoSkillMenu( + chatScope: SkillChatScope, + allowedTools?: string[] + ): Promise { + await this.ensureInitialized() + const compatible = Array.from(this.cache.values()).filter((skill) => { + if (skill.chatScope !== 'all' && skill.chatScope !== chatScope) return false + if (!allowedTools || allowedTools.length === 0) return true + return skill.tools.every((tool) => allowedTools.includes(tool)) + }) + if (compatible.length === 0) return null + const lines = compatible.slice(0, 15).map((skill) => `- ${skill.id}: ${skill.name} - ${skill.description}`) + return [ + '你可以按需调用工具 activate_skill 以激活对应技能。', + '当用户问题明显匹配某个技能时,先调用 activate_skill 获取执行手册。', + '若问题简单或不匹配技能,可直接回答。', + '', + ...lines + ].join('\n') + } +} + +export const aiSkillService = new AiSkillService() diff --git a/electron/services/config.ts b/electron/services/config.ts index fb05832..6177dcc 100644 --- a/electron/services/config.ts +++ b/electron/services/config.ts @@ -74,6 +74,16 @@ interface ConfigSchema { aiModelApiBaseUrl: string aiModelApiKey: string aiModelApiModel: string + aiAgentMaxMessagesPerRequest: number + aiAgentMaxHistoryRounds: number + aiAgentEnableAutoSkill: boolean + aiAgentSearchContextBefore: number + aiAgentSearchContextAfter: number + aiAgentPreprocessClean: boolean + aiAgentPreprocessMerge: boolean + aiAgentPreprocessDenoise: boolean + aiAgentPreprocessDesensitize: boolean + aiAgentPreprocessAnonymize: boolean aiInsightEnabled: boolean aiInsightApiBaseUrl: string aiInsightApiKey: string @@ -184,6 +194,16 @@ export class ConfigService { aiModelApiBaseUrl: '', aiModelApiKey: '', aiModelApiModel: 'gpt-4o-mini', + aiAgentMaxMessagesPerRequest: 120, + aiAgentMaxHistoryRounds: 12, + aiAgentEnableAutoSkill: true, + aiAgentSearchContextBefore: 3, + aiAgentSearchContextAfter: 3, + aiAgentPreprocessClean: true, + aiAgentPreprocessMerge: true, + aiAgentPreprocessDenoise: true, + aiAgentPreprocessDesensitize: false, + aiAgentPreprocessAnonymize: false, aiInsightEnabled: false, aiInsightApiBaseUrl: '', aiInsightApiKey: '', diff --git a/electron/services/wcdbCore.ts b/electron/services/wcdbCore.ts index 116ba45..de46281 100644 --- a/electron/services/wcdbCore.ts +++ b/electron/services/wcdbCore.ts @@ -85,6 +85,10 @@ export class WcdbCore { private wcdbScanMediaStream: any = null private wcdbGetHeadImageBuffers: any = null private wcdbSearchMessages: any = null + private wcdbAiQuerySessionCandidates: any = null + private wcdbAiQueryTimeline: any = null + private wcdbAiQueryTopicStats: any = null + private wcdbAiQuerySourceRefs: any = null private wcdbGetSnsTimeline: any = null private wcdbGetSnsAnnualStats: any = null private wcdbGetSnsUsernames: any = null @@ -1060,6 +1064,26 @@ export class WcdbCore { } catch { this.wcdbSearchMessages = null } + try { + this.wcdbAiQuerySessionCandidates = this.lib.func('int32 wcdb_ai_query_session_candidates(int64 handle, const char* optionsJson, _Out_ void** outJson)') + } catch { + this.wcdbAiQuerySessionCandidates = null + } + try { + this.wcdbAiQueryTimeline = this.lib.func('int32 wcdb_ai_query_timeline(int64 handle, const char* optionsJson, _Out_ void** outJson)') + } catch { + this.wcdbAiQueryTimeline = null + } + try { + this.wcdbAiQueryTopicStats = this.lib.func('int32 wcdb_ai_query_topic_stats(int64 handle, const char* optionsJson, _Out_ void** outJson)') + } catch { + this.wcdbAiQueryTopicStats = null + } + try { + this.wcdbAiQuerySourceRefs = this.lib.func('int32 wcdb_ai_query_source_refs(int64 handle, const char* optionsJson, _Out_ void** outJson)') + } catch { + this.wcdbAiQuerySourceRefs = null + } // wcdb_status wcdb_get_sns_timeline(wcdb_handle handle, int32_t limit, int32_t offset, const char* username, const char* keyword, int32_t start_time, int32_t end_time, char** out_json) try { @@ -3370,6 +3394,204 @@ export class WcdbCore { } } + private normalizeSqlIdentifier(name: string): string { + return `"${String(name || '').replace(/"/g, '""')}"` + } + + private stripSqlComments(sql: string): string { + return String(sql || '') + .replace(/\/\*[\s\S]*?\*\//g, ' ') + .replace(/--[^\n\r]*/g, ' ') + .trim() + } + + private isSqlLabReadOnly(sql: string): boolean { + const normalized = this.stripSqlComments(sql).trim() + if (!normalized) return false + if (normalized.includes('\u0000')) return false + const hasMultipleStatements = /;[\s\r\n]*\S/.test(normalized) + if (hasMultipleStatements) return false + const lower = normalized.toLowerCase() + if (/(insert|update|delete|drop|alter|create|attach|detach|replace|truncate|reindex|vacuum|analyze|begin|commit|rollback|savepoint|release)\b/.test(lower)) { + return false + } + if (/pragma\s+.*(writable_schema|journal_mode|locking_mode|foreign_keys)\s*=/.test(lower)) { + return false + } + return /^(select|with|pragma|explain)\b/.test(lower) + } + + private async sqlLabListTablesForSource( + kind: 'message' | 'contact' | 'biz', + path: string | null, + maxTables: number = 60, + maxColumns: number = 120 + ): Promise> { + const tableRows = await this.execQuery( + kind, + path, + `SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' ORDER BY name LIMIT ${Math.max(1, maxTables)}` + ) + if (!tableRows.success || !Array.isArray(tableRows.rows)) return [] + + const tables: Array<{ name: string; columns: string[] }> = [] + for (const row of tableRows.rows) { + const tableName = String((row as any)?.name || '').trim() + if (!tableName) continue + const pragma = await this.execQuery(kind, path, `PRAGMA table_info(${this.normalizeSqlIdentifier(tableName)})`) + const columns = pragma.success && Array.isArray(pragma.rows) + ? pragma.rows + .map((item: any) => String(item?.name || '').trim()) + .filter(Boolean) + .slice(0, maxColumns) + : [] + tables.push({ name: tableName, columns }) + } + + return tables + } + + async sqlLabGetSchema(payload?: { sessionId?: string }): Promise<{ + success: boolean + schema?: { + generatedAt: number + sources: Array<{ + kind: 'message' | 'contact' | 'biz' + path: string | null + label: string + tables: Array<{ name: string; columns: string[] }> + }> + } + schemaText?: string + error?: string + }> { + if (!this.ensureReady()) { + return { success: false, error: 'WCDB 未连接' } + } + + try { + const sessionId = String(payload?.sessionId || '').trim() + const sources: Array<{ + kind: 'message' | 'contact' | 'biz' + path: string | null + label: string + tables: Array<{ name: string; columns: string[] }> + }> = [] + + if (sessionId) { + const tableStats = await this.getMessageTableStats(sessionId) + const tableEntries = tableStats.success && Array.isArray(tableStats.tables) ? tableStats.tables : [] + const dbPathSet = new Set() + for (const entry of tableEntries) { + const dbPath = String((entry as any)?.db_path || '').trim() + if (!dbPath) continue + dbPathSet.add(dbPath) + } + for (const dbPath of Array.from(dbPathSet).slice(0, 8)) { + sources.push({ + kind: 'message', + path: dbPath, + label: dbPath.split(/[\\/]/).pop() || dbPath, + tables: await this.sqlLabListTablesForSource('message', dbPath) + }) + } + } else { + const messageDbs = await this.listMessageDbs() + const paths = messageDbs.success && Array.isArray(messageDbs.data) ? messageDbs.data : [] + for (const dbPath of paths.slice(0, 8)) { + sources.push({ + kind: 'message', + path: dbPath, + label: dbPath.split(/[\\/]/).pop() || dbPath, + tables: await this.sqlLabListTablesForSource('message', dbPath) + }) + } + } + + sources.push({ + kind: 'contact', + path: null, + label: 'contact', + tables: await this.sqlLabListTablesForSource('contact', null) + }) + sources.push({ + kind: 'biz', + path: null, + label: 'biz', + tables: await this.sqlLabListTablesForSource('biz', null) + }) + + const schemaText = sources + .map((source) => { + const tableLines = source.tables + .map((table) => `- ${table.name} (${table.columns.join(', ')})`) + .join('\n') + return `[${source.kind}] ${source.label}\n${tableLines}` + }) + .join('\n\n') + + return { + success: true, + schema: { + generatedAt: Date.now(), + sources + }, + schemaText + } + } catch (e) { + return { success: false, error: String(e) } + } + } + + async sqlLabExecuteReadonly(payload: { + kind: 'message' | 'contact' | 'biz' + path?: string | null + sql: string + limit?: number + }): Promise<{ + success: boolean + rows?: any[] + columns?: string[] + total?: number + error?: string + }> { + if (!this.ensureReady()) { + return { success: false, error: 'WCDB 未连接' } + } + + try { + const sql = String(payload?.sql || '').trim() + if (!this.isSqlLabReadOnly(sql)) { + return { success: false, error: '仅允许只读 SQL(SELECT/WITH/PRAGMA/EXPLAIN)' } + } + + const kind = payload?.kind === 'contact' || payload?.kind === 'biz' ? payload.kind : 'message' + const path = kind === 'message' + ? (payload?.path == null ? null : String(payload.path)) + : null + const limit = Math.max(1, Math.min(1000, Number(payload?.limit || 200))) + const sqlNoTail = sql.replace(/;+\s*$/, '') + const lower = sqlNoTail.toLowerCase() + const executable = /^(select|with)\b/.test(lower) + ? `SELECT * FROM (${sqlNoTail}) LIMIT ${limit}` + : sqlNoTail + + const result = await this.execQuery(kind, path, executable) + if (!result.success) { + return { success: false, error: result.error || '执行 SQL 失败' } + } + const rows = Array.isArray(result.rows) ? result.rows : [] + return { + success: true, + rows, + columns: rows[0] && typeof rows[0] === 'object' ? Object.keys(rows[0] as Record) : [], + total: rows.length + } + } catch (e) { + return { success: false, error: String(e) } + } + } + async execQuery(kind: string, path: string | null, sql: string, params: any[] = []): Promise<{ success: boolean; rows?: any[]; error?: string }> { if (!this.ensureReady()) { return { success: false, error: 'WCDB 未连接' } @@ -3979,6 +4201,110 @@ export class WcdbCore { } } + async aiQuerySessionCandidates(options: { + keyword: string + limit?: number + beginTimestamp?: number + endTimestamp?: number + }): Promise<{ success: boolean; rows?: any[]; error?: string }> { + if (!this.ensureReady()) return { success: false, error: 'WCDB 未连接' } + if (!this.wcdbAiQuerySessionCandidates) return { success: false, error: '当前数据服务版本不支持 AI 候选会话查询' } + try { + const outPtr = [null as any] + const result = this.wcdbAiQuerySessionCandidates(this.handle, JSON.stringify({ + keyword: options.keyword || '', + limit: options.limit || 12, + begin_timestamp: options.beginTimestamp || 0, + end_timestamp: options.endTimestamp || 0 + }), outPtr) + if (result !== 0 || !outPtr[0]) return { success: false, error: `AI 候选会话查询失败: ${result}` } + const jsonStr = this.decodeJsonPtr(outPtr[0]) + if (!jsonStr) return { success: false, error: '解析 AI 候选会话结果失败' } + const rows = JSON.parse(jsonStr) + return { success: true, rows: Array.isArray(rows) ? rows : [] } + } catch (e) { + return { success: false, error: String(e) } + } + } + + async aiQueryTimeline(options: { + sessionId?: string + keyword: string + limit?: number + offset?: number + beginTimestamp?: number + endTimestamp?: number + }): Promise<{ success: boolean; rows?: any[]; error?: string }> { + if (!this.ensureReady()) return { success: false, error: 'WCDB 未连接' } + if (!this.wcdbAiQueryTimeline) return { success: false, error: '当前数据服务版本不支持 AI 时间轴查询' } + try { + const outPtr = [null as any] + const result = this.wcdbAiQueryTimeline(this.handle, JSON.stringify({ + session_id: options.sessionId || '', + keyword: options.keyword || '', + limit: options.limit || 120, + offset: options.offset || 0, + begin_timestamp: options.beginTimestamp || 0, + end_timestamp: options.endTimestamp || 0 + }), outPtr) + if (result !== 0 || !outPtr[0]) return { success: false, error: `AI 时间轴查询失败: ${result}` } + const jsonStr = this.decodeJsonPtr(outPtr[0]) + if (!jsonStr) return { success: false, error: '解析 AI 时间轴结果失败' } + const rows = this.parseMessageJson(jsonStr) + return { success: true, rows } + } catch (e) { + return { success: false, error: String(e) } + } + } + + async aiQueryTopicStats(options: { + sessionIds: string[] + beginTimestamp?: number + endTimestamp?: number + }): Promise<{ success: boolean; data?: any; error?: string }> { + if (!this.ensureReady()) return { success: false, error: 'WCDB 未连接' } + if (!this.wcdbAiQueryTopicStats) return { success: false, error: '当前数据服务版本不支持 AI 主题统计' } + try { + const outPtr = [null as any] + const result = this.wcdbAiQueryTopicStats(this.handle, JSON.stringify({ + session_ids_json: JSON.stringify(options.sessionIds || []), + begin_timestamp: options.beginTimestamp || 0, + end_timestamp: options.endTimestamp || 0 + }), outPtr) + if (result !== 0 || !outPtr[0]) return { success: false, error: `AI 主题统计失败: ${result}` } + const jsonStr = this.decodeJsonPtr(outPtr[0]) + if (!jsonStr) return { success: false, error: '解析 AI 主题统计失败' } + const data = JSON.parse(jsonStr) + return { success: true, data } + } catch (e) { + return { success: false, error: String(e) } + } + } + + async aiQuerySourceRefs(options: { + sessionIds: string[] + beginTimestamp?: number + endTimestamp?: number + }): Promise<{ success: boolean; data?: any; error?: string }> { + if (!this.ensureReady()) return { success: false, error: 'WCDB 未连接' } + if (!this.wcdbAiQuerySourceRefs) return { success: false, error: '当前数据服务版本不支持 AI 来源引用查询' } + try { + const outPtr = [null as any] + const result = this.wcdbAiQuerySourceRefs(this.handle, JSON.stringify({ + session_ids_json: JSON.stringify(options.sessionIds || []), + begin_timestamp: options.beginTimestamp || 0, + end_timestamp: options.endTimestamp || 0 + }), outPtr) + if (result !== 0 || !outPtr[0]) return { success: false, error: `AI 来源引用查询失败: ${result}` } + const jsonStr = this.decodeJsonPtr(outPtr[0]) + if (!jsonStr) return { success: false, error: '解析 AI 来源引用查询失败' } + const data = JSON.parse(jsonStr) + return { success: true, data } + } catch (e) { + return { success: false, error: String(e) } + } + } + async getSnsTimeline(limit: number, offset: number, usernames?: string[], keyword?: string, startTime?: number, endTime?: number): Promise<{ success: boolean; timeline?: any[]; error?: string }> { if (!this.ensureReady()) return { success: false, error: 'WCDB 未连接' } if (!this.wcdbGetSnsTimeline) return { success: false, error: '当前数据服务版本不支持获取朋友圈' } diff --git a/electron/services/wcdbService.ts b/electron/services/wcdbService.ts index d4c77ef..829bb0e 100644 --- a/electron/services/wcdbService.ts +++ b/electron/services/wcdbService.ts @@ -489,6 +489,44 @@ export class WcdbService { return this.callWorker('closeMessageCursor', { cursor }) } + /** + * SQL Lab: 获取多数据源 Schema 摘要 + */ + async sqlLabGetSchema(payload?: { sessionId?: string }): Promise<{ + success: boolean + schema?: { + generatedAt: number + sources: Array<{ + kind: 'message' | 'contact' | 'biz' + path: string | null + label: string + tables: Array<{ name: string; columns: string[] }> + }> + } + schemaText?: string + error?: string + }> { + return this.callWorker('sqlLabGetSchema', payload || {}) + } + + /** + * SQL Lab: 执行只读 SQL + */ + async sqlLabExecuteReadonly(payload: { + kind: 'message' | 'contact' | 'biz' + path?: string | null + sql: string + limit?: number + }): Promise<{ + success: boolean + rows?: any[] + columns?: string[] + total?: number + error?: string + }> { + return this.callWorker('sqlLabExecuteReadonly', payload) + } + /** * 执行 SQL 查询(仅主进程内部使用:fallback/diagnostic/低频兼容) */ @@ -542,6 +580,42 @@ export class WcdbService { return this.callWorker('searchMessages', { keyword, sessionId, limit, offset, beginTimestamp, endTimestamp }) } + async aiQuerySessionCandidates(options: { + keyword: string + limit?: number + beginTimestamp?: number + endTimestamp?: number + }): Promise<{ success: boolean; rows?: any[]; error?: string }> { + return this.callWorker('aiQuerySessionCandidates', { options }) + } + + async aiQueryTimeline(options: { + sessionId?: string + keyword: string + limit?: number + offset?: number + beginTimestamp?: number + endTimestamp?: number + }): Promise<{ success: boolean; rows?: any[]; error?: string }> { + return this.callWorker('aiQueryTimeline', { options }) + } + + async aiQueryTopicStats(options: { + sessionIds: string[] + beginTimestamp?: number + endTimestamp?: number + }): Promise<{ success: boolean; data?: any; error?: string }> { + return this.callWorker('aiQueryTopicStats', { options }) + } + + async aiQuerySourceRefs(options: { + sessionIds: string[] + beginTimestamp?: number + endTimestamp?: number + }): Promise<{ success: boolean; data?: any; error?: string }> { + return this.callWorker('aiQuerySourceRefs', { options }) + } + /** * 获取语音数据 */ diff --git a/electron/wcdbWorker.ts b/electron/wcdbWorker.ts index 2992d01..64e0e67 100644 --- a/electron/wcdbWorker.ts +++ b/electron/wcdbWorker.ts @@ -173,6 +173,12 @@ if (parentPort) { case 'closeMessageCursor': result = await core.closeMessageCursor(payload.cursor) break + case 'sqlLabGetSchema': + result = await core.sqlLabGetSchema(payload) + break + case 'sqlLabExecuteReadonly': + result = await core.sqlLabExecuteReadonly(payload) + break case 'execQuery': result = await core.execQuery(payload.kind, payload.path, payload.sql, payload.params) break @@ -197,6 +203,18 @@ if (parentPort) { case 'searchMessages': result = await core.searchMessages(payload.keyword, payload.sessionId, payload.limit, payload.offset, payload.beginTimestamp, payload.endTimestamp) break + case 'aiQuerySessionCandidates': + result = await core.aiQuerySessionCandidates(payload.options || {}) + break + case 'aiQueryTimeline': + result = await core.aiQueryTimeline(payload.options || {}) + break + case 'aiQueryTopicStats': + result = await core.aiQueryTopicStats(payload.options || {}) + break + case 'aiQuerySourceRefs': + result = await core.aiQuerySourceRefs(payload.options || {}) + break case 'getVoiceData': result = await core.getVoiceData(payload.sessionId, payload.createTime, payload.candidates, payload.localId, payload.svrId) if (!result.success) { diff --git a/resources/wcdb/win32/x64/wcdb_api.dll b/resources/wcdb/win32/x64/wcdb_api.dll index 05b6d96..15e5351 100644 Binary files a/resources/wcdb/win32/x64/wcdb_api.dll and b/resources/wcdb/win32/x64/wcdb_api.dll differ diff --git a/src/App.tsx b/src/App.tsx index 8cfb8f4..f5b7f36 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -6,6 +6,7 @@ import RouteGuard from './components/RouteGuard' import WelcomePage from './pages/WelcomePage' import HomePage from './pages/HomePage' import ChatPage from './pages/ChatPage' +import AiAnalysisPage from './pages/AiAnalysisPage' import AnalyticsPage from './pages/AnalyticsPage' import AnalyticsWelcomePage from './pages/AnalyticsWelcomePage' import ChatAnalyticsHubPage from './pages/ChatAnalyticsHubPage' @@ -679,6 +680,7 @@ function App() { } /> } /> } /> + } /> } /> } /> diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx index 9a9f0aa..c2aa61c 100644 --- a/src/components/Sidebar.tsx +++ b/src/components/Sidebar.tsx @@ -1,6 +1,6 @@ import { useState, useEffect, useRef } from 'react' import { NavLink, useLocation, useNavigate } from 'react-router-dom' -import { Home, MessageSquare, BarChart3, FileText, Settings, Download, Aperture, UserCircle, Lock, LockOpen, ChevronUp, RefreshCw, FolderClosed, Footprints } from 'lucide-react' +import { Home, MessageSquare, BarChart3, FileText, Settings, Download, Aperture, UserCircle, Lock, LockOpen, ChevronUp, RefreshCw, FolderClosed, Footprints, Sparkles } from 'lucide-react' import { useAppStore } from '../stores/appStore' import { useChatStore } from '../stores/chatStore' import { useAnalyticsStore } from '../stores/analyticsStore' @@ -409,6 +409,16 @@ function Sidebar({ collapsed }: SidebarProps) { 聊天 + {/* AI分析 */} + + + AI分析 + + {/* 朋友圈 */} [], columns: string[]): string { + const esc = (value: unknown) => { + const text = String(value ?? '') + if (/[",\n\r]/.test(text)) return `"${text.replace(/"/g, '""')}"` + return text + } + const header = columns.map((column) => esc(column)).join(',') + const body = rows.map((row) => columns.map((column) => esc(row[column])).join(',')).join('\n') + return `${header}\n${body}` +} + +function AiAnalysisPage() { + const aiApi = window.electronAPI.aiApi + const agentApi = window.electronAPI.agentApi + const assistantApi = window.electronAPI.assistantApi + const skillApi = window.electronAPI.skillApi + const [activeTab, setActiveTab] = useState('chat') + const [scopeMode, setScopeMode] = useState('global') + const [scopeTarget, setScopeTarget] = useState('') + const [conversations, setConversations] = useState([]) + const [currentConversationId, setCurrentConversationId] = useState('') + const [messages, setMessages] = useState([]) + const [assistants, setAssistants] = useState([]) + const [selectedAssistantId, setSelectedAssistantId] = useState('general_cn') + const [skills, setSkills] = useState([]) + const [selectedSkillId, setSelectedSkillId] = useState('') + const [contacts, setContacts] = useState>([]) + const [input, setInput] = useState('') + const [loadingConversations, setLoadingConversations] = useState(false) + const [loadingMessages, setLoadingMessages] = useState(false) + const [errorText, setErrorText] = useState('') + + const [sqlPrompt, setSqlPrompt] = useState('') + const [sqlGenerated, setSqlGenerated] = useState('') + const [sqlGenerating, setSqlGenerating] = useState(false) + const [sqlSchema, setSqlSchema] = useState(null) + const [sqlSchemaText, setSqlSchemaText] = useState('') + const [sqlTargetKey, setSqlTargetKey] = useState('message:') + const [sqlResult, setSqlResult] = useState(null) + const [sqlError, setSqlError] = useState('') + const [sqlHistory, setSqlHistory] = useState([]) + const [sqlSortBy, setSqlSortBy] = useState('') + const [sqlSortOrder, setSqlSortOrder] = useState<'asc' | 'desc'>('asc') + const [sqlPage, setSqlPage] = useState(1) + const [sqlPageSize] = useState(50) + + const [toolCatalog, setToolCatalog] = useState([]) + const [toolName, setToolName] = useState('') + const [toolArgsText, setToolArgsText] = useState('{}') + const [toolRunning, setToolRunning] = useState(false) + const [toolOutput, setToolOutput] = useState('') + + const sqlRunIdRef = useRef('') + const sqlGeneratedRef = useRef('') + const messageEndRef = useRef(null) + + const activeRunId = useAiRuntimeStore((state) => state.activeRunId) + const runtimeState = useAiRuntimeStore((state) => ( + currentConversationId ? state.states[currentConversationId] : undefined + )) + const startRun = useAiRuntimeStore((state) => state.startRun) + const appendChunk = useAiRuntimeStore((state) => state.appendChunk) + const finishRun = useAiRuntimeStore((state) => state.finishRun) + + const selectedAssistant = useMemo( + () => assistants.find((assistant) => assistant.id === selectedAssistantId) || null, + [assistants, selectedAssistantId] + ) + + const slashSuggestions = useMemo(() => { + const text = normalizeText(input) + if (!text.startsWith('/')) return [] + const key = text.slice(1).toLowerCase() + return skills.filter((skill) => !key || skill.id.includes(key) || skill.name.toLowerCase().includes(key)).slice(0, 8) + }, [input, skills]) + + const mentionSuggestions = useMemo(() => { + const match = input.match(/@([^\s@]*)$/) + if (!match) return [] + const keyword = match[1].toLowerCase() + return contacts + .filter((contact) => !keyword || contact.displayName.toLowerCase().includes(keyword) || contact.username.toLowerCase().includes(keyword)) + .slice(0, 8) + }, [contacts, input]) + + const sqlTargetOptions = useMemo(() => { + if (!sqlSchema) return [] + return sqlSchema.sources.map((source) => ({ + key: `${source.kind}:${source.path || ''}`, + label: `[${source.kind}] ${source.label}` + })) + }, [sqlSchema]) + + const sqlSortedRows = useMemo(() => { + const rows = sqlResult?.rows || [] + if (!sqlSortBy) return rows + const copied = [...rows] + copied.sort((a, b) => { + const left = String(a[sqlSortBy] ?? '') + const right = String(b[sqlSortBy] ?? '') + if (left === right) return 0 + return sqlSortOrder === 'asc' ? (left > right ? 1 : -1) : (left > right ? -1 : 1) + }) + return copied + }, [sqlResult, sqlSortBy, sqlSortOrder]) + + const sqlPagedRows = useMemo(() => { + const start = (sqlPage - 1) * sqlPageSize + return sqlSortedRows.slice(start, start + sqlPageSize) + }, [sqlPage, sqlPageSize, sqlSortedRows]) + + const loadConversations = useCallback(async () => { + setLoadingConversations(true) + try { + const res = await aiApi.listConversations({ page: 1, pageSize: 200 }) + if (!res.success) { + setErrorText(res.error || '加载会话失败') + return + } + const list = res.conversations || [] + setConversations(list) + if (!currentConversationId && list.length > 0) setCurrentConversationId(list[0].conversationId) + } finally { + setLoadingConversations(false) + } + }, [aiApi, currentConversationId]) + + const loadMessages = useCallback(async (conversationId: string) => { + if (!conversationId) return + setLoadingMessages(true) + try { + const res = await aiApi.listMessages({ conversationId, limit: 1200 }) + if (!res.success) { + setErrorText(res.error || '加载消息失败') + return + } + setMessages((res.messages || []).filter((message) => normalizeText(message.role) !== 'tool')) + } finally { + setLoadingMessages(false) + } + }, [aiApi]) + + const loadAssistantsAndSkills = useCallback(async () => { + try { + const [assistantList, skillList] = await Promise.all([ + assistantApi.getAll(), + skillApi.getAll() + ]) + setAssistants(assistantList || []) + setSkills(skillList || []) + if (assistantList && assistantList.length > 0 && !assistantList.some((item) => item.id === selectedAssistantId)) { + setSelectedAssistantId(assistantList[0].id) + } + } catch (error) { + setErrorText(String((error as Error)?.message || error)) + } + }, [assistantApi, skillApi, selectedAssistantId]) + + const loadContacts = useCallback(async () => { + try { + const res = await window.electronAPI.chat.getContacts({ lite: true }) + if (!res.success || !res.contacts) return + const list = res.contacts + .map((contact) => ({ + username: normalizeText(contact.username), + displayName: normalizeText(contact.displayName || contact.remark || contact.nickname || contact.username) + })) + .filter((contact) => contact.username && contact.displayName) + .slice(0, 300) + setContacts(list) + } catch { + // ignore + } + }, []) + + const loadToolCatalog = useCallback(async () => { + try { + const catalog = await aiApi.getToolCatalog() + setToolCatalog(Array.isArray(catalog) ? catalog : []) + if (!toolName && Array.isArray(catalog) && catalog.length > 0) { + setToolName(catalog[0].name) + } + } catch (error) { + setErrorText(String((error as Error)?.message || error)) + } + }, [aiApi, toolName]) + + const loadSchema = useCallback(async () => { + const res = await window.electronAPI.chat.getSchema({}) + if (!res.success || !res.schema) { + setSqlError(res.error || 'Schema 加载失败') + return + } + setSqlSchema(res.schema) + setSqlSchemaText(res.schemaText || '') + if (res.schema.sources.length > 0) { + setSqlTargetKey(`${res.schema.sources[0].kind}:${res.schema.sources[0].path || ''}`) + } + }, []) + + useEffect(() => { + void loadConversations() + void loadAssistantsAndSkills() + void loadContacts() + }, [loadConversations, loadAssistantsAndSkills, loadContacts]) + + useEffect(() => { + if (!currentConversationId) return + void loadMessages(currentConversationId) + }, [currentConversationId, loadMessages]) + + useEffect(() => { + if (activeTab === 'sql' && !sqlSchema) void loadSchema() + if (activeTab === 'tool' && toolCatalog.length === 0) void loadToolCatalog() + }, [activeTab, sqlSchema, loadSchema, toolCatalog.length, loadToolCatalog]) + + useEffect(() => { + const off = agentApi.onStream((chunk: AgentStreamChunk) => { + if (sqlRunIdRef.current && chunk.runId === sqlRunIdRef.current) { + if (chunk.type === 'content') { + setSqlGenerated((prev) => { + const next = `${prev}${chunk.content || ''}` + sqlGeneratedRef.current = next + return next + }) + } else if (chunk.type === 'done') { + setSqlGenerating(false) + if (normalizeText(sqlGeneratedRef.current)) { + setSqlHistory((prev) => [sqlGeneratedRef.current.trim(), ...prev].slice(0, 30)) + } + sqlRunIdRef.current = '' + } else if (chunk.type === 'error') { + setSqlGenerating(false) + setSqlError(chunk.error || 'SQL 生成失败') + sqlRunIdRef.current = '' + } + return + } + const conversationId = normalizeText(chunk.conversationId, currentConversationId) + if (!conversationId) return + appendChunk(conversationId, chunk) + if (chunk.type === 'done' || chunk.type === 'error' || chunk.isFinished) { + finishRun(conversationId) + void loadMessages(conversationId) + void loadConversations() + } + }) + return () => off() + }, [agentApi, appendChunk, currentConversationId, finishRun, loadConversations, loadMessages]) + + useEffect(() => { + messageEndRef.current?.scrollIntoView({ behavior: 'smooth', block: 'end' }) + }, [messages, runtimeState?.draft, runtimeState?.chunks.length]) + + const ensureConversation = useCallback(async (): Promise => { + if (currentConversationId) return currentConversationId + const created = await aiApi.createConversation({ title: '新的 AI 对话' }) + if (!created.success || !created.conversationId) throw new Error(created.error || '创建会话失败') + setCurrentConversationId(created.conversationId) + await loadConversations() + return created.conversationId + }, [aiApi, currentConversationId, loadConversations]) + + const handleCreateConversation = async () => { + const created = await aiApi.createConversation({ title: '新的 AI 对话' }) + if (!created.success || !created.conversationId) { + setErrorText(created.error || '创建会话失败') + return + } + setCurrentConversationId(created.conversationId) + setMessages([]) + setErrorText('') + await loadConversations() + } + + const handleRenameConversation = async (conversationId: string) => { + const current = conversations.find((item) => item.conversationId === conversationId) + const nextTitle = window.prompt('请输入新的会话标题', current?.title || '新的 AI 对话') + if (!nextTitle) return + const result = await aiApi.renameConversation({ conversationId, title: nextTitle }) + if (!result.success) { + setErrorText(result.error || '重命名失败') + return + } + await loadConversations() + } + + const handleDeleteConversation = async (conversationId: string) => { + const ok = window.confirm('确认删除该会话吗?') + if (!ok) return + const result = await aiApi.deleteConversation(conversationId) + if (!result.success) { + setErrorText(result.error || '删除失败') + return + } + if (currentConversationId === conversationId) { + setCurrentConversationId('') + setMessages([]) + } + await loadConversations() + } + + const handleSend = async () => { + const text = normalizeText(input) + if (!text) return + setErrorText('') + const conversationId = await ensureConversation() + setMessages((prev) => ([ + ...prev, + { + messageId: `temp-${Date.now()}`, + conversationId, + role: 'user', + content: text, + intentType: '', + components: [], + toolTrace: [], + createdAt: Date.now() + } + ])) + setInput('') + const run = await agentApi.runStream({ + mode: 'chat', + conversationId, + userInput: text, + assistantId: selectedAssistantId, + activeSkillId: selectedSkillId || undefined, + chatScope: scopeMode === 'session' ? 'private' : 'private' + }) + if (!run.success || !run.runId) { + setErrorText('启动失败') + return + } + startRun(conversationId, run.runId) + } + + const handleStop = async () => { + if (!currentConversationId) return + await agentApi.abort({ runId: activeRunId || undefined, conversationId: currentConversationId }) + finishRun(currentConversationId) + } + + const handleExportConversation = async () => { + if (!currentConversationId) return + const result = await aiApi.exportConversation({ conversationId: currentConversationId }) + if (!result.success || !result.markdown) { + setErrorText(result.error || '导出失败') + return + } + await navigator.clipboard.writeText(result.markdown) + window.alert('会话 Markdown 已复制到剪贴板') + } + + const handleOpenLog = async () => { + const logPath = await window.electronAPI.log.getPath() + await window.electronAPI.shell.openPath(logPath) + } + + const handleGenerateSql = async () => { + const prompt = normalizeText(sqlPrompt) + if (!prompt) return + setSqlGenerating(true) + setSqlGenerated('') + sqlGeneratedRef.current = '' + setSqlError('') + const target = extractSqlTarget(sqlSchema, sqlTargetKey) + const run = await agentApi.runStream({ + mode: 'sql', + userInput: prompt, + sqlContext: { + schemaText: sqlSchemaText, + targetHint: target ? `${target.kind}:${target.path || ''}` : '' + } + }) + if (!run.success || !run.runId) { + setSqlGenerating(false) + setSqlError('SQL 生成失败') + return + } + sqlRunIdRef.current = run.runId + } + + const handleExecuteSql = async () => { + const sql = normalizeText(sqlGenerated) + if (!sql) return + const target = extractSqlTarget(sqlSchema, sqlTargetKey) + if (!target) { + setSqlError('请选择 SQL 数据源') + return + } + const result = await window.electronAPI.chat.executeSQL({ + kind: target.kind, + path: target.path, + sql, + limit: 500 + }) + if (!result.success || !result.rows || !result.columns) { + setSqlError(result.error || '执行失败') + return + } + setSqlError('') + setSqlResult({ + rows: result.rows, + columns: result.columns, + total: result.total || result.rows.length + }) + setSqlHistory((prev) => [sql, ...prev].slice(0, 30)) + setSqlPage(1) + } + + const handleExportSqlRows = () => { + if (!sqlResult || sqlResult.rows.length === 0) return + const csv = toCsv(sqlResult.rows, sqlResult.columns) + const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' }) + const url = URL.createObjectURL(blob) + const link = document.createElement('a') + link.href = url + link.download = `sql-result-${Date.now()}.csv` + link.click() + URL.revokeObjectURL(url) + } + + const handleRunTool = async () => { + setToolRunning(true) + try { + const args = JSON.parse(toolArgsText || '{}') + const result = await aiApi.executeTool({ name: toolName, args }) + setToolOutput(JSON.stringify(result, null, 2)) + } catch (error) { + setToolOutput(String((error as Error)?.message || error)) + } finally { + setToolRunning(false) + } + } + + const groupedTools = useMemo(() => ({ + core: toolCatalog.filter((item) => item.category === 'core'), + analysis: toolCatalog.filter((item) => item.category === 'analysis') + }), [toolCatalog]) + + return ( +
+
+
+ +

AI Analysis

+ Chat Explorer + SQL Lab + Tool Test +
+
+ + + +
+
+ + {activeTab === 'chat' && ( +
+ + +
+
+
+ + + + + + + {scopeMode !== 'global' && ( + setScopeTarget(event.target.value)} + placeholder={scopeMode === 'contact' ? '联系人昵称/账号' : '会话ID'} + /> + )} +
+ {selectedAssistant?.presetQuestions?.length ? ( +
+ {selectedAssistant.presetQuestions.slice(0, 8).map((question) => ( + + ))} +
+ ) : null} +
+ +
+ {loadingMessages ? ( +
加载消息...
+ ) : ( +
+ {messages.map((message) => ( +
+
{message.role === 'user' ? '你' : message.role === 'assistant' ? '助手' : message.role}
+
{message.content || '(空)'}
+
+ ))} + + {runtimeState?.running && runtimeState?.chunks?.length ? ( +
+ {runtimeState.chunks + .filter((chunk) => chunk.type === 'tool_start' || chunk.type === 'tool_result' || chunk.type === 'error') + .slice(-16) + .map((chunk, index) => ( +
+ {chunk.type} + {chunk.toolName ? {chunk.toolName} : null} + {chunk.content ?
{chunk.content}
: null} + {chunk.type === 'tool_result' && chunk.toolResult !== undefined ? ( +
{JSON.stringify(chunk.toolResult, null, 2)}
+ ) : null} + {chunk.error ? {chunk.error} : null} +
+ ))} +
+ ) : null} + + {runtimeState?.draft ? ( +
+
助手(流式)
+
{runtimeState.draft}
+
+ ) : null} + +
+
+ )} +
+ +
+ + + +
+ +
+