// electron/main/ipc/ai.ts import { ipcMain, shell } from 'electron' import * as fs from 'fs' import * as path from 'path' import * as aiConversations from '../ai/conversations' import * as llm from '../ai/llm' import * as rag from '../ai/rag' import { aiLogger, setDebugMode } from '../ai/logger' import { serializeError } from '../ai/serialize-error' import { getLogsDir } from '../paths' import { Agent, type AgentStreamChunk, type SkillContext } from '../ai/agent' import { getDefaultGeneralAssistantId } from '../ai/assistant/defaultGeneral' import { getDefaultAssistantConfig, buildPiModel } from '../ai/llm' import { checkAndCompress, manualCompress, type CompressionConfig } from '../ai/compression' import { countMessagesTokens } from '../ai/tokenizer' import * as assistantManager from '../ai/assistant' import type { AssistantConfig } from '../ai/assistant/types' import * as skillManager from '../ai/skills' import { completeSimple, streamSimple, type Message as PiMessage, type TextContent as PiTextContent, } from '@mariozechner/pi-ai' import { t } from '../i18n' import type { ToolContext } from '../ai/tools/types' import { TOOL_REGISTRY } from '../ai/tools/definitions' import { getDefaultRulesForLocale, mergeRulesForLocale } from '../ai/preprocessor/builtin-rules' import type { IpcContext } from './types' function toPiSimpleMessages(messages: Array<{ role: string; content: string }>, timestamp: number): PiMessage[] { // pi-ai 的 simple API 在类型上要求完整 Message 联合,这里沿用现有轻量消息格式并集中做兼容转换。 return messages.map((message) => ({ role: message.role as 'user' | 'assistant', content: message.content, timestamp, })) as unknown as PiMessage[] } // ==================== AI Agent 请求追踪 ==================== // 用于跟踪活跃的 Agent 请求,支持中止操作 const activeAgentRequests = new Map() /** * 格式化 AI 报错信息,输出更友好的提示 */ function formatAIError(error: unknown, provider?: llm.LLMProvider): string { const candidates: unknown[] = [] if (error) { candidates.push(error) } const errorObj = error as { lastError?: unknown errors?: unknown[] } if (errorObj?.lastError) { candidates.push(errorObj.lastError) } if (Array.isArray(errorObj?.errors)) { candidates.push(...errorObj.errors) } let rawMessage = '' let statusCode: number | undefined let retrySeconds: number | undefined for (const candidate of candidates) { if (!candidate || typeof candidate !== 'object') { if (!rawMessage && typeof candidate === 'string') { rawMessage = candidate } continue } const record = candidate as Record if (typeof record.statusCode === 'number') { statusCode = record.statusCode } if (!rawMessage && typeof record.message === 'string') { rawMessage = record.message } if (!rawMessage && record.data && typeof record.data === 'object') { const data = record.data as { error?: { message?: string } } if (data.error?.message) { rawMessage = data.error.message } } if (record.responseBody && typeof record.responseBody === 'string') { const responseBody = record.responseBody try { const parsed = JSON.parse(responseBody) as { error?: { message?: string } } if (!rawMessage && parsed.error?.message) { rawMessage = parsed.error.message } } catch { if (!rawMessage) { rawMessage = responseBody } } } if (rawMessage) { const retryMatch = rawMessage.match(/retry in ([0-9.]+)s/i) if (retryMatch) { retrySeconds = Math.ceil(Number(retryMatch[1])) } } } const fallbackMessage = rawMessage || String(error) const lowerMessage = fallbackMessage.toLowerCase() const providerName = provider === 'openai-compatible' ? t('llm.genericProviderName') : provider ? llm.getProviderInfo(provider)?.name || provider : t('llm.genericProviderName') let friendlyMessage = '' if (statusCode === 429 || lowerMessage.includes('quota') || lowerMessage.includes('resource_exhausted')) { friendlyMessage = retrySeconds ? `${providerName} quota exhausted, please retry after ${retrySeconds}s or upgrade your quota.` : `${providerName} quota exhausted, please retry later or upgrade your quota.` } else if ( statusCode === 403 && (lowerMessage.includes('quota') || lowerMessage.includes('not enough') || lowerMessage.includes('insufficient')) ) { friendlyMessage = `${providerName} rejected the request due to insufficient quota or balance.` } else if (statusCode === 503 || lowerMessage.includes('overloaded') || lowerMessage.includes('unavailable')) { friendlyMessage = `${providerName} model is overloaded, please retry later.` } else if (fallbackMessage.length > 300) { friendlyMessage = `${fallbackMessage.slice(0, 300)}...` } else { friendlyMessage = fallbackMessage } const details = [statusCode ? `status=${statusCode}` : null, fallbackMessage].filter(Boolean).join('; ') // 同时返回封装提示和原始错误详情,便于用户定位第三方平台问题。 if (friendlyMessage !== fallbackMessage) { return `${friendlyMessage}\n\n${t('llm.rawErrorLabel')}: ${details}` } return friendlyMessage } /** * 递归剥离对象中的 avatar/senderAvatar 字段(base64 大数据) * 用于工具测试场景,避免传输和序列化大量无用头像数据 */ function stripAvatarFields(obj: unknown): void { if (!obj || typeof obj !== 'object') return if (Array.isArray(obj)) { for (const item of obj) stripAvatarFields(item) return } const record = obj as Record for (const key of Object.keys(record)) { if ((key === 'avatar' || key === 'senderAvatar') && typeof record[key] === 'string') { const val = record[key] as string if (val.length > 200) { record[key] = '[stripped]' } } else if (typeof record[key] === 'object' && record[key] !== null) { stripAvatarFields(record[key]) } } } export function registerAIHandlers({ win }: IpcContext): void { console.log('[IPC] Registering AI handlers...') // 初始化助手管理器(同步内置助手、加载用户助手) try { assistantManager.initAssistantManager() console.log('[IPC] Assistant manager initialized') } catch (error) { console.error('[IPC] Failed to initialize assistant manager:', error) } // 初始化技能管理器(扫描用户技能文件) try { skillManager.initSkillManager() console.log('[IPC] Skill manager initialized') } catch (error) { console.error('[IPC] Failed to initialize skill manager:', error) } // ==================== Debug 模式 ==================== ipcMain.on('app:setDebugMode', (_, enabled: boolean) => { setDebugMode(enabled) aiLogger.info('Config', `Debug mode ${enabled ? 'enabled' : 'disabled'}`) }) // ==================== AI 对话管理 ==================== /** * 创建新的 AI 对话 * 参数契约与 preload / 数据层保持一致:(sessionId, title?) */ ipcMain.handle( 'ai:createConversation', async (_, sessionId: string, title: string | undefined, assistantId: string) => { try { return aiConversations.createConversation(sessionId, title, assistantId) } catch (error) { console.error('Failed to create AI conversation:', error) throw error } } ) /** * 获取所有 AI 对话列表 */ ipcMain.handle('ai:getConversations', async (_, sessionId: string) => { try { return aiConversations.getConversations(sessionId) } catch (error) { console.error('Failed to get AI conversations:', error) return [] } }) /** * 打开当前 AI 日志文件并定位到文件 */ ipcMain.handle('ai:showLogFile', async () => { try { // 优先使用当前已存在的日志文件,避免创建新的空日志 const existingLogPath = aiLogger.getExistingLogPath() if (existingLogPath) { shell.showItemInFolder(existingLogPath) return { success: true, path: existingLogPath } } const logDir = path.join(getLogsDir(), 'ai') if (!fs.existsSync(logDir)) { return { success: false, error: 'No AI log files found' } } const logFiles = fs.readdirSync(logDir).filter((name) => name.startsWith('ai_') && name.endsWith('.log')) if (logFiles.length === 0) { return { success: false, error: 'No AI log files found' } } // 选择最近修改的日志文件 const latestLog = logFiles .map((name) => { const filePath = path.join(logDir, name) const stat = fs.statSync(filePath) return { path: filePath, mtimeMs: stat.mtimeMs } }) .sort((a, b) => b.mtimeMs - a.mtimeMs)[0] shell.showItemInFolder(latestLog.path) return { success: true, path: latestLog.path } } catch (error) { console.error('Failed to open AI log file:', error) return { success: false, error: String(error) } } }) /** * 获取单个对话详情 */ ipcMain.handle('ai:getConversation', async (_, conversationId: string) => { try { return aiConversations.getConversation(conversationId) } catch (error) { console.error('Failed to get AI conversation details:', error) return null } }) /** * 更新 AI 对话标题 */ ipcMain.handle('ai:updateConversationTitle', async (_, conversationId: string, title: string) => { try { return aiConversations.updateConversationTitle(conversationId, title) } catch (error) { console.error('Failed to update AI conversation title:', error) return false } }) /** * 删除 AI 对话 */ ipcMain.handle('ai:deleteConversation', async (_, conversationId: string) => { try { return aiConversations.deleteConversation(conversationId) } catch (error) { console.error('Failed to delete AI conversation:', error) return false } }) /** * 添加 AI 消息 */ ipcMain.handle( 'ai:addMessage', async ( _, conversationId: string, role: aiConversations.AIMessageRole, content: string, dataKeywords?: string[], dataMessageCount?: number, contentBlocks?: aiConversations.ContentBlock[], tokenUsage?: aiConversations.TokenUsageData ) => { try { return aiConversations.addMessage( conversationId, role, content, dataKeywords, dataMessageCount, contentBlocks, tokenUsage ) } catch (error) { console.error('Failed to add AI message:', error) throw error } } ) /** * 获取 AI 对话的所有消息 */ ipcMain.handle('ai:getMessages', async (_, conversationId: string) => { try { return aiConversations.getMessages(conversationId) } catch (error) { console.error('Failed to get AI messages:', error) return [] } }) /** * 获取对话的累计 token 使用量 */ ipcMain.handle('ai:getConversationTokenUsage', async (_, conversationId: string) => { try { return aiConversations.getConversationTokenUsage(conversationId) } catch (error) { console.error('Failed to get conversation token usage:', error) return { promptTokens: 0, completionTokens: 0, totalTokens: 0 } } }) /** * 删除 AI 消息 */ ipcMain.handle('ai:deleteMessage', async (_, messageId: string) => { try { return aiConversations.deleteMessage(messageId) } catch (error) { console.error('Failed to delete AI message:', error) return false } }) // ==================== 脱敏规则 ==================== ipcMain.handle('ai:getDefaultDesensitizeRules', (_, locale: string) => { return getDefaultRulesForLocale(locale) }) ipcMain.handle('ai:mergeDesensitizeRules', (_, existingRules: unknown[], locale: string) => { return mergeRulesForLocale(existingRules as any[], locale) }) // ==================== LLM 服务(多配置管理)==================== /** * 获取所有支持的 LLM 提供商 */ ipcMain.handle('llm:getProviders', async () => { return llm.PROVIDERS }) /** * 获取所有配置列表 */ ipcMain.handle('llm:getAllConfigs', async () => { const configs = llm.getAllConfigs() // 返回 API Key return configs.map((c) => ({ ...c, apiKeySet: !!c.apiKey, })) }) /** * 获取默认助手 slot */ ipcMain.handle('llm:getDefaultAssistantSlot', async () => { return llm.getDefaultAssistantSlot() }) /** * 获取快速模型 slot */ ipcMain.handle('llm:getFastModelSlot', async () => { return llm.getFastModelSlot() }) /** * 添加新配置 */ ipcMain.handle( 'llm:addConfig', async ( _, config: { name: string provider: llm.LLMProvider apiKey: string model?: string baseUrl?: string maxTokens?: number apiFormat?: string customModels?: Array<{ id: string; name: string }> } ) => { try { const result = llm.addConfig(config) if (result.success && result.config) { return { success: true, config: { ...result.config, apiKeySet: !!result.config.apiKey, }, } } return result } catch (error) { console.error('Failed to add LLM config:', error) return { success: false, error: String(error) } } } ) /** * 更新配置 */ ipcMain.handle( 'llm:updateConfig', async ( _, id: string, updates: { name?: string provider?: llm.LLMProvider apiKey?: string model?: string baseUrl?: string maxTokens?: number apiFormat?: string customModels?: Array<{ id: string; name: string }> } ) => { try { // 如果 apiKey 为空字符串,表示不更新 API Key const cleanUpdates = { ...updates } if (cleanUpdates.apiKey === '') { delete cleanUpdates.apiKey } return llm.updateConfig(id, cleanUpdates) } catch (error) { console.error('Failed to update LLM config:', error) return { success: false, error: String(error) } } } ) /** * 删除配置 */ ipcMain.handle('llm:deleteConfig', async (_, id?: string) => { try { if (!id) { const defaultConfig = llm.getDefaultAssistantConfig() if (defaultConfig) { return llm.deleteConfig(defaultConfig.id) } return { success: false, error: t('llm.noActiveConfig') } } return llm.deleteConfig(id) } catch (error) { console.error('Failed to delete LLM config:', error) return { success: false, error: String(error) } } }) /** * 设置默认助手模型(configId + modelId) */ ipcMain.handle('llm:setDefaultAssistantModel', async (_, configId: string, modelId: string) => { try { return llm.setDefaultAssistantModel(configId, modelId) } catch (error) { console.error('Failed to set default assistant model:', error) return { success: false, error: String(error) } } }) /** * 设置快速模型 */ ipcMain.handle('llm:setFastModel', async (_, slot: { configId: string; modelId: string } | null) => { try { return llm.setFastModel(slot) } catch (error) { console.error('Failed to set fast model:', error) return { success: false, error: String(error) } } }) /** * 验证 API Key(支持自定义 baseUrl 和 model) * 返回对象格式:{ success: boolean, error?: string } */ ipcMain.handle( 'llm:validateApiKey', async (_, provider: llm.LLMProvider, apiKey: string, baseUrl?: string, model?: string) => { aiLogger.info('IPC', 'llm:validateApiKey validating', { provider, baseUrl, model, apiKeyLength: apiKey?.length, }) try { const result = await llm.validateApiKey(provider, apiKey, baseUrl, model) aiLogger.info('IPC', 'llm:validateApiKey result', result) return result } catch (error) { aiLogger.error('IPC', 'llm:validateApiKey failed', { error: String(error), provider, baseUrl, model }) const errorMessage = error instanceof Error ? error.message : String(error) return { success: false, error: errorMessage } } } ) /** * 获取远程模型列表 */ ipcMain.handle( 'llm:fetchRemoteModels', async (_, provider: string, apiKey: string, baseUrl?: string, apiFormat?: string) => { aiLogger.info('IPC', 'llm:fetchRemoteModels', { provider, baseUrl, apiFormat }) try { return await llm.fetchRemoteModels(provider, apiKey, baseUrl, apiFormat) } catch (error) { aiLogger.error('IPC', 'llm:fetchRemoteModels failed', { error: String(error) }) return { success: false, error: error instanceof Error ? error.message : String(error) } } } ) /** * 检查是否已配置 LLM(是否有激活的配置) */ ipcMain.handle('llm:hasConfig', async () => { return llm.hasActiveConfig() }) // ==================== Provider Registry / Model Catalog ==================== ipcMain.handle('llm:getProviderRegistry', async () => { return llm.getProviderRegistry() }) ipcMain.handle('llm:getModelCatalog', async () => { return llm.getModelCatalog() }) ipcMain.handle('llm:addCustomProvider', async (_, input) => { try { const provider = llm.addCustomProvider(input) return { success: true, provider } } catch (error) { return { success: false, error: String(error) } } }) ipcMain.handle('llm:updateCustomProvider', async (_, id: string, updates) => { try { return llm.updateCustomProvider(id, updates) } catch (error) { return { success: false, error: String(error) } } }) ipcMain.handle('llm:deleteCustomProvider', async (_, id: string) => { try { return llm.deleteCustomProvider(id) } catch (error) { return { success: false, error: String(error) } } }) ipcMain.handle('llm:addCustomModel', async (_, input) => { try { const model = llm.addCustomModel(input) return { success: true, model } } catch (error) { return { success: false, error: String(error) } } }) ipcMain.handle('llm:updateCustomModel', async (_, providerId: string, modelId: string, updates) => { try { return llm.updateCustomModel(providerId, modelId, updates) } catch (error) { return { success: false, error: String(error) } } }) ipcMain.handle('llm:deleteCustomModel', async (_, providerId: string, modelId: string) => { try { return llm.deleteCustomModel(providerId, modelId) } catch (error) { return { success: false, error: String(error) } } }) // ==================== LLM 直接调用 API(SQLLab 等非 Agent 场景使用) ==================== /** * 非流式 LLM 调用 */ ipcMain.handle( 'llm:chat', async ( _, messages: Array<{ role: string; content: string }>, options?: { temperature?: number; maxTokens?: number } ) => { try { const activeConfig = getDefaultAssistantConfig() if (!activeConfig) { return { success: false, error: t('llm.notConfigured') } } const piModel = buildPiModel(activeConfig) const now = Date.now() const systemMsg = messages.find((m) => m.role === 'system') const nonSystemMsgs = messages.filter((m) => m.role !== 'system') const result = await completeSimple( piModel, { systemPrompt: systemMsg?.content, messages: toPiSimpleMessages(nonSystemMsgs, now), }, { apiKey: activeConfig.apiKey, temperature: options?.temperature, maxTokens: options?.maxTokens, } ) const content = result.content .filter((item): item is PiTextContent => item.type === 'text') .map((item) => item.text) .join('') return { success: true, content } } catch (error) { aiLogger.error('IPC', 'llm:chat error', { error: String(error) }) return { success: false, error: String(error) } } } ) /** * 流式 LLM 调用(SQLLab AI 生成 / 结果总结等场景使用) */ ipcMain.handle( 'llm:chatStream', async ( _, requestId: string, messages: Array<{ role: string; content: string }>, options?: { temperature?: number; maxTokens?: number } ) => { try { const activeConfig = getDefaultAssistantConfig() if (!activeConfig) { return { success: false, error: t('llm.notConfigured') } } const piModel = buildPiModel(activeConfig) const now = Date.now() const systemMsg = messages.find((m) => m.role === 'system') const nonSystemMsgs = messages.filter((m) => m.role !== 'system') const eventStream = streamSimple( piModel, { systemPrompt: systemMsg?.content, messages: toPiSimpleMessages(nonSystemMsgs, now), }, { apiKey: activeConfig.apiKey, temperature: options?.temperature, maxTokens: options?.maxTokens, } ) // 异步消费流,通过事件发送 chunks ;(async () => { let hasTerminalChunk = false try { for await (const event of eventStream) { if (event.type === 'text_delta') { win.webContents.send('llm:streamChunk', { requestId, chunk: { content: event.delta, isFinished: false }, }) continue } if (event.type === 'done') { hasTerminalChunk = true win.webContents.send('llm:streamChunk', { requestId, chunk: { content: '', isFinished: true, finishReason: event.reason === 'length' ? 'length' : 'stop' }, }) return } if (event.type === 'error') { hasTerminalChunk = true const errorMsg = event.error?.errorMessage || formatAIError(event.error, activeConfig.provider) || 'Unknown LLM error' aiLogger.error('IPC', 'llm:chatStream LLM error', { requestId, error: errorMsg }) win.webContents.send('llm:streamChunk', { requestId, error: errorMsg, chunk: { content: '', isFinished: true, finishReason: 'error' }, }) return } } if (!hasTerminalChunk) { win.webContents.send('llm:streamChunk', { requestId, chunk: { content: '', isFinished: true, finishReason: 'stop' }, }) } } catch (error) { if (!hasTerminalChunk) { const friendlyError = formatAIError(error, activeConfig.provider) aiLogger.error('IPC', 'llm:chatStream stream error', { requestId, error: String(error) }) win.webContents.send('llm:streamChunk', { requestId, error: friendlyError, chunk: { content: '', isFinished: true, finishReason: 'error' }, }) } } })() return { success: true } } catch (error) { aiLogger.error('IPC', 'llm:chatStream error', { error: String(error) }) return { success: false, error: String(error) } } } ) // ==================== 助手管理 API ==================== ipcMain.handle('assistant:getAll', async () => { try { return assistantManager.getAllAssistants() } catch (error) { console.error('Failed to get assistants:', error) return [] } }) ipcMain.handle('assistant:getConfig', async (_, id: string) => { try { return assistantManager.getAssistantConfig(id) } catch (error) { console.error('Failed to get assistant config:', error) return null } }) ipcMain.handle('assistant:update', async (_, id: string, updates: Partial) => { try { return assistantManager.updateAssistant(id, updates) } catch (error) { console.error('Failed to update assistant:', error) return { success: false, error: String(error) } } }) ipcMain.handle('assistant:create', async (_, config: Omit) => { try { return assistantManager.createAssistant(config) } catch (error) { console.error('Failed to create assistant:', error) return { success: false, error: String(error) } } }) ipcMain.handle('assistant:delete', async (_, id: string) => { try { return assistantManager.deleteAssistant(id) } catch (error) { console.error('Failed to delete assistant:', error) return { success: false, error: String(error) } } }) ipcMain.handle('assistant:reset', async (_, id: string) => { try { return assistantManager.resetAssistant(id) } catch (error) { console.error('Failed to reset assistant:', error) return { success: false, error: String(error) } } }) ipcMain.handle('assistant:getBuiltinToolCatalog', async () => { try { return assistantManager.getBuiltinToolCatalog() } catch (error) { console.error('Failed to get builtin tool catalog:', error) return [] } }) ipcMain.handle('assistant:getBuiltinCatalog', async () => { try { return assistantManager.getBuiltinCatalog() } catch (error) { console.error('Failed to get builtin catalog:', error) return [] } }) ipcMain.handle('assistant:import', async (_, builtinId: string) => { try { return assistantManager.importAssistant(builtinId) } catch (error) { console.error('Failed to import assistant:', error) return { success: false, error: String(error) } } }) ipcMain.handle('assistant:reimport', async (_, id: string) => { try { return assistantManager.reimportAssistant(id) } catch (error) { console.error('Failed to reimport assistant:', error) return { success: false, error: String(error) } } }) ipcMain.handle('assistant:importFromMd', async (_, rawMd: string) => { try { return assistantManager.importAssistantFromMd(rawMd) } catch (error) { console.error('Failed to import assistant from markdown:', error) return { success: false, error: String(error) } } }) // ==================== 技能管理 API ==================== ipcMain.handle('skill:getAll', async () => { try { return skillManager.getAllSkills() } catch (error) { console.error('Failed to get skills:', error) return [] } }) ipcMain.handle('skill:getConfig', async (_, id: string) => { try { return skillManager.getSkillConfig(id) } catch (error) { console.error('Failed to get skill config:', error) return null } }) ipcMain.handle('skill:update', async (_, id: string, rawMd: string) => { try { return skillManager.updateSkill(id, rawMd) } catch (error) { console.error('Failed to update skill:', error) return { success: false, error: String(error) } } }) ipcMain.handle('skill:create', async (_, rawMd: string) => { try { return skillManager.createSkill(rawMd) } catch (error) { console.error('Failed to create skill:', error) return { success: false, error: String(error) } } }) ipcMain.handle('skill:delete', async (_, id: string) => { try { return skillManager.deleteSkill(id) } catch (error) { console.error('Failed to delete skill:', error) return { success: false, error: String(error) } } }) ipcMain.handle('skill:getBuiltinCatalog', async () => { try { return skillManager.getBuiltinCatalog() } catch (error) { console.error('Failed to get builtin catalog:', error) return [] } }) ipcMain.handle('skill:import', async (_, builtinId: string) => { try { return skillManager.importSkill(builtinId) } catch (error) { console.error('Failed to import skill:', error) return { success: false, error: String(error) } } }) ipcMain.handle('skill:reimport', async (_, id: string) => { try { return skillManager.reimportSkill(id) } catch (error) { console.error('Failed to reimport skill:', error) return { success: false, error: String(error) } } }) ipcMain.handle('skill:importFromMd', async (_, rawMd: string) => { try { return skillManager.importSkillFromMd(rawMd) } catch (error) { console.error('Failed to import skill from markdown:', error) return { success: false, error: String(error) } } }) // ==================== 工具测试 API(实验室 - 基础工具) ==================== const activeToolTests = new Map() ipcMain.handle('ai:getToolCatalog', async () => { try { return TOOL_REGISTRY.map((entry) => { const dummyContext: ToolContext = { sessionId: '__catalog__' } const tool = entry.factory(dummyContext) const descKey = `ai.tools.${entry.name}.desc` const translated = t(descKey) return { name: entry.name, category: entry.category, description: translated !== descKey ? translated : (tool.description ?? ''), parameters: tool.parameters ?? {}, } }) } catch (error) { console.error('Failed to get tool catalog:', error) return [] } }) ipcMain.handle( 'ai:executeTool', async (_, testId: string, toolName: string, params: Record, sessionId: string) => { const MAX_RESULT_CHARS = 500_000 const abortController = new AbortController() activeToolTests.set(testId, abortController) try { const entry = TOOL_REGISTRY.find((e) => e.name === toolName) if (!entry) { return { success: false, error: `Tool not found: ${toolName}` } } const context: ToolContext = { sessionId } const tool = entry.factory(context) const startTime = Date.now() const result = await tool.execute(`test_${Date.now()}`, params) const elapsed = Date.now() - startTime if (abortController.signal.aborted) { return { success: false, error: 'cancelled' } } let details = result.details as Record | undefined let truncated = false if (details) { stripAvatarFields(details) const raw = JSON.stringify(details) if (raw.length > MAX_RESULT_CHARS) { truncated = true details = { _truncated: true, _originalSize: raw.length, _preview: raw.slice(0, MAX_RESULT_CHARS) } } } return { success: true, elapsed, content: result.content, details, truncated, } } catch (error) { if (abortController.signal.aborted) { return { success: false, error: 'cancelled' } } console.error(`Failed to execute tool ${toolName}:`, error) return { success: false, error: String(error) } } finally { activeToolTests.delete(testId) } } ) ipcMain.handle('ai:cancelToolTest', async (_, testId: string) => { const controller = activeToolTests.get(testId) if (controller) { controller.abort() activeToolTests.delete(testId) return { success: true } } return { success: false } }) // ==================== AI Agent API ==================== /** * 执行 Agent 对话(流式) * Agent 会自动调用工具并返回最终结果 * Agent 通过 context.conversationId 从 SQLite 读取对话历史(数据流倒置) * @param chatType 聊天类型('group' | 'private') * @param locale 语言设置(可选,默认 'zh-CN') * @param assistantId 助手 ID(可选,传入时从 AssistantManager 获取配置) */ ipcMain.handle( 'agent:runStream', async ( _, requestId: string, userMessage: string, context: ToolContext, chatType?: 'group' | 'private', locale?: string, assistantId?: string, skillId?: string | null, enableAutoSkill?: boolean, compressionConfig?: CompressionConfig ) => { aiLogger.info('IPC', `Agent stream request received: ${requestId}`, { userMessage: userMessage.slice(0, 100), sessionId: context.sessionId, conversationId: context.conversationId, chatType: chatType ?? 'group', assistantId: assistantId ?? '(none)', skillId: skillId ?? '(none)', enableAutoSkill: enableAutoSkill ?? false, }) try { const abortController = new AbortController() activeAgentRequests.set(requestId, abortController) const activeAIConfig = getDefaultAssistantConfig() if (!activeAIConfig) { return { success: false, error: t('llm.notConfigured') } } const piModel = buildPiModel(activeAIConfig) // 上下文压缩前置步骤(在 Agent 创建之前执行) if (compressionConfig?.enabled && context.conversationId) { try { win.webContents.send('agent:streamChunk', { requestId, chunk: { type: 'status', status: { phase: 'compressing', round: 0, toolsUsed: 0, contextTokens: 0, totalUsage: { promptTokens: 0, completionTokens: 0, totalTokens: 0 }, updatedAt: Date.now(), } satisfies import('@electron/shared/types').AgentRuntimeStatus, }, }) // 获取助手 systemPrompt 用于 token 计算 const tempAssistantConfig = assistantId ? (assistantManager.getAssistantConfig(assistantId) ?? undefined) : undefined const systemPromptForCompression = tempAssistantConfig?.systemPrompt || '' const compressionResult = await checkAndCompress( context.conversationId, compressionConfig, systemPromptForCompression, activeAIConfig ) aiLogger.info('IPC', `Compression result for ${requestId}`, compressionResult) if (compressionResult.compressed && compressionResult.summaryContent) { win.webContents.send('agent:streamChunk', { requestId, chunk: { type: 'compression_done', compressionResult: { summaryContent: compressionResult.summaryContent, tokensBefore: compressionResult.tokensBefore ?? 0, tokensAfter: compressionResult.tokensAfter ?? 0, timestamp: Date.now(), }, }, }) } } catch (error) { aiLogger.error('IPC', `Compression failed for ${requestId}, continuing without compression`, { error: String(error), }) } } const pp = context.preprocessConfig aiLogger.info('IPC', `Agent context: ${requestId}`, { model: activeAIConfig.model, provider: activeAIConfig.provider, baseUrl: activeAIConfig.baseUrl || '(default)', maxMessagesLimit: context.maxMessagesLimit, hasTimeFilter: !!context.timeFilter, mentionedMembersCount: context.mentionedMembers?.length ?? 0, preprocess: pp ? { dataCleaning: pp.dataCleaning ?? true, mergeConsecutive: pp.mergeConsecutive, denoise: pp.denoise, desensitize: pp.desensitize, anonymizeNames: pp.anonymizeNames, } : '(disabled)', }) // 提示词系统已退场,主流程统一从助手配置获取 systemPrompt。 const defaultAssistantId = getDefaultGeneralAssistantId(locale) let resolvedAssistantId = assistantId || defaultAssistantId let assistantConfig: AssistantConfig | undefined = assistantManager.getAssistantConfig(resolvedAssistantId) ?? undefined if (!assistantConfig && resolvedAssistantId !== defaultAssistantId) { aiLogger.warn('IPC', `Assistant not found: ${resolvedAssistantId}, falling back to ${defaultAssistantId}`, { requestedAssistantId: assistantId ?? null, locale: locale ?? null, }) resolvedAssistantId = defaultAssistantId assistantConfig = assistantManager.getAssistantConfig(defaultAssistantId) ?? undefined } // 构建技能上下文 let skillCtx: SkillContext | undefined if (skillId) { const skillDef = skillManager.getSkillConfig(skillId) ?? undefined if (skillDef) { skillCtx = { skillDef } } else { aiLogger.warn('IPC', `Skill not found: ${skillId}`) } } else if (enableAutoSkill) { const effectiveChatType = chatType ?? 'group' const allowedTools = assistantConfig?.allowedBuiltinTools const menu = skillManager.getSkillMenu(effectiveChatType, allowedTools) if (menu) { skillCtx = { skillMenu: menu } } } // 工具结果 token 预算注入:基于 context window 百分比计算 const maxToolResultPercent = compressionConfig?.maxToolResultPercent ?? 50 const modelDef = llm.findModelDefinition(activeAIConfig.provider, activeAIConfig.model || '') const resolvedContextWindow = modelDef?.contextWindow || 128000 const maxToolResultTokens = Math.floor(resolvedContextWindow * (maxToolResultPercent / 100)) const enrichedContext: ToolContext = { ...context, maxToolResultTokens } const agent = new Agent( enrichedContext, piModel, activeAIConfig.apiKey, { abortSignal: abortController.signal }, chatType ?? 'group', locale ?? 'zh-CN', assistantConfig, skillCtx ) // 异步执行,通过事件发送流式数据 ;(async () => { try { const result = await agent.executeStream(userMessage, (chunk: AgentStreamChunk) => { // 如果已中止,不再发送 if (abortController.signal.aborted) { return } if (chunk.type === 'tool_start') { aiLogger.info('IPC', `Tool call: ${chunk.toolName}`, chunk.toolParams) } win.webContents.send('agent:streamChunk', { requestId, chunk }) }) if (abortController.signal.aborted) { aiLogger.info('IPC', `Agent aborted: ${requestId}`) win.webContents.send('agent:complete', { requestId, result: { content: result.content, toolsUsed: result.toolsUsed, toolRounds: result.toolRounds, totalUsage: result.totalUsage, aborted: true, }, }) return } // 发送完成信息 win.webContents.send('agent:complete', { requestId, result: { content: result.content, toolsUsed: result.toolsUsed, toolRounds: result.toolRounds, totalUsage: result.totalUsage, }, }) aiLogger.info('IPC', `Agent execution completed: ${requestId}`, { toolsUsed: result.toolsUsed, toolRounds: result.toolRounds, contentLength: result.content.length, totalUsage: result.totalUsage, }) } catch (error) { if (error instanceof Error && error.name === 'AbortError') { aiLogger.info('IPC', `Agent request aborted (error): ${requestId}`) win.webContents.send('agent:complete', { requestId, result: { content: '', toolsUsed: [], toolRounds: 0, aborted: true }, }) return } const serializedError = serializeError(error, activeAIConfig.provider) serializedError.friendlyMessage = formatAIError(error, activeAIConfig.provider) if (!serializedError.url && activeAIConfig.baseUrl) { serializedError.url = activeAIConfig.baseUrl } aiLogger.error('IPC', `Agent execution error: ${requestId}`, serializedError) // 发送错误 chunk win.webContents.send('agent:streamChunk', { requestId, chunk: { type: 'error', error: serializedError, isFinished: true }, }) // 发送完成事件(带错误信息),确保前端 promise 能 resolve win.webContents.send('agent:complete', { requestId, result: { content: '', toolsUsed: [], toolRounds: 0, totalUsage: { promptTokens: 0, completionTokens: 0, totalTokens: 0 }, error: serializedError, }, }) } finally { // 清理请求追踪 activeAgentRequests.delete(requestId) } })() return { success: true } } catch (error) { aiLogger.error('IPC', `Failed to create Agent request: ${requestId}`, { error: String(error) }) return { success: false, error: String(error) } } } ) /** * 中止 Agent 请求 */ ipcMain.handle('agent:abort', async (_, requestId: string) => { aiLogger.info('IPC', `Abort request received: ${requestId}`) const abortController = activeAgentRequests.get(requestId) if (abortController) { abortController.abort() activeAgentRequests.delete(requestId) aiLogger.info('IPC', `Agent request aborted: ${requestId}`) return { success: true } } else { aiLogger.warn('IPC', `Agent request not found: ${requestId}`) return { success: false, error: 'Request not found' } } }) // ==================== 上下文压缩 ==================== ipcMain.handle( 'ai:compressContext', async (_, conversationId: string, compressionConfig: CompressionConfig, systemPrompt: string) => { try { const activeAIConfig = getDefaultAssistantConfig() if (!activeAIConfig) { return { success: false, error: t('llm.notConfigured') } } const result = await manualCompress(conversationId, compressionConfig, systemPrompt, activeAIConfig) return { success: true, result } } catch (error) { aiLogger.error('IPC', 'Manual compression failed', { error: String(error) }) return { success: false, error: String(error) } } } ) ipcMain.handle('ai:estimateContextTokens', async (_, conversationId: string) => { try { const history = aiConversations.getHistoryForAgent(conversationId) const tokens = countMessagesTokens(history.map((m) => ({ role: m.role, content: m.content }))) return { success: true, tokens, messageCount: history.length } } catch (error) { return { success: false, tokens: 0, error: String(error) } } }) // ==================== Embedding 多配置管理 ==================== /** * 获取所有 Embedding 配置(展示用,隐藏 apiKey) */ ipcMain.handle('embedding:getAllConfigs', async () => { try { const configs = rag.getAllEmbeddingConfigs() // 隐藏敏感信息 return configs.map((c) => ({ ...c, apiKey: undefined, apiKeySet: !!c.apiKey, })) } catch (error) { aiLogger.error('IPC', 'Failed to get Embedding configs', error) return [] } }) /** * 获取单个 Embedding 配置(用于编辑,包含完整信息) */ ipcMain.handle('embedding:getConfig', async (_, id: string) => { try { return rag.getEmbeddingConfigById(id) } catch (error) { aiLogger.error('IPC', 'Failed to get Embedding config', error) return null } }) /** * 获取激活的 Embedding 配置 ID */ ipcMain.handle('embedding:getActiveConfigId', async () => { try { return rag.getActiveEmbeddingConfigId() } catch (error) { return null } }) /** * 检查语义搜索是否启用 */ ipcMain.handle('embedding:isEnabled', async () => { try { return rag.isEmbeddingEnabled() } catch (error) { return false } }) /** * 添加 Embedding 配置 */ ipcMain.handle( 'embedding:addConfig', async (_, config: Omit) => { try { aiLogger.info('IPC', 'Adding Embedding config', { name: config.name, model: config.model }) const result = rag.addEmbeddingConfig(config) if (result.success) { await rag.resetEmbeddingService() } return result } catch (error) { aiLogger.error('IPC', 'Failed to add Embedding config', error) return { success: false, error: String(error) } } } ) /** * 更新 Embedding 配置 */ ipcMain.handle( 'embedding:updateConfig', async (_, id: string, updates: Partial>) => { try { aiLogger.info('IPC', 'Updating Embedding config', { id }) const result = rag.updateEmbeddingConfig(id, updates) if (result.success) { await rag.resetEmbeddingService() } return result } catch (error) { aiLogger.error('IPC', 'Failed to update Embedding config', error) return { success: false, error: String(error) } } } ) /** * 删除 Embedding 配置 */ ipcMain.handle('embedding:deleteConfig', async (_, id: string) => { try { aiLogger.info('IPC', 'Deleting Embedding config', { id }) const result = rag.deleteEmbeddingConfig(id) if (result.success) { await rag.resetEmbeddingService() } return result } catch (error) { aiLogger.error('IPC', 'Failed to delete Embedding config', error) return { success: false, error: String(error) } } }) /** * 设置激活的 Embedding 配置 */ ipcMain.handle('embedding:setActiveConfig', async (_, id: string) => { try { aiLogger.info('IPC', 'Setting active Embedding config', { id }) const result = rag.setActiveEmbeddingConfig(id) if (result.success) { await rag.resetEmbeddingService() } return result } catch (error) { aiLogger.error('IPC', 'Failed to set active Embedding config', error) return { success: false, error: String(error) } } }) /** * 验证 Embedding 配置 */ ipcMain.handle('embedding:validateConfig', async (_, config: rag.EmbeddingServiceConfig) => { try { return await rag.validateEmbeddingConfig(config) } catch (error) { return { success: false, error: String(error) } } }) // ==================== 向量存储管理 ==================== /** * [Debug] 获取 AI 数据库 Schema */ ipcMain.handle('ai:getAiSchema', async () => { try { return aiConversations.getAiSchema() } catch (error) { console.error('Failed to get AI schema:', error) return [] } }) /** * [Debug] 在 AI 数据库上执行原始 SQL */ ipcMain.handle('ai:executeAiSQL', async (_, sql: string) => { try { return aiConversations.executeAiSQL(sql) } catch (error: any) { console.error('Failed to execute AI SQL:', error) throw error } }) /** * 获取向量存储统计信息 */ ipcMain.handle('rag:getVectorStoreStats', async () => { try { return await rag.getVectorStoreStats() } catch (error) { console.error('Failed to get vector store stats:', error) return { enabled: false, error: String(error) } } }) /** * 清空向量存储 */ ipcMain.handle('rag:clearVectorStore', async () => { try { const store = await rag.getVectorStore() if (store) { await store.clear() return { success: true } } return { success: false, error: 'Vector store not enabled' } } catch (error) { console.error('Failed to clear vector store:', error) return { success: false, error: String(error) } } }) }