Files
ChatLab/apps/desktop/main/ipc/ai.ts
T
digua b12c07dc32 refactor: extract Electron shell to apps/desktop/
Move Electron-specific code (main, preload, shared) and configs
(electron.vite, electron-builder) into apps/desktop/ to prepare
for multi-platform support (Web Browser standalone).

- Create apps/desktop/ with dedicated package.json and tsconfig
- Split Electron deps (electron, electron-updater, better-sqlite3,
  etc.) from shared frontend deps in root package.json
- Declare better-sqlite3 and @node-rs/jieba in packages/node-runtime
- Add stream-json to packages/parser where it belongs
- Update all path aliases (@electron/* → apps/desktop/*) in tsconfig,
  vite configs, and relative imports in preload/main
- Keep slim root tsconfig.node.json as shared base for packages
- Update CI/CD release workflow for new directory layout
- Fix pre-existing type errors (pi-ai Model generic, SQLLabTab
  LLMChatStreamChunk, filter.test.ts empty methods)
2026-05-19 00:31:00 +08:00

1277 lines
41 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 { aiLogger, setDebugMode } from '../ai/logger'
import { serializeError } from '../ai/serialize-error'
import { getLogsDir } from '../paths'
import * as workerManager from '../worker/workerManager'
import { Agent, type AgentStreamChunk, type SkillContext } from '../ai/agent'
import { getDefaultGeneralAssistantId } from '../ai/assistant/defaultGeneral'
import { getDefaultAssistantConfig, buildPiModel, findModelDefinition } from '../ai/llm'
import type { AIServiceConfig } from '../ai/llm/types'
import { countMessagesTokens } from '@openchatlab/node-runtime'
import { stripAvatarFields } from '@openchatlab/core'
import * as assistantManager from '../ai/assistant'
import type { AssistantConfig } from '../ai/assistant/types'
import * as skillManager from '../ai/skills'
import {
checkAndCompress,
manualCompress,
createCompressionLlmAdapter,
type CompressionConfig,
type CompressionLlmAdapter,
completeSimple,
runSimpleLlmStream,
formatAIError,
type PiMessage,
type PiTextContent,
} from '@openchatlab/node-runtime'
import { getManager as getConversationManager } from '../ai/conversations'
import { t } from '../i18n'
import type { ToolContext } from '../ai/tools/types'
import { TOOL_REGISTRY } from '../ai/tools/definitions'
import { getDefaultRulesForLocale, mergeRulesForLocale } from '@openchatlab/node-runtime'
import type { IpcContext } from './types'
const DEFAULT_CONTEXT_WINDOW = 128000
const compressionLogger = {
info: (cat: string, msg: string, extra?: Record<string, unknown>) => aiLogger.info(cat, msg, extra),
warn: (cat: string, msg: string, extra?: Record<string, unknown>) => aiLogger.warn(cat, msg, extra),
error: (cat: string, msg: string, extra?: Record<string, unknown>) => aiLogger.error(cat, msg, extra),
}
function buildCompressionAdapter(activeAIConfig: AIServiceConfig, onCompressing?: () => void): CompressionLlmAdapter {
const modelDef = findModelDefinition(activeAIConfig.provider, activeAIConfig.model || '')
return createCompressionLlmAdapter({
piModel: buildPiModel(activeAIConfig),
apiKey: activeAIConfig.apiKey,
contextWindow: modelDef?.contextWindow ?? DEFAULT_CONTEXT_WINDOW,
onCompressing,
onError: (error) => aiLogger.warn('Compression', 'LLM compression attempt failed', { error: String(error) }),
})
}
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>()
function resolveProviderName(provider?: llm.LLMProvider): string {
if (provider === 'openai-compatible') return t('llm.genericProviderName')
return provider ? llm.getProviderInfo(provider)?.name || provider : t('llm.genericProviderName')
}
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'}`)
})
ipcMain.handle('ai:clearDebugContext', async () => {
try {
const cleared = aiConversations.clearAllDebugContext()
aiLogger.info('Debug', `Cleared debug_context for ${cleared} messages`)
return { success: true, cleared }
} catch (error) {
console.error('Failed to clear debug context:', error)
throw error
}
})
// ==================== 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)
;(async () => {
await runSimpleLlmStream({
messages,
apiKey: activeConfig.apiKey,
piModel,
temperature: options?.temperature,
maxTokens: options?.maxTokens,
onChunk: (chunk) => {
if (chunk.error) {
win.webContents.send('llm:streamChunk', { requestId, error: chunk.error, chunk })
} else {
win.webContents.send('llm:streamChunk', { requestId, chunk })
}
},
})
})()
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 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 {
// 获取助手 systemPrompt 用于 token 计算
const tempAssistantConfig = assistantId
? (assistantManager.getAssistantConfig(assistantId) ?? undefined)
: undefined
const systemPromptForCompression = tempAssistantConfig?.systemPrompt || ''
const compressionResult = await checkAndCompress(
context.conversationId,
compressionConfig,
systemPromptForCompression,
buildCompressionAdapter(activeAIConfig, () => {
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,
},
})
}),
getConversationManager(),
compressionLogger
)
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))
let dataSnapshot: ToolContext['dataSnapshot'] | undefined = context.dataSnapshot
try {
const overview = await workerManager.getChatOverview(context.sessionId, 5)
if (overview) {
dataSnapshot = {
name: overview.name,
platform: overview.platform,
type: overview.type,
totalMessages: overview.totalMessages,
totalMembers: overview.totalMembers,
firstMessageTs: overview.firstMessageTs,
lastMessageTs: overview.lastMessageTs,
capturedAt: Math.floor(Date.now() / 1000),
}
}
} catch (error) {
aiLogger.warn('IPC', `Failed to load agent data snapshot: ${requestId}`, { error: String(error) })
}
const enrichedContext: ToolContext = { ...context, maxToolResultTokens, dataSnapshot }
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, {
providerName: resolveProviderName(activeAIConfig.provider),
rawErrorLabel: t('llm.rawErrorLabel'),
})
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,
buildCompressionAdapter(activeAIConfig),
getConversationManager(),
compressionLogger
)
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) }
}
})
/**
* [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
}
})
}