diff --git a/electron/main/ai/agent.ts b/electron/main/ai/agent.ts index 56b429e..d508ecf 100644 --- a/electron/main/ai/agent.ts +++ b/electron/main/ai/agent.ts @@ -8,6 +8,64 @@ import { chatStream, chat } from './llm' import { getAllToolDefinitions, executeToolCalls } from './tools' import type { ToolContext } from './tools/types' import { aiLogger } from './logger' +import { randomUUID } from 'crypto' + +// ==================== Fallback 解析器 ==================== + +/** + * 从文本内容中提取 标签内容 + */ +function extractThinkingContent(content: string): { thinking: string; cleanContent: string } { + const thinkRegex = /([\s\S]*?)<\/think>/gi + let thinking = '' + let cleanContent = content + + const matches = content.matchAll(thinkRegex) + for (const match of matches) { + thinking += match[1].trim() + '\n' + cleanContent = cleanContent.replace(match[0], '') + } + + return { thinking: thinking.trim(), cleanContent: cleanContent.trim() } +} + +/** + * 从文本内容中解析 标签并转换为标准 ToolCall 格式 + */ +function parseToolCallTags(content: string): ToolCall[] | null { + const toolCallRegex = /\s*([\s\S]*?)\s*<\/tool_call>/gi + const toolCalls: ToolCall[] = [] + + const matches = content.matchAll(toolCallRegex) + for (const match of matches) { + try { + const jsonStr = match[1].trim() + const parsed = JSON.parse(jsonStr) + + if (parsed.name && parsed.arguments) { + toolCalls.push({ + id: `fallback-${randomUUID()}`, + type: 'function', + function: { + name: parsed.name, + arguments: typeof parsed.arguments === 'string' ? parsed.arguments : JSON.stringify(parsed.arguments), + }, + }) + } + } catch (e) { + aiLogger.warn('Agent', 'Failed to parse tool_call tag', { content: match[1], error: String(e) }) + } + } + + return toolCalls.length > 0 ? toolCalls : null +} + +/** + * 检测内容是否包含工具调用标签(用于判断是否需要 fallback 解析) + */ +function hasToolCallTags(content: string): boolean { + return //i.test(content) +} /** * Agent 配置 @@ -138,21 +196,56 @@ export class Agent { contentLength: response.content?.length, }) - // 如果是普通文本响应,完成 + let toolCallsToProcess = response.tool_calls + + // 如果没有标准 tool_calls,尝试 fallback 解析 if (response.finishReason !== 'tool_calls' || !response.tool_calls) { - aiLogger.info('Agent', '执行完成', { - toolsUsed: this.toolsUsed, - toolRounds: this.toolRounds, - }) - return { - content: response.content, - toolsUsed: this.toolsUsed, - toolRounds: this.toolRounds, + // Fallback: 检查内容中是否有 标签 + if (hasToolCallTags(response.content)) { + aiLogger.info('Agent', '检测到 标签,执行 fallback 解析') + + // 提取 thinking 内容 + const { thinking, cleanContent } = extractThinkingContent(response.content) + if (thinking) { + aiLogger.info('Agent', '提取到 thinking 内容', { length: thinking.length }) + } + + // 解析 tool_call 标签 + const fallbackToolCalls = parseToolCallTags(response.content) + if (fallbackToolCalls && fallbackToolCalls.length > 0) { + aiLogger.info('Agent', 'Fallback 解析成功', { + toolCount: fallbackToolCalls.length, + tools: fallbackToolCalls.map((tc) => tc.function.name), + }) + toolCallsToProcess = fallbackToolCalls + } else { + // 解析失败,返回清理后的内容 + aiLogger.info('Agent', '执行完成', { + toolsUsed: this.toolsUsed, + toolRounds: this.toolRounds, + }) + return { + content: cleanContent, + toolsUsed: this.toolsUsed, + toolRounds: this.toolRounds, + } + } + } else { + // 没有 tool_call 标签,正常完成 + aiLogger.info('Agent', '执行完成', { + toolsUsed: this.toolsUsed, + toolRounds: this.toolRounds, + }) + return { + content: response.content, + toolsUsed: this.toolsUsed, + toolRounds: this.toolRounds, + } } } // 处理工具调用 - await this.handleToolCalls(response.tool_calls) + await this.handleToolCalls(toolCallsToProcess!) this.toolRounds++ } @@ -196,7 +289,9 @@ export class Agent { // 执行循环 while (this.toolRounds < this.config.maxToolRounds!) { let accumulatedContent = '' + let displayedContent = '' // 已发送给前端的内容 let toolCalls: ToolCall[] | undefined + let isBufferingToolCall = false // 是否正在缓冲 tool_call 内容 // 流式调用 LLM for await (const chunk of chatStream(this.messages, { @@ -205,7 +300,32 @@ export class Agent { })) { if (chunk.content) { accumulatedContent += chunk.content - onChunk({ type: 'content', content: chunk.content }) + + // 检测是否开始出现 标签 + // 一旦检测到,停止向前端发送后续内容 + if (!isBufferingToolCall) { + // 检查累积内容中是否有开始标签 + if (//i.test(accumulatedContent) || //i.test(accumulatedContent)) { + isBufferingToolCall = true + // 发送标签之前的内容(如果有) + const tagStart = Math.min( + accumulatedContent.indexOf('') >= 0 ? accumulatedContent.indexOf('') : Infinity, + accumulatedContent.indexOf('') >= 0 ? accumulatedContent.indexOf('') : Infinity + ) + if (tagStart > displayedContent.length) { + const newContent = accumulatedContent.slice(displayedContent.length, tagStart) + if (newContent) { + onChunk({ type: 'content', content: newContent }) + displayedContent = accumulatedContent.slice(0, tagStart) + } + } + } else { + // 正常发送内容 + onChunk({ type: 'content', content: chunk.content }) + displayedContent = accumulatedContent + } + } + // 如果已经在缓冲模式,不发送内容 } if (chunk.tool_calls) { @@ -213,20 +333,58 @@ export class Agent { } if (chunk.isFinished) { - // 如果是普通文本响应,完成 + // 如果没有标准 tool_calls,尝试 fallback 解析 if (chunk.finishReason !== 'tool_calls' || !toolCalls) { - finalContent = accumulatedContent - onChunk({ type: 'done', isFinished: true }) + // Fallback: 检查内容中是否有 标签 + if (hasToolCallTags(accumulatedContent)) { + aiLogger.info('Agent', '检测到 标签,执行 fallback 解析') - aiLogger.info('Agent', '流式执行完成', { - toolsUsed: this.toolsUsed, - toolRounds: this.toolRounds, - }) + // 提取 thinking 内容 + const { thinking, cleanContent } = extractThinkingContent(accumulatedContent) + if (thinking) { + aiLogger.info('Agent', '提取到 thinking 内容', { length: thinking.length }) + } - return { - content: finalContent, - toolsUsed: this.toolsUsed, - toolRounds: this.toolRounds, + // 解析 tool_call 标签 + const fallbackToolCalls = parseToolCallTags(accumulatedContent) + if (fallbackToolCalls && fallbackToolCalls.length > 0) { + aiLogger.info('Agent', 'Fallback 解析成功', { + toolCount: fallbackToolCalls.length, + tools: fallbackToolCalls.map((tc) => tc.function.name), + }) + toolCalls = fallbackToolCalls + // 更新累积内容为清理后的内容(移除 think 和 tool_call 标签) + accumulatedContent = cleanContent.replace(/[\s\S]*?<\/tool_call>/gi, '').trim() + // 不返回,继续执行工具调用 + } else { + // 解析失败,作为普通响应处理(发送清理后的内容) + const remainingContent = cleanContent.slice(displayedContent.length) + if (remainingContent) { + onChunk({ type: 'content', content: remainingContent }) + } + finalContent = cleanContent + onChunk({ type: 'done', isFinished: true }) + return { + content: finalContent, + toolsUsed: this.toolsUsed, + toolRounds: this.toolRounds, + } + } + } else { + // 没有 tool_call 标签,正常完成 + finalContent = accumulatedContent + onChunk({ type: 'done', isFinished: true }) + + aiLogger.info('Agent', '流式执行完成', { + toolsUsed: this.toolsUsed, + toolRounds: this.toolRounds, + }) + + return { + content: finalContent, + toolsUsed: this.toolsUsed, + toolRounds: this.toolRounds, + } } } } diff --git a/electron/main/ai/llm/index.ts b/electron/main/ai/llm/index.ts index dbb85ac..7f980eb 100644 --- a/electron/main/ai/llm/index.ts +++ b/electron/main/ai/llm/index.ts @@ -1,21 +1,34 @@ /** * LLM 服务模块入口 - * 提供统一的 LLM 服务管理 + * 提供统一的 LLM 服务管理(支持多配置) */ import * as fs from 'fs' import * as path from 'path' import { app } from 'electron' -import type { LLMConfig, LLMProvider, ILLMService, ProviderInfo, ChatMessage, ChatOptions, ChatStreamChunk } from './types' +import { randomUUID } from 'crypto' +import type { + LLMConfig, + LLMProvider, + ILLMService, + ProviderInfo, + ChatMessage, + ChatOptions, + ChatStreamChunk, + AIServiceConfig, + AIConfigStore, +} from './types' +import { MAX_CONFIG_COUNT } from './types' import { DeepSeekService, DEEPSEEK_INFO } from './deepseek' import { QwenService, QWEN_INFO } from './qwen' +import { OpenAICompatibleService, OPENAI_COMPATIBLE_INFO } from './openai-compatible' import { aiLogger } from '../logger' // 导出类型 export * from './types' // 所有支持的提供商信息 -export const PROVIDERS: ProviderInfo[] = [DEEPSEEK_INFO, QWEN_INFO] +export const PROVIDERS: ProviderInfo[] = [DEEPSEEK_INFO, QWEN_INFO, OPENAI_COMPATIBLE_INFO] // 配置文件路径 let CONFIG_PATH: string | null = null @@ -33,10 +46,9 @@ function getConfigPath(): string { return CONFIG_PATH } -/** - * LLM 配置管理 - */ -export interface StoredConfig { +// ==================== 旧配置格式(用于迁移)==================== + +interface LegacyStoredConfig { provider: LLMProvider apiKey: string model?: string @@ -44,9 +56,70 @@ export interface StoredConfig { } /** - * 保存 LLM 配置 + * 检测是否为旧格式配置 */ -export function saveLLMConfig(config: StoredConfig): void { +function isLegacyConfig(data: unknown): data is LegacyStoredConfig { + if (!data || typeof data !== 'object') return false + const obj = data as Record + return 'provider' in obj && 'apiKey' in obj && !('configs' in obj) +} + +/** + * 迁移旧配置到新格式 + */ +function migrateLegacyConfig(legacy: LegacyStoredConfig): AIConfigStore { + const now = Date.now() + const newConfig: AIServiceConfig = { + id: randomUUID(), + name: getProviderInfo(legacy.provider)?.name || legacy.provider, + provider: legacy.provider, + apiKey: legacy.apiKey, + model: legacy.model, + maxTokens: legacy.maxTokens, + createdAt: now, + updatedAt: now, + } + + return { + configs: [newConfig], + activeConfigId: newConfig.id, + } +} + +// ==================== 多配置管理 ==================== + +/** + * 加载配置存储(自动处理迁移) + */ +export function loadConfigStore(): AIConfigStore { + const configPath = getConfigPath() + + if (!fs.existsSync(configPath)) { + return { configs: [], activeConfigId: null } + } + + try { + const content = fs.readFileSync(configPath, 'utf-8') + const data = JSON.parse(content) + + // 检查是否需要迁移 + if (isLegacyConfig(data)) { + aiLogger.info('LLM', '检测到旧配置格式,执行迁移') + const migrated = migrateLegacyConfig(data) + saveConfigStore(migrated) + return migrated + } + + return data as AIConfigStore + } catch { + return { configs: [], activeConfigId: null } + } +} + +/** + * 保存配置存储 + */ +export function saveConfigStore(store: AIConfigStore): void { const configPath = getConfigPath() const dir = path.dirname(configPath) @@ -54,55 +127,204 @@ export function saveLLMConfig(config: StoredConfig): void { fs.mkdirSync(dir, { recursive: true }) } - fs.writeFileSync(configPath, JSON.stringify(config, null, 2), 'utf-8') + fs.writeFileSync(configPath, JSON.stringify(store, null, 2), 'utf-8') } /** - * 加载 LLM 配置 + * 获取所有配置列表 */ -export function loadLLMConfig(): StoredConfig | null { - const configPath = getConfigPath() +export function getAllConfigs(): AIServiceConfig[] { + return loadConfigStore().configs +} - if (!fs.existsSync(configPath)) { - return null +/** + * 获取当前激活的配置 + */ +export function getActiveConfig(): AIServiceConfig | null { + const store = loadConfigStore() + if (!store.activeConfigId) return null + return store.configs.find((c) => c.id === store.activeConfigId) || null +} + +/** + * 获取单个配置 + */ +export function getConfigById(id: string): AIServiceConfig | null { + const store = loadConfigStore() + return store.configs.find((c) => c.id === id) || null +} + +/** + * 添加新配置 + */ +export function addConfig(config: Omit): { + success: boolean + config?: AIServiceConfig + error?: string +} { + const store = loadConfigStore() + + if (store.configs.length >= MAX_CONFIG_COUNT) { + return { success: false, error: `最多只能添加 ${MAX_CONFIG_COUNT} 个配置` } } - try { - const content = fs.readFileSync(configPath, 'utf-8') - return JSON.parse(content) as StoredConfig - } catch { - return null + const now = Date.now() + const newConfig: AIServiceConfig = { + ...config, + id: randomUUID(), + createdAt: now, + updatedAt: now, + } + + store.configs.push(newConfig) + + // 如果是第一个配置,自动设为激活 + if (store.configs.length === 1) { + store.activeConfigId = newConfig.id + } + + saveConfigStore(store) + return { success: true, config: newConfig } +} + +/** + * 更新配置 + */ +export function updateConfig( + id: string, + updates: Partial> +): { success: boolean; error?: string } { + const store = loadConfigStore() + const index = store.configs.findIndex((c) => c.id === id) + + if (index === -1) { + return { success: false, error: '配置不存在' } + } + + store.configs[index] = { + ...store.configs[index], + ...updates, + updatedAt: Date.now(), + } + + saveConfigStore(store) + return { success: true } +} + +/** + * 删除配置 + */ +export function deleteConfig(id: string): { success: boolean; error?: string } { + const store = loadConfigStore() + const index = store.configs.findIndex((c) => c.id === id) + + if (index === -1) { + return { success: false, error: '配置不存在' } + } + + store.configs.splice(index, 1) + + // 如果删除的是当前激活的配置,选择第一个作为新的激活配置 + if (store.activeConfigId === id) { + store.activeConfigId = store.configs.length > 0 ? store.configs[0].id : null + } + + saveConfigStore(store) + return { success: true } +} + +/** + * 设置激活的配置 + */ +export function setActiveConfig(id: string): { success: boolean; error?: string } { + const store = loadConfigStore() + const config = store.configs.find((c) => c.id === id) + + if (!config) { + return { success: false, error: '配置不存在' } + } + + store.activeConfigId = id + saveConfigStore(store) + return { success: true } +} + +/** + * 检查是否有激活的配置 + */ +export function hasActiveConfig(): boolean { + const config = getActiveConfig() + return config !== null +} + +// ==================== 兼容旧 API(deprecated)==================== + +/** + * @deprecated 使用 loadConfigStore 代替 + */ +export function loadLLMConfig(): LegacyStoredConfig | null { + const activeConfig = getActiveConfig() + if (!activeConfig) return null + return { + provider: activeConfig.provider, + apiKey: activeConfig.apiKey, + model: activeConfig.model, + maxTokens: activeConfig.maxTokens, } } /** - * 删除 LLM 配置 + * @deprecated 使用 addConfig 或 updateConfig 代替 + */ +export function saveLLMConfig(config: LegacyStoredConfig): void { + const store = loadConfigStore() + + // 如果有激活配置,更新它;否则创建新的 + if (store.activeConfigId) { + updateConfig(store.activeConfigId, config) + } else { + addConfig({ + name: getProviderInfo(config.provider)?.name || config.provider, + ...config, + }) + } +} + +/** + * @deprecated 使用 deleteConfig 代替 */ export function deleteLLMConfig(): void { - const configPath = getConfigPath() - - if (fs.existsSync(configPath)) { - fs.unlinkSync(configPath) + const store = loadConfigStore() + if (store.activeConfigId) { + deleteConfig(store.activeConfigId) } } /** - * 检查是否已配置 LLM + * @deprecated 使用 hasActiveConfig 代替 */ export function hasLLMConfig(): boolean { - const config = loadLLMConfig() - return config !== null && !!config.apiKey + return hasActiveConfig() +} + +/** + * 扩展的 LLM 配置(包含本地服务特有选项) + */ +interface ExtendedLLMConfig extends LLMConfig { + disableThinking?: boolean } /** * 创建 LLM 服务实例 */ -export function createLLMService(config: LLMConfig): ILLMService { +export function createLLMService(config: ExtendedLLMConfig): ILLMService { switch (config.provider) { case 'deepseek': return new DeepSeekService(config.apiKey, config.model, config.baseUrl) case 'qwen': return new QwenService(config.apiKey, config.model, config.baseUrl) + case 'openai-compatible': + return new OpenAICompatibleService(config.apiKey, config.model, config.baseUrl, config.disableThinking) default: throw new Error(`Unknown LLM provider: ${config.provider}`) } @@ -112,16 +334,18 @@ export function createLLMService(config: LLMConfig): ILLMService { * 获取当前配置的 LLM 服务实例 */ export function getCurrentLLMService(): ILLMService | null { - const config = loadLLMConfig() - if (!config || !config.apiKey) { + const activeConfig = getActiveConfig() + if (!activeConfig) { return null } return createLLMService({ - provider: config.provider, - apiKey: config.apiKey, - model: config.model, - maxTokens: config.maxTokens, + provider: activeConfig.provider, + apiKey: activeConfig.apiKey, + model: activeConfig.model, + baseUrl: activeConfig.baseUrl, + maxTokens: activeConfig.maxTokens, + disableThinking: activeConfig.disableThinking, }) } @@ -217,4 +441,3 @@ export async function* chatStream(messages: ChatMessage[], options?: ChatOptions throw error } } - diff --git a/electron/main/ai/llm/openai-compatible.ts b/electron/main/ai/llm/openai-compatible.ts new file mode 100644 index 0000000..f6cc465 --- /dev/null +++ b/electron/main/ai/llm/openai-compatible.ts @@ -0,0 +1,313 @@ +/** + * OpenAI Compatible LLM Provider + * 支持任何兼容 OpenAI API 格式的服务(如 Ollama、LocalAI、vLLM 等) + */ + +import type { + ILLMService, + LLMProvider, + ChatMessage, + ChatOptions, + ChatResponse, + ChatStreamChunk, + ProviderInfo, + ToolCall, +} from './types' + +const DEFAULT_BASE_URL = 'http://localhost:11434/v1' + +export const OPENAI_COMPATIBLE_INFO: ProviderInfo = { + id: 'openai-compatible', + name: 'OpenAI 兼容', + description: '支持任何兼容 OpenAI API 的服务(如 Ollama、LocalAI、vLLM 等)', + defaultBaseUrl: DEFAULT_BASE_URL, + models: [ + { id: 'llama3.2', name: 'Llama 3.2', description: 'Meta Llama 3.2 模型' }, + { id: 'qwen2.5', name: 'Qwen 2.5', description: '通义千问 2.5 模型' }, + { id: 'deepseek-r1', name: 'DeepSeek R1', description: 'DeepSeek R1 推理模型' }, + ], +} + +export class OpenAICompatibleService implements ILLMService { + private apiKey: string + private baseUrl: string + private model: string + private disableThinking: boolean + + constructor(apiKey: string, model?: string, baseUrl?: string, disableThinking?: boolean) { + this.apiKey = apiKey || 'sk-no-key-required' // 本地服务可能不需要 API Key + this.baseUrl = baseUrl || DEFAULT_BASE_URL + this.model = model || 'llama3.2' + this.disableThinking = disableThinking ?? true // 默认禁用思考模式 + } + + getProvider(): LLMProvider { + return 'openai-compatible' + } + + getModels(): string[] { + return OPENAI_COMPATIBLE_INFO.models.map((m) => m.id) + } + + getDefaultModel(): string { + return 'llama3.2' + } + + async chat(messages: ChatMessage[], options?: ChatOptions): Promise { + const requestBody: Record = { + model: this.model, + messages: messages.map((m) => { + const msg: Record = { role: m.role, content: m.content } + if (m.role === 'tool' && m.tool_call_id) { + msg.tool_call_id = m.tool_call_id + } + if (m.role === 'assistant' && m.tool_calls) { + msg.tool_calls = m.tool_calls + } + return msg + }), + temperature: options?.temperature ?? 0.7, + max_tokens: options?.maxTokens ?? 2048, + stream: false, + } + + if (options?.tools && options.tools.length > 0) { + requestBody.tools = options.tools + } + + // 禁用思考模式(用于 Qwen3、DeepSeek-R1 等本地模型) + if (this.disableThinking) { + requestBody.chat_template_kwargs = { enable_thinking: false } + } + + const headers: Record = { + 'Content-Type': 'application/json', + } + + // 只有在有 API Key 时才添加 Authorization header + if (this.apiKey && this.apiKey !== 'sk-no-key-required') { + headers['Authorization'] = `Bearer ${this.apiKey}` + } + + const response = await fetch(`${this.baseUrl}/chat/completions`, { + method: 'POST', + headers, + body: JSON.stringify(requestBody), + }) + + if (!response.ok) { + const error = await response.text() + throw new Error(`OpenAI Compatible API error: ${response.status} - ${error}`) + } + + const data = await response.json() + const choice = data.choices?.[0] + const message = choice?.message + + let finishReason: ChatResponse['finishReason'] = 'error' + if (choice?.finish_reason === 'stop') { + finishReason = 'stop' + } else if (choice?.finish_reason === 'length') { + finishReason = 'length' + } else if (choice?.finish_reason === 'tool_calls') { + finishReason = 'tool_calls' + } + + let toolCalls: ToolCall[] | undefined + if (message?.tool_calls && Array.isArray(message.tool_calls)) { + toolCalls = message.tool_calls.map((tc: Record) => ({ + id: tc.id as string, + type: 'function' as const, + function: { + name: (tc.function as Record)?.name as string, + arguments: (tc.function as Record)?.arguments as string, + }, + })) + } + + return { + content: message?.content || '', + finishReason, + tool_calls: toolCalls, + usage: data.usage + ? { + promptTokens: data.usage.prompt_tokens, + completionTokens: data.usage.completion_tokens, + totalTokens: data.usage.total_tokens, + } + : undefined, + } + } + + async *chatStream(messages: ChatMessage[], options?: ChatOptions): AsyncGenerator { + const requestBody: Record = { + model: this.model, + messages: messages.map((m) => { + const msg: Record = { role: m.role, content: m.content } + if (m.role === 'tool' && m.tool_call_id) { + msg.tool_call_id = m.tool_call_id + } + if (m.role === 'assistant' && m.tool_calls) { + msg.tool_calls = m.tool_calls + } + return msg + }), + temperature: options?.temperature ?? 0.7, + max_tokens: options?.maxTokens ?? 2048, + stream: true, + } + + if (options?.tools && options.tools.length > 0) { + requestBody.tools = options.tools + } + + // 禁用思考模式(用于 Qwen3、DeepSeek-R1 等本地模型) + if (this.disableThinking) { + requestBody.chat_template_kwargs = { enable_thinking: false } + } + + const headers: Record = { + 'Content-Type': 'application/json', + } + + if (this.apiKey && this.apiKey !== 'sk-no-key-required') { + headers['Authorization'] = `Bearer ${this.apiKey}` + } + + const response = await fetch(`${this.baseUrl}/chat/completions`, { + method: 'POST', + headers, + body: JSON.stringify(requestBody), + }) + + if (!response.ok) { + const error = await response.text() + throw new Error(`OpenAI Compatible API error: ${response.status} - ${error}`) + } + + const reader = response.body?.getReader() + if (!reader) { + throw new Error('Failed to get response reader') + } + + const decoder = new TextDecoder() + let buffer = '' + const toolCallsAccumulator: Map = new Map() + + try { + while (true) { + const { done, value } = await reader.read() + if (done) break + + buffer += decoder.decode(value, { stream: true }) + const lines = buffer.split('\n') + buffer = lines.pop() || '' + + for (const line of lines) { + const trimmed = line.trim() + if (!trimmed || !trimmed.startsWith('data: ')) continue + + const data = trimmed.slice(6) + if (data === '[DONE]') { + if (toolCallsAccumulator.size > 0) { + const toolCalls: ToolCall[] = Array.from(toolCallsAccumulator.values()).map((tc) => ({ + id: tc.id, + type: 'function' as const, + function: { + name: tc.name, + arguments: tc.arguments, + }, + })) + yield { content: '', isFinished: true, finishReason: 'tool_calls', tool_calls: toolCalls } + } else { + yield { content: '', isFinished: true, finishReason: 'stop' } + } + return + } + + try { + const parsed = JSON.parse(data) + const delta = parsed.choices?.[0]?.delta + const finishReason = parsed.choices?.[0]?.finish_reason + + if (delta?.content) { + yield { + content: delta.content, + isFinished: false, + } + } + + if (delta?.tool_calls && Array.isArray(delta.tool_calls)) { + for (const tc of delta.tool_calls) { + const index = tc.index ?? 0 + const existing = toolCallsAccumulator.get(index) + if (existing) { + if (tc.function?.arguments) { + existing.arguments += tc.function.arguments + } + } else { + toolCallsAccumulator.set(index, { + id: tc.id || '', + name: tc.function?.name || '', + arguments: tc.function?.arguments || '', + }) + } + } + } + + if (finishReason) { + let reason: ChatStreamChunk['finishReason'] = 'error' + if (finishReason === 'stop') { + reason = 'stop' + } else if (finishReason === 'length') { + reason = 'length' + } else if (finishReason === 'tool_calls') { + reason = 'tool_calls' + } + + if (toolCallsAccumulator.size > 0) { + const toolCalls: ToolCall[] = Array.from(toolCallsAccumulator.values()).map((tc) => ({ + id: tc.id, + type: 'function' as const, + function: { + name: tc.name, + arguments: tc.arguments, + }, + })) + yield { content: '', isFinished: true, finishReason: reason, tool_calls: toolCalls } + } else { + yield { content: '', isFinished: true, finishReason: reason } + } + return + } + } catch { + // 忽略解析错误,继续处理下一行 + } + } + } + } finally { + reader.releaseLock() + } + } + + async validateApiKey(): Promise { + try { + const headers: Record = {} + if (this.apiKey && this.apiKey !== 'sk-no-key-required') { + headers['Authorization'] = `Bearer ${this.apiKey}` + } + + // 尝试调用 models 端点验证连接 + const response = await fetch(`${this.baseUrl}/models`, { + method: 'GET', + headers, + }) + + // 对于本地服务,即使返回 401 也可能是正常的(不需要认证) + // 所以我们主要检查服务是否可达 + return response.ok || response.status === 401 + } catch { + return false + } + } +} diff --git a/electron/main/ai/llm/types.ts b/electron/main/ai/llm/types.ts index 4d4f8b8..1f303dc 100644 --- a/electron/main/ai/llm/types.ts +++ b/electron/main/ai/llm/types.ts @@ -5,7 +5,7 @@ /** * 支持的 LLM 提供商 */ -export type LLMProvider = 'deepseek' | 'qwen' +export type LLMProvider = 'deepseek' | 'qwen' | 'openai-compatible' /** * LLM 配置 @@ -167,3 +167,34 @@ export interface ProviderInfo { }> } +// ==================== 多配置管理相关类型 ==================== + +/** + * 单个 AI 服务配置 + */ +export interface AIServiceConfig { + id: string // UUID + name: string // 用户自定义名称 + provider: LLMProvider + apiKey: string // 可为空(本地 API 场景) + model?: string + baseUrl?: string // 自定义端点 + maxTokens?: number + /** 禁用思考模式(用于本地服务,如 Qwen3、DeepSeek-R1 等) */ + disableThinking?: boolean + createdAt: number // 创建时间戳 + updatedAt: number // 更新时间戳 +} + +/** + * AI 配置存储结构 + */ +export interface AIConfigStore { + configs: AIServiceConfig[] + activeConfigId: string | null +} + +/** + * 最大配置数量限制 + */ +export const MAX_CONFIG_COUNT = 10 diff --git a/electron/main/ipcMain.ts b/electron/main/ipcMain.ts index 74e6ea5..69fdb25 100644 --- a/electron/main/ipcMain.ts +++ b/electron/main/ipcMain.ts @@ -735,7 +735,14 @@ const mainIpcMain = (win: BrowserWindow) => { */ ipcMain.handle( 'ai:searchMessages', - async (_, sessionId: string, keywords: string[], filter?: { startTs?: number; endTs?: number }, limit?: number, offset?: number) => { + async ( + _, + sessionId: string, + keywords: string[], + filter?: { startTs?: number; endTs?: number }, + limit?: number, + offset?: number + ) => { aiLogger.info('IPC', '收到搜索消息请求', { sessionId, keywords, @@ -835,7 +842,14 @@ const mainIpcMain = (win: BrowserWindow) => { */ ipcMain.handle( 'ai:addMessage', - async (_, conversationId: string, role: 'user' | 'assistant', content: string, dataKeywords?: string[], dataMessageCount?: number) => { + async ( + _, + conversationId: string, + role: 'user' | 'assistant', + content: string, + dataKeywords?: string[], + dataMessageCount?: number + ) => { try { return aiConversations.addMessage(conversationId, role, content, dataKeywords, dataMessageCount) } catch (error) { @@ -869,7 +883,7 @@ const mainIpcMain = (win: BrowserWindow) => { } }) - // ==================== LLM 服务 ==================== + // ==================== LLM 服务(多配置管理)==================== /** * 获取所有支持的 LLM 提供商 @@ -879,68 +893,135 @@ const mainIpcMain = (win: BrowserWindow) => { }) /** - * 获取当前 LLM 配置 + * 获取所有配置列表 */ - ipcMain.handle('llm:getConfig', async () => { - const config = llm.loadLLMConfig() - if (!config) return null - // 不返回完整的 API Key,只返回脱敏版本 - return { - provider: config.provider, - apiKey: config.apiKey ? `${config.apiKey.slice(0, 8)}...${config.apiKey.slice(-4)}` : '', - apiKeySet: !!config.apiKey, - model: config.model, - maxTokens: config.maxTokens, - } + 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, + })) }) /** - * 保存 LLM 配置 - * 如果 apiKey 为空但已有配置,保留原有的 apiKey + * 获取当前激活的配置 ID */ - ipcMain.handle('llm:saveConfig', async (_, config: { provider: llm.LLMProvider; apiKey: string; model?: string; maxTokens?: number }) => { - try { - // 如果没有提供新的 API Key,保留原有的 - let apiKeyToSave = config.apiKey - if (!apiKeyToSave || apiKeyToSave.trim() === '') { - const existingConfig = llm.loadLLMConfig() - if (existingConfig?.apiKey) { - apiKeyToSave = existingConfig.apiKey - } else { - return { success: false, error: '请输入 API Key' } - } - } + ipcMain.handle('llm:getActiveConfigId', async () => { + const config = llm.getActiveConfig() + return config?.id || null + }) - llm.saveLLMConfig({ - ...config, - apiKey: apiKeyToSave, - }) - return { success: true } + /** + * 添加新配置 + */ + 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 { + // 兼容旧 API:如果没有传 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) + console.error('删除 LLM 配置失败:', error) return { success: false, error: String(error) } } }) /** - * 删除 LLM 配置 + * 设置激活的配置 */ - ipcMain.handle('llm:deleteConfig', async () => { + ipcMain.handle('llm:setActiveConfig', async (_, id: string) => { try { - llm.deleteLLMConfig() - return true + return llm.setActiveConfig(id) } catch (error) { - console.error('删除 LLM 配置失败:', error) - return false + console.error('设置激活配置失败:', error) + return { success: false, error: String(error) } } }) /** - * 验证 API Key + * 验证 API Key(支持自定义 baseUrl) */ - ipcMain.handle('llm:validateApiKey', async (_, provider: llm.LLMProvider, apiKey: string) => { + ipcMain.handle('llm:validateApiKey', async (_, provider: llm.LLMProvider, apiKey: string, baseUrl?: string) => { try { - return await llm.validateApiKey(provider, apiKey) + const service = llm.createLLMService({ provider, apiKey, baseUrl }) + return await service.validateApiKey() } catch (error) { console.error('验证 API Key 失败:', error) return false @@ -948,12 +1029,69 @@ const mainIpcMain = (win: BrowserWindow) => { }) /** - * 检查是否已配置 LLM + * 检查是否已配置 LLM(是否有激活的配置) */ ipcMain.handle('llm:hasConfig', async () => { - return llm.hasLLMConfig() + return llm.hasActiveConfig() }) + // ==================== 兼容旧 API(deprecated)==================== + + /** + * @deprecated 使用 llm:getAllConfigs 代替 + * 获取当前 LLM 配置 + */ + ipcMain.handle('llm:getConfig', async () => { + const config = llm.getActiveConfig() + if (!config) return null + return { + provider: config.provider, + apiKey: config.apiKey ? `${config.apiKey.slice(0, 8)}...${config.apiKey.slice(-4)}` : '', + apiKeySet: !!config.apiKey, + model: config.model, + baseUrl: config.baseUrl, + maxTokens: config.maxTokens, + } + }) + + /** + * @deprecated 使用 llm:addConfig 或 llm:updateConfig 代替 + * 保存 LLM 配置 + */ + ipcMain.handle( + 'llm:saveConfig', + async ( + _, + config: { provider: llm.LLMProvider; apiKey: string; model?: string; baseUrl?: string; maxTokens?: number } + ) => { + try { + const activeConfig = llm.getActiveConfig() + + if (activeConfig) { + // 更新现有配置 + const updates: Record = { ...config } + if (!config.apiKey || config.apiKey.trim() === '') { + delete updates.apiKey + } + return llm.updateConfig(activeConfig.id, updates) + } else { + // 创建新配置 + if (!config.apiKey || config.apiKey.trim() === '') { + return { success: false, error: '请输入 API Key' } + } + const providerInfo = llm.getProviderInfo(config.provider) + return llm.addConfig({ + name: providerInfo?.name || config.provider, + ...config, + }) + } + } catch (error) { + console.error('保存 LLM 配置失败:', error) + return { success: false, error: String(error) } + } + } + ) + /** * 发送 LLM 聊天请求(非流式) */ @@ -979,51 +1117,54 @@ const mainIpcMain = (win: BrowserWindow) => { * 发送 LLM 聊天请求(流式) * 使用 IPC 事件发送流式数据 */ - ipcMain.handle('llm:chatStream', async (_, requestId: string, messages: llm.ChatMessage[], options?: llm.ChatOptions) => { - aiLogger.info('IPC', `收到流式聊天请求: ${requestId}`, { - messagesCount: messages.length, - options, - }) + 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}`) + 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, + // 异步处理流式响应 + ;(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), }) - 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) } + 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 ==================== @@ -1031,65 +1172,57 @@ const mainIpcMain = (win: BrowserWindow) => { * 执行 Agent 对话(流式) * Agent 会自动调用工具并返回最终结果 */ - ipcMain.handle( - 'agent:runStream', - async ( - _, - requestId: string, - userMessage: string, - context: ToolContext - ) => { - aiLogger.info('IPC', `收到 Agent 流式请求: ${requestId}`, { - userMessage: userMessage.slice(0, 100), - sessionId: context.sessionId, - }) + ipcMain.handle('agent:runStream', async (_, requestId: string, userMessage: string, context: ToolContext) => { + aiLogger.info('IPC', `收到 Agent 流式请求: ${requestId}`, { + userMessage: userMessage.slice(0, 100), + sessionId: context.sessionId, + }) - try { - const agent = new Agent(context) + try { + const agent = new Agent(context) - // 异步执行,通过事件发送流式数据 - ;(async () => { - try { - const result = await agent.executeStream(userMessage, (chunk: AgentStreamChunk) => { - aiLogger.debug('IPC', `Agent chunk: ${requestId}`, { - type: chunk.type, - contentLength: chunk.content?.length, - toolName: chunk.toolName, - }) - win.webContents.send('agent:streamChunk', { requestId, chunk }) + // 异步执行,通过事件发送流式数据 + ;(async () => { + try { + const result = await agent.executeStream(userMessage, (chunk: AgentStreamChunk) => { + aiLogger.debug('IPC', `Agent chunk: ${requestId}`, { + type: chunk.type, + contentLength: chunk.content?.length, + toolName: chunk.toolName, }) + win.webContents.send('agent:streamChunk', { requestId, chunk }) + }) - // 发送完成信息 - win.webContents.send('agent:complete', { - requestId, - result: { - content: result.content, - toolsUsed: result.toolsUsed, - toolRounds: result.toolRounds, - }, - }) - - aiLogger.info('IPC', `Agent 执行完成: ${requestId}`, { + // 发送完成信息 + win.webContents.send('agent:complete', { + requestId, + result: { + content: result.content, toolsUsed: result.toolsUsed, toolRounds: result.toolRounds, - contentLength: result.content.length, - }) - } catch (error) { - aiLogger.error('IPC', `Agent 执行出错: ${requestId}`, { error: String(error) }) - win.webContents.send('agent:streamChunk', { - requestId, - chunk: { type: 'error', error: String(error), isFinished: true }, - }) - } - })() + }, + }) - return { success: true } - } catch (error) { - aiLogger.error('IPC', `创建 Agent 请求失败: ${requestId}`, { error: String(error) }) - return { success: false, error: String(error) } - } + aiLogger.info('IPC', `Agent 执行完成: ${requestId}`, { + toolsUsed: result.toolsUsed, + toolRounds: result.toolRounds, + contentLength: result.content.length, + }) + } catch (error) { + aiLogger.error('IPC', `Agent 执行出错: ${requestId}`, { error: String(error) }) + win.webContents.send('agent:streamChunk', { + requestId, + chunk: { type: 'error', error: String(error), isFinished: true }, + }) + } + })() + + return { success: true } + } catch (error) { + aiLogger.error('IPC', `创建 Agent 请求失败: ${requestId}`, { error: String(error) }) + return { success: false, error: String(error) } } - ) + }) } export default mainIpcMain diff --git a/electron/preload/index.d.ts b/electron/preload/index.d.ts index ef3e8ef..da52f44 100644 --- a/electron/preload/index.d.ts +++ b/electron/preload/index.d.ts @@ -143,11 +143,27 @@ interface LLMProviderInfo { models: Array<{ id: string; name: string; description?: string }> } +// 单个 AI 服务配置(前端显示用,API Key 已脱敏) +interface AIServiceConfigDisplay { + id: string + name: string + provider: string + apiKey: string // 脱敏后的 API Key + apiKeySet: boolean + model?: string + baseUrl?: string + maxTokens?: number + createdAt: number + updatedAt: number +} + +// 兼容旧 API 的配置类型 interface LLMConfig { provider: string apiKey: string apiKeySet: boolean model?: string + baseUrl?: string maxTokens?: number } @@ -168,18 +184,55 @@ interface LLMChatStreamChunk { } interface LlmApi { + // 提供商 getProviders: () => Promise + + // 多配置管理 API + getAllConfigs: () => Promise + getActiveConfigId: () => Promise + addConfig: (config: { + name: string + provider: string + apiKey: string + model?: string + baseUrl?: string + maxTokens?: number + }) => Promise<{ success: boolean; config?: AIServiceConfigDisplay; error?: string }> + updateConfig: ( + id: string, + updates: { + name?: string + provider?: string + apiKey?: string + model?: string + baseUrl?: string + maxTokens?: number + } + ) => Promise<{ success: boolean; error?: string }> + deleteConfig: (id?: string) => Promise<{ success: boolean; error?: string }> + setActiveConfig: (id: string) => Promise<{ success: boolean; error?: string }> + + // 验证和检查 + validateApiKey: (provider: string, apiKey: string, baseUrl?: string) => Promise + hasConfig: () => Promise + + // 兼容旧 API(deprecated) + /** @deprecated 使用 getAllConfigs 代替 */ getConfig: () => Promise + /** @deprecated 使用 addConfig 或 updateConfig 代替 */ saveConfig: (config: { provider: string apiKey: string model?: string + baseUrl?: string maxTokens?: number }) => Promise<{ success: boolean; error?: string }> - deleteConfig: () => Promise - validateApiKey: (provider: string, apiKey: string) => Promise - hasConfig: () => Promise - chat: (messages: LLMChatMessage[], options?: LLMChatOptions) => Promise<{ success: boolean; content?: string; error?: string }> + + // 聊天功能 + chat: ( + messages: LLMChatMessage[], + options?: LLMChatOptions + ) => Promise<{ success: boolean; content?: string; error?: string }> chatStream: ( messages: LLMChatMessage[], options?: LLMChatOptions, @@ -229,4 +282,23 @@ declare global { } } -export { ChatApi, Api, MergeApi, AiApi, LlmApi, AgentApi, SearchMessageResult, AIConversation, AIMessage, LLMProviderInfo, LLMConfig, LLMChatMessage, LLMChatOptions, LLMChatStreamChunk, AgentStreamChunk, AgentResult, ToolContext } +export { + ChatApi, + Api, + MergeApi, + AiApi, + LlmApi, + AgentApi, + SearchMessageResult, + AIConversation, + AIMessage, + LLMProviderInfo, + LLMConfig, + AIServiceConfigDisplay, + LLMChatMessage, + LLMChatOptions, + LLMChatStreamChunk, + AgentStreamChunk, + AgentResult, + ToolContext, +} diff --git a/electron/preload/index.ts b/electron/preload/index.ts index 79a7a64..d761bf1 100644 --- a/electron/preload/index.ts +++ b/electron/preload/index.ts @@ -42,7 +42,14 @@ const api = { } }, receive: (channel: string, func: (...args: unknown[]) => void) => { - const validChannels = ['show-message', 'chat:importProgress', 'merge:parseProgress', 'llm:streamChunk', 'agent:streamChunk', 'agent:complete'] + const validChannels = [ + 'show-message', + 'chat:importProgress', + 'merge:parseProgress', + 'llm:streamChunk', + 'agent:streamChunk', + 'agent:complete', + ] if (validChannels.includes(channel)) { // Deliberately strip event as it includes `sender` ipcRenderer.on(channel, (_event, ...args) => func(...args)) @@ -499,6 +506,20 @@ interface ToolContext { timeFilter?: { startTs: number; endTs: number } } +// AI 服务配置类型(前端用) +interface AIServiceConfigDisplay { + id: string + name: string + provider: string + apiKey: string // 脱敏后的 API Key + apiKeySet: boolean + model?: string + baseUrl?: string + maxTokens?: number + createdAt: number + updatedAt: number +} + const llmApi = { /** * 获取所有支持的 LLM 提供商 @@ -507,7 +528,85 @@ const llmApi = { return ipcRenderer.invoke('llm:getProviders') }, + // ==================== 多配置管理 API ==================== + /** + * 获取所有配置列表 + */ + getAllConfigs: (): Promise => { + return ipcRenderer.invoke('llm:getAllConfigs') + }, + + /** + * 获取当前激活的配置 ID + */ + getActiveConfigId: (): Promise => { + return ipcRenderer.invoke('llm:getActiveConfigId') + }, + + /** + * 添加新配置 + */ + addConfig: (config: { + name: string + provider: string + apiKey: string + model?: string + baseUrl?: string + maxTokens?: number + }): Promise<{ success: boolean; config?: AIServiceConfigDisplay; error?: string }> => { + return ipcRenderer.invoke('llm:addConfig', config) + }, + + /** + * 更新配置 + */ + updateConfig: ( + id: string, + updates: { + name?: string + provider?: string + apiKey?: string + model?: string + baseUrl?: string + maxTokens?: number + } + ): Promise<{ success: boolean; error?: string }> => { + return ipcRenderer.invoke('llm:updateConfig', id, updates) + }, + + /** + * 删除配置 + */ + deleteConfig: (id?: string): Promise<{ success: boolean; error?: string }> => { + return ipcRenderer.invoke('llm:deleteConfig', id) + }, + + /** + * 设置激活的配置 + */ + setActiveConfig: (id: string): Promise<{ success: boolean; error?: string }> => { + return ipcRenderer.invoke('llm:setActiveConfig', id) + }, + + /** + * 验证 API Key(支持自定义 baseUrl) + */ + validateApiKey: (provider: string, apiKey: string, baseUrl?: string): Promise => { + return ipcRenderer.invoke('llm:validateApiKey', provider, apiKey, baseUrl) + }, + + /** + * 检查是否已配置 LLM(是否有激活的配置) + */ + hasConfig: (): Promise => { + return ipcRenderer.invoke('llm:hasConfig') + }, + + // ==================== 兼容旧 API(deprecated)==================== + + /** + * @deprecated 使用 getAllConfigs 代替 * 获取当前 LLM 配置 */ getConfig: (): Promise => { @@ -515,42 +614,26 @@ const llmApi = { }, /** + * @deprecated 使用 addConfig 或 updateConfig 代替 * 保存 LLM 配置 */ saveConfig: (config: { provider: string apiKey: string model?: string + baseUrl?: string maxTokens?: number }): Promise<{ success: boolean; error?: string }> => { return ipcRenderer.invoke('llm:saveConfig', config) }, - /** - * 删除 LLM 配置 - */ - deleteConfig: (): Promise => { - return ipcRenderer.invoke('llm:deleteConfig') - }, - - /** - * 验证 API Key - */ - validateApiKey: (provider: string, apiKey: string): Promise => { - return ipcRenderer.invoke('llm:validateApiKey', provider, apiKey) - }, - - /** - * 检查是否已配置 LLM - */ - hasConfig: (): Promise => { - return ipcRenderer.invoke('llm:hasConfig') - }, - /** * 发送 LLM 聊天请求(非流式) */ - chat: (messages: ChatMessage[], options?: ChatOptions): Promise<{ success: boolean; content?: string; error?: string }> => { + chat: ( + messages: ChatMessage[], + options?: ChatOptions + ): Promise<{ success: boolean; content?: string; error?: string }> => { return ipcRenderer.invoke('llm:chat', messages, options) }, @@ -597,18 +680,21 @@ const llmApi = { ipcRenderer.on('llm:streamChunk', handler) // 发起请求 - ipcRenderer.invoke('llm:chatStream', requestId, messages, options).then((result) => { - console.log('[preload] chatStream invoke 返回:', result) - if (!result.success) { + ipcRenderer + .invoke('llm:chatStream', requestId, messages, options) + .then((result) => { + console.log('[preload] chatStream invoke 返回:', result) + if (!result.success) { + ipcRenderer.removeListener('llm:streamChunk', handler) + resolve(result) + } + // 如果 success,等待流完成(由 handler 处理 resolve) + }) + .catch((error) => { + console.error('[preload] chatStream invoke 错误:', error) ipcRenderer.removeListener('llm:streamChunk', handler) - resolve(result) - } - // 如果 success,等待流完成(由 handler 处理 resolve) - }).catch((error) => { - console.error('[preload] chatStream invoke 错误:', error) - ipcRenderer.removeListener('llm:streamChunk', handler) - resolve({ success: false, error: String(error) }) - }) + resolve({ success: false, error: String(error) }) + }) }) }, } @@ -641,10 +727,7 @@ const agentApi = { } // 监听完成事件 - const completeHandler = ( - _event: Electron.IpcRendererEvent, - data: { requestId: string; result: AgentResult } - ) => { + const completeHandler = (_event: Electron.IpcRendererEvent, data: { requestId: string; result: AgentResult }) => { if (data.requestId === requestId) { console.log('[preload] Agent 完成,requestId:', requestId) ipcRenderer.removeListener('agent:streamChunk', chunkHandler) @@ -657,20 +740,23 @@ const agentApi = { ipcRenderer.on('agent:complete', completeHandler) // 发起请求 - ipcRenderer.invoke('agent:runStream', requestId, userMessage, context).then((result) => { - console.log('[preload] Agent invoke 返回:', result) - if (!result.success) { + ipcRenderer + .invoke('agent:runStream', requestId, userMessage, context) + .then((result) => { + console.log('[preload] Agent invoke 返回:', result) + if (!result.success) { + ipcRenderer.removeListener('agent:streamChunk', chunkHandler) + ipcRenderer.removeListener('agent:complete', completeHandler) + resolve(result) + } + // 如果 success,等待完成(由 completeHandler 处理 resolve) + }) + .catch((error) => { + console.error('[preload] Agent invoke 错误:', error) ipcRenderer.removeListener('agent:streamChunk', chunkHandler) ipcRenderer.removeListener('agent:complete', completeHandler) - resolve(result) - } - // 如果 success,等待完成(由 completeHandler 处理 resolve) - }).catch((error) => { - console.error('[preload] Agent invoke 错误:', error) - ipcRenderer.removeListener('agent:streamChunk', chunkHandler) - ipcRenderer.removeListener('agent:complete', completeHandler) - resolve({ success: false, error: String(error) }) - }) + resolve({ success: false, error: String(error) }) + }) }) }, } diff --git a/src/App.vue b/src/App.vue index f555a63..bde5f5b 100644 --- a/src/App.vue +++ b/src/App.vue @@ -42,7 +42,7 @@ onMounted(async () => { - + diff --git a/src/components/analysis/AITab.vue b/src/components/analysis/AITab.vue index 1cb3858..6ea5330 100644 --- a/src/components/analysis/AITab.vue +++ b/src/components/analysis/AITab.vue @@ -19,6 +19,19 @@ const subTabs = [ ] const activeSubTab = ref('chat-explorer') + +// ChatExplorer 组件引用 +const chatExplorerRef = ref | null>(null) + +// 刷新 AI 配置(供父组件调用) +function refreshAIConfig() { + chatExplorerRef.value?.refreshConfig() +} + +// 暴露方法供父组件调用 +defineExpose({ + refreshAIConfig, +})