Files
ChatLab/electron/main/ipc/ai.ts
T
2026-01-25 21:37:34 +08:00

832 lines
24 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, BrowserWindow, 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 } from '../ai/logger'
import { getLogsDir } from '../paths'
import { Agent, type AgentStreamChunk, type PromptConfig } from '../ai/agent'
import type { ToolContext } from '../ai/tools/types'
import type { IpcContext } from './types'
// ==================== AI Agent 请求追踪 ====================
// 用于跟踪活跃的 Agent 请求,支持中止操作
const activeAgentRequests = new Map<string, AbortController>()
/**
* 格式化 AI 报错信息,输出更友好的提示
*/
function formatAIError(error: unknown): 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()
if (statusCode === 429 || lowerMessage.includes('quota') || lowerMessage.includes('resource_exhausted')) {
return retrySeconds
? `Gemini 配额已用尽,请等待 ${retrySeconds} 秒后重试,或更换/升级配额。`
: 'Gemini 配额已用尽,请稍后重试或更换/升级配额。'
}
if (statusCode === 503 || lowerMessage.includes('overloaded') || lowerMessage.includes('unavailable')) {
return 'Gemini 模型繁忙,请稍后重试。'
}
if (fallbackMessage.length > 300) {
return `${fallbackMessage.slice(0, 300)}...`
}
return fallbackMessage
}
export function registerAIHandlers({ win }: IpcContext): void {
console.log('[IPC] Registering AI handlers...')
// ==================== AI 对话管理 ====================
/**
* 创建新的 AI 对话
*/
ipcMain.handle(
'ai:createConversation',
async (
_,
title: string,
sessionId?: string,
dataSource?: { type: 'chat' | 'member'; id: string; name?: string }
) => {
try {
return aiConversations.createConversation(title, sessionId, dataSource)
} catch (error) {
console.error('创建 AI 对话失败:', error)
throw error
}
}
)
/**
* 获取所有 AI 对话列表
*/
ipcMain.handle('ai:getConversations', async (_, sessionId?: string) => {
try {
return aiConversations.getConversations(sessionId)
} catch (error) {
console.error('获取 AI 对话列表失败:', 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: '暂无 AI 日志文件' }
}
const logFiles = fs
.readdirSync(logDir)
.filter((name) => name.startsWith('ai_') && name.endsWith('.log'))
if (logFiles.length === 0) {
return { success: false, error: '暂无 AI 日志文件' }
}
// 选择最近修改的日志文件
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('打开 AI 日志文件失败:', error)
return { success: false, error: String(error) }
}
})
/**
* 获取单个对话详情
*/
ipcMain.handle('ai:getConversation', async (_, conversationId: string) => {
try {
return aiConversations.getConversation(conversationId)
} catch (error) {
console.error('获取 AI 对话详情失败:', error)
return null
}
})
/**
* 更新 AI 对话标题
*/
ipcMain.handle('ai:updateConversationTitle', async (_, conversationId: string, title: string) => {
try {
return aiConversations.updateConversationTitle(conversationId, title)
} catch (error) {
console.error('更新 AI 对话标题失败:', error)
return false
}
})
/**
* 删除 AI 对话
*/
ipcMain.handle('ai:deleteConversation', async (_, conversationId: string) => {
try {
return aiConversations.deleteConversation(conversationId)
} catch (error) {
console.error('删除 AI 对话失败:', 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('添加 AI 消息失败:', error)
throw error
}
}
)
/**
* 获取 AI 对话的所有消息
*/
ipcMain.handle('ai:getMessages', async (_, conversationId: string) => {
try {
return aiConversations.getMessages(conversationId)
} catch (error) {
console.error('获取 AI 消息失败:', error)
return []
}
})
/**
* 删除 AI 消息
*/
ipcMain.handle('ai:deleteMessage', async (_, messageId: string) => {
try {
return aiConversations.deleteMessage(messageId)
} catch (error) {
console.error('删除 AI 消息失败:', error)
return false
}
})
// ==================== 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,
apiKey: c.apiKey ? `${c.apiKey.slice(0, 4)}****${c.apiKey.slice(-4)}` : '',
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
}
) => {
try {
const result = llm.addConfig(config)
if (result.success && result.config) {
return {
success: true,
config: {
...result.config,
apiKey: result.config.apiKey
? `${result.config.apiKey.slice(0, 4)}****${result.config.apiKey.slice(-4)}`
: '',
apiKeySet: !!result.config.apiKey,
},
}
}
return result
} catch (error) {
console.error('添加 LLM 配置失败:', 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
}
) => {
try {
// 如果 apiKey 为空字符串,表示不更新 API Key
const cleanUpdates = { ...updates }
if (cleanUpdates.apiKey === '') {
delete cleanUpdates.apiKey
}
return llm.updateConfig(id, cleanUpdates)
} catch (error) {
console.error('更新 LLM 配置失败:', 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: '没有激活的配置' }
}
return llm.deleteConfig(id)
} catch (error) {
console.error('删除 LLM 配置失败:', error)
return { success: false, error: String(error) }
}
})
/**
* 设置激活的配置
*/
ipcMain.handle('llm:setActiveConfig', async (_, id: string) => {
try {
return llm.setActiveConfig(id)
} catch (error) {
console.error('设置激活配置失败:', 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) => {
console.log('[LLM:validateApiKey] 开始验证:', { provider, baseUrl, model, apiKeyLength: apiKey?.length })
try {
const service = llm.createLLMService({ provider, apiKey, baseUrl, model })
const result = await service.validateApiKey()
console.log('[LLM:validateApiKey] 验证结果:', result)
return { success: result.success, error: result.error }
} catch (error) {
console.error('[LLM:validateApiKey] 验证失败:', error)
// 提取有意义的错误信息
const errorMessage = error instanceof Error ? error.message : String(error)
return { success: false, error: errorMessage }
}
}
)
/**
* 检查是否已配置 LLM(是否有激活的配置)
*/
ipcMain.handle('llm:hasConfig', async () => {
return llm.hasActiveConfig()
})
/**
* 发送 LLM 聊天请求(非流式)
*/
ipcMain.handle('llm:chat', async (_, messages: llm.ChatMessage[], options?: llm.ChatOptions) => {
aiLogger.info('IPC', '收到非流式 LLM 请求', {
messagesCount: messages.length,
firstMsgRole: messages[0]?.role,
firstMsgContentLen: messages[0]?.content?.length,
options,
})
try {
const response = await llm.chat(messages, options)
aiLogger.info('IPC', '非流式 LLM 请求成功', { responseLength: response.length })
return { success: true, content: response }
} catch (error) {
aiLogger.error('IPC', '非流式 LLM 请求失败', { error: String(error) })
console.error('LLM 聊天失败:', error)
return { success: false, error: String(error) }
}
})
/**
* 发送 LLM 聊天请求(流式)
* 使用 IPC 事件发送流式数据
*/
ipcMain.handle(
'llm:chatStream',
async (_, requestId: string, messages: llm.ChatMessage[], options?: llm.ChatOptions) => {
aiLogger.info('IPC', `收到流式聊天请求: ${requestId}`, {
messagesCount: messages.length,
options,
})
try {
const generator = llm.chatStream(messages, options)
aiLogger.info('IPC', `创建流式生成器: ${requestId}`)
// 异步处理流式响应
;(async () => {
let chunkIndex = 0
try {
aiLogger.info('IPC', `开始迭代流式响应: ${requestId}`)
for await (const chunk of generator) {
chunkIndex++
aiLogger.debug('IPC', `发送 chunk #${chunkIndex}: ${requestId}`, {
contentLength: chunk.content?.length,
isFinished: chunk.isFinished,
finishReason: chunk.finishReason,
})
win.webContents.send('llm:streamChunk', { requestId, chunk })
}
aiLogger.info('IPC', `流式响应完成: ${requestId}`, { totalChunks: chunkIndex })
} catch (error) {
aiLogger.error('IPC', `流式响应出错: ${requestId}`, {
error: String(error),
chunkIndex,
})
win.webContents.send('llm:streamChunk', {
requestId,
chunk: { content: '', isFinished: true, finishReason: 'error' },
error: String(error),
})
}
})()
return { success: true }
} catch (error) {
aiLogger.error('IPC', `创建流式请求失败: ${requestId}`, { error: String(error) })
console.error('LLM 流式聊天失败:', error)
return { success: false, error: String(error) }
}
}
)
// ==================== AI Agent API ====================
/**
* 执行 Agent 对话(流式)
* Agent 会自动调用工具并返回最终结果
* @param historyMessages 对话历史(可选,用于上下文关联)
* @param chatType 聊天类型('group' | 'private'
* @param promptConfig 用户自定义提示词配置(可选)
* @param locale 语言设置(可选,默认 'zh-CN'
*/
ipcMain.handle(
'agent:runStream',
async (
_,
requestId: string,
userMessage: string,
context: ToolContext,
historyMessages?: Array<{ role: 'user' | 'assistant'; content: string }>,
chatType?: 'group' | 'private',
promptConfig?: PromptConfig,
locale?: string
) => {
aiLogger.info('IPC', `收到 Agent 流式请求: ${requestId}`, {
userMessage: userMessage.slice(0, 100),
sessionId: context.sessionId,
historyLength: historyMessages?.length ?? 0,
chatType: chatType ?? 'group',
hasPromptConfig: !!promptConfig,
})
try {
// 创建 AbortController 并存储
const abortController = new AbortController()
activeAgentRequests.set(requestId, abortController)
// 转换历史消息格式
const formattedHistory =
historyMessages?.map((msg) => ({
role: msg.role as 'user' | 'assistant',
content: msg.content,
})) ?? []
const agent = new Agent(
context,
{ abortSignal: abortController.signal },
formattedHistory,
chatType ?? 'group',
promptConfig,
locale ?? 'zh-CN'
)
// 异步执行,通过事件发送流式数据
;(async () => {
try {
const result = await agent.executeStream(userMessage, (chunk: AgentStreamChunk) => {
// 如果已中止,不再发送
if (abortController.signal.aborted) {
return
}
// 减少日志噪音:只在工具调用时记录
if (chunk.type === 'tool_start' || chunk.type === 'tool_result') {
aiLogger.debug('IPC', `Agent chunk: ${requestId}`, {
type: chunk.type,
toolName: chunk.toolName,
})
}
win.webContents.send('agent:streamChunk', { requestId, chunk })
})
// 如果已中止,不发送完成信息
if (abortController.signal.aborted) {
aiLogger.info('IPC', `Agent 已中止,跳过完成信息: ${requestId}`)
return
}
// 发送完成信息
win.webContents.send('agent:complete', {
requestId,
result: {
content: result.content,
toolsUsed: result.toolsUsed,
toolRounds: result.toolRounds,
totalUsage: result.totalUsage,
},
})
aiLogger.info('IPC', `Agent 执行完成: ${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 请求已中止: ${requestId}`)
return
}
const friendlyError = formatAIError(error)
aiLogger.error('IPC', `Agent 执行出错: ${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', `创建 Agent 请求失败: ${requestId}`, { error: String(error) })
return { success: false, error: String(error) }
}
}
)
/**
* 中止 Agent 请求
*/
ipcMain.handle('agent:abort', async (_, requestId: string) => {
aiLogger.info('IPC', `收到中止请求: ${requestId}`)
const abortController = activeAgentRequests.get(requestId)
if (abortController) {
abortController.abort()
activeAgentRequests.delete(requestId)
aiLogger.info('IPC', `已中止 Agent 请求: ${requestId}`)
return { success: true }
} else {
aiLogger.warn('IPC', `未找到 Agent 请求: ${requestId}`)
return { success: false, 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', '获取 Embedding 配置失败', error)
return []
}
})
/**
* 获取单个 Embedding 配置(用于编辑,包含完整信息)
*/
ipcMain.handle('embedding:getConfig', async (_, id: string) => {
try {
return rag.getEmbeddingConfigById(id)
} catch (error) {
aiLogger.error('IPC', '获取 Embedding 配置失败', 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', '添加 Embedding 配置', { name: config.name, model: config.model })
const result = rag.addEmbeddingConfig(config)
if (result.success) {
await rag.resetEmbeddingService()
}
return result
} catch (error) {
aiLogger.error('IPC', '添加 Embedding 配置失败', 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', '更新 Embedding 配置', { id })
const result = rag.updateEmbeddingConfig(id, updates)
if (result.success) {
await rag.resetEmbeddingService()
}
return result
} catch (error) {
aiLogger.error('IPC', '更新 Embedding 配置失败', error)
return { success: false, error: String(error) }
}
}
)
/**
* 删除 Embedding 配置
*/
ipcMain.handle('embedding:deleteConfig', async (_, id: string) => {
try {
aiLogger.info('IPC', '删除 Embedding 配置', { id })
const result = rag.deleteEmbeddingConfig(id)
if (result.success) {
await rag.resetEmbeddingService()
}
return result
} catch (error) {
aiLogger.error('IPC', '删除 Embedding 配置失败', error)
return { success: false, error: String(error) }
}
})
/**
* 设置激活的 Embedding 配置
*/
ipcMain.handle('embedding:setActiveConfig', async (_, id: string) => {
try {
aiLogger.info('IPC', '设置激活 Embedding 配置', { id })
const result = rag.setActiveEmbeddingConfig(id)
if (result.success) {
await rag.resetEmbeddingService()
}
return result
} catch (error) {
aiLogger.error('IPC', '设置激活 Embedding 配置失败', 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('获取向量存储统计失败:', 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: '向量存储未启用' }
} catch (error) {
console.error('清空向量存储失败:', error)
return { success: false, error: String(error) }
}
})
}