Files
ChatLab/electron/main/ipc/ai.ts
T
2026-04-10 00:47:56 +08:00

1390 lines
42 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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 { getLogsDir } from '../paths'
import { Agent, type AgentStreamChunk, type SkillContext } from '../ai/agent'
import { getDefaultGeneralAssistantId } from '../ai/assistant/defaultGeneral'
import { getActiveConfig, buildPiModel } from '../ai/llm'
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<string, AbortController>()
/**
* 格式化 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<string, unknown>
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<string, unknown>
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: 'user' | 'assistant',
content: string,
dataKeywords?: string[],
dataMessageCount?: number,
contentBlocks?: aiConversations.ContentBlock[]
) => {
try {
return aiConversations.addMessage(conversationId, role, content, dataKeywords, dataMessageCount, contentBlocks)
} 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 []
}
})
/**
* 删除 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,
}))
})
/**
* 获取当前激活的配置 ID
*/
ipcMain.handle('llm:getActiveConfigId', async () => {
const config = llm.getActiveConfig()
return config?.id || null
})
/**
* 添加新配置
*/
ipcMain.handle(
'llm:addConfig',
async (
_,
config: {
name: string
provider: llm.LLMProvider
apiKey: string
model?: string
baseUrl?: string
maxTokens?: number
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
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 {
// 如果没有传 id,删除当前激活的配置
if (!id) {
const activeConfig = llm.getActiveConfig()
if (activeConfig) {
return llm.deleteConfig(activeConfig.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) }
}
})
/**
* 设置激活的配置
*/
ipcMain.handle('llm:setActiveConfig', async (_, id: string) => {
try {
return llm.setActiveConfig(id)
} catch (error) {
console.error('Failed to set active config:', 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 }
}
}
)
/**
* 检查是否已配置 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 直接调用 APISQLLab 等非 Agent 场景使用) ====================
/**
* 非流式 LLM 调用
*/
ipcMain.handle(
'llm:chat',
async (
_,
messages: Array<{ role: string; content: string }>,
options?: { temperature?: number; maxTokens?: number }
) => {
try {
const activeConfig = getActiveConfig()
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 = getActiveConfig()
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<AssistantConfig>) => {
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<AssistantConfig, 'id' | 'version'>) => {
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<string, AbortController>()
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<string, unknown>, 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<string, unknown> | 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 maxHistoryRounds 前端用户配置的最大历史轮数(可选,每轮 = user + assistant = 2 条)
* @param assistantId 助手 ID(可选,传入时从 AssistantManager 获取配置)
*/
ipcMain.handle(
'agent:runStream',
async (
_,
requestId: string,
userMessage: string,
context: ToolContext,
chatType?: 'group' | 'private',
locale?: string,
maxHistoryRounds?: number,
assistantId?: string,
skillId?: string | null,
enableAutoSkill?: boolean
) => {
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 = getActiveConfig()
if (!activeAIConfig) {
return { success: false, error: t('llm.notConfigured') }
}
const piModel = buildPiModel(activeAIConfig)
const contextHistoryLimit = maxHistoryRounds ? maxHistoryRounds * 2 : undefined
const pp = context.preprocessConfig
aiLogger.info('IPC', `Agent context: ${requestId}`, {
model: activeAIConfig.model,
provider: activeAIConfig.provider,
baseUrl: activeAIConfig.baseUrl || '(default)',
maxHistoryRounds: maxHistoryRounds ?? '(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 }
}
}
const agent = new Agent(
context,
piModel,
activeAIConfig.apiKey,
{ abortSignal: abortController.signal, contextHistoryLimit },
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 friendlyError = formatAIError(error, activeAIConfig.provider)
aiLogger.error('IPC', `Agent execution error: ${requestId}`, {
error: String(error),
friendlyError,
})
// 发送错误 chunk
win.webContents.send('agent:streamChunk', {
requestId,
chunk: { type: 'error', error: friendlyError, isFinished: true },
})
// 发送完成事件(带错误信息),确保前端 promise 能 resolve
win.webContents.send('agent:complete', {
requestId,
result: {
content: '',
toolsUsed: [],
toolRounds: 0,
totalUsage: { promptTokens: 0, completionTokens: 0, totalTokens: 0 },
error: friendlyError,
},
})
} 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' }
}
})
// ==================== 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<rag.EmbeddingServiceConfig, 'id' | 'createdAt' | 'updatedAt'>) => {
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<Omit<rag.EmbeddingServiceConfig, 'id' | 'createdAt' | 'updatedAt'>>) => {
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) }
}
})
// ==================== 向量存储管理 ====================
/**
* 获取向量存储统计信息
*/
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) }
}
})
}