From df0b2a36d24bec12c19b90bb6e177cc21d0951be Mon Sep 17 00:00:00 2001 From: digua Date: Sat, 2 May 2026 00:01:30 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E4=B8=8A=E4=B8=8B=E6=96=87=E5=8E=8B?= =?UTF-8?q?=E7=BC=A9=E8=A1=8C=E4=B8=BA=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- electron/main/ai/agent/index.ts | 6 +- electron/main/ai/compression/index.ts | 177 ++++++++++---- electron/main/ai/conversations.ts | 75 +++++- electron/main/ai/llm/index.ts | 131 ++++++++--- electron/main/ai/llm/model-types.ts | 8 +- electron/main/ai/llm/types.ts | 21 +- electron/main/ai/rag/embedding/index.ts | 2 +- electron/main/ai/rag/pipeline/semantic.ts | 4 +- electron/main/ai/summary/index.ts | 10 +- electron/main/ipc/ai.ts | 51 ++-- electron/preload/apis/ai.ts | 28 ++- electron/preload/index.d.ts | 14 +- src/components/AIChat/chat/ChatStatusBar.vue | 21 +- .../common/Settings/AI/AIDefaultModelTab.vue | 222 ++++++++++++++++++ .../common/Settings/AI/AIModelConfigTab.vue | 145 +++++------- .../common/Settings/AISettingsTab.vue | 10 + src/i18n/locales/en-US/settings.json | 16 ++ src/i18n/locales/ja-JP/settings.json | 16 ++ src/i18n/locales/zh-CN/settings.json | 16 ++ src/i18n/locales/zh-TW/settings.json | 16 ++ src/stores/aiChat.ts | 6 +- src/stores/llm.ts | 66 +++--- src/stores/prompt.ts | 2 - 23 files changed, 795 insertions(+), 268 deletions(-) create mode 100644 src/components/common/Settings/AI/AIDefaultModelTab.vue diff --git a/electron/main/ai/agent/index.ts b/electron/main/ai/agent/index.ts index d6052afd..21ede61c 100644 --- a/electron/main/ai/agent/index.ts +++ b/electron/main/ai/agent/index.ts @@ -3,7 +3,7 @@ * 编排 PiAgentCore 的对话流程(工具调用、流式输出、中止控制) */ -import { getActiveConfig, buildPiModel } from '../llm' +import { getDefaultAssistantConfig, buildPiModel } from '../llm' import { getAllTools, createActivateSkillTool } from '../tools' import type { ToolContext } from '../tools/types' import { getHistoryForAgent } from '../conversations' @@ -355,7 +355,7 @@ export async function runAgent( assistantConfig?: AssistantConfig, skillCtx?: SkillContext ): Promise { - const activeConfig = getActiveConfig() + const activeConfig = getDefaultAssistantConfig() if (!activeConfig) throw new Error('LLM service not configured') const piModel = buildPiModel(activeConfig) const agent = new Agent(context, piModel, activeConfig.apiKey, config, chatType, locale, assistantConfig, skillCtx) @@ -375,7 +375,7 @@ export async function runAgentStream( assistantConfig?: AssistantConfig, skillCtx?: SkillContext ): Promise { - const activeConfig = getActiveConfig() + const activeConfig = getDefaultAssistantConfig() if (!activeConfig) throw new Error('LLM service not configured') const piModel = buildPiModel(activeConfig) const agent = new Agent(context, piModel, activeConfig.apiKey, config, chatType, locale, assistantConfig, skillCtx) diff --git a/electron/main/ai/compression/index.ts b/electron/main/ai/compression/index.ts index 97321bcc..4ea32233 100644 --- a/electron/main/ai/compression/index.ts +++ b/electron/main/ai/compression/index.ts @@ -17,11 +17,13 @@ import { getAllUserAssistantMessages, addSummaryMessage, getMessageCountAfterSummary, + type ContentBlock, + type AIMessageRole, } from '../conversations' -import { buildPiModel, getActiveConfig, findModelDefinition } from '../llm' +import { buildPiModel, findModelDefinition } from '../llm' import type { AIServiceConfig } from '../llm/types' import { completeSimple, type TextContent as PiTextContent } from '@mariozechner/pi-ai' -import { aiLogger } from '../logger' +import { aiLogger, isDebugMode } from '../logger' // ==================== 类型定义 ==================== @@ -31,8 +33,6 @@ export interface CompressionConfig { tokenThresholdPercent: number /** 保留最近消息的缓冲区大小(相对于 context window 的百分比),默认 20 */ bufferSizePercent: number - /** 独立压缩模型配置(为空则使用当前对话模型) */ - compressionModelConfigId?: string /** 单次工具返回的最大上下文占比(相对于 context window 的百分比),默认 35 */ maxToolResultPercent?: number } @@ -53,19 +53,41 @@ export interface CompressionResult { error?: string } -const DEFAULT_COMPRESSION_PROMPT = `You are a context compression assistant. Compress the conversation below into a structured summary. +const INITIAL_COMPRESSION_PROMPT = `You are a context compression assistant. Compress the conversation below into a structured summary. STRICT RULES: - Output ONLY the summary content. No greetings, no preamble, no meta-commentary, no word/token counts. - Use the same language as the conversation. - Maximum output length: {maxTokens} tokens. Be concise. -- Organize by topic/thread when possible. -- Preserve: key facts, decisions, user preferences, data, names, timestamps, action items. -- Omit: pleasantries, filler, redundant back-and-forth. +- NEVER reproduce any single message verbatim. Always paraphrase and compress. +- Cover ALL topics discussed — no single topic should exceed 30% of the summary. +- Organize by topic/thread, using brief headers (e.g. "## Topic"). +- Preserve: key facts, conclusions, user preferences, data points, names, important timestamps, action items. +- Omit: pleasantries, filler, redundant back-and-forth, detailed tables (summarize their conclusions instead). CONVERSATION: {messages}` +const PROGRESSIVE_COMPRESSION_PROMPT = `You are a context compression assistant performing an INCREMENTAL summary update. + +You will receive: +1. A [PREVIOUS SUMMARY] — this represents the compressed history of earlier conversation. Its content MUST be preserved in your output. +2. [NEW MESSAGES] — recent messages that need to be merged into the summary. + +STRICT RULES: +- Output ONLY the updated summary. No greetings, no preamble, no meta-commentary. +- Use the same language as the conversation. +- Maximum output length: {maxTokens} tokens. Be concise. +- CRITICAL: You MUST retain ALL key points from the previous summary. Do not discard prior context. +- NEVER reproduce any single message verbatim. Always paraphrase and compress. +- Merge new information into appropriate existing topic sections, or add new sections. +- Cover ALL topics — no single topic should exceed 30% of the summary. +- Organize by topic/thread, using brief headers (e.g. "## Topic"). +- Preserve: key facts, conclusions, user preferences, data points, names, important timestamps, action items. +- Omit: pleasantries, filler, redundant back-and-forth, detailed tables (summarize their conclusions instead). + +{messages}` + const DEFAULT_CONTEXT_WINDOW = 128000 // ==================== 核心压缩逻辑 ==================== @@ -89,9 +111,18 @@ export async function checkAndCompress( // 收集当前上下文消息 const summary = getLatestSummary(conversationId) - const messages = summary - ? getMessagesAfterSummary(conversationId, summary.timestamp) - : getAllUserAssistantMessages(conversationId) + + let messages: Array<{ role: AIMessageRole; content: string; timestamp: number }> + if (summary) { + // 从 summary_meta 获取 buffer 边界,取 >= boundary 的消息(buffer + 新消息) + const metaBlock = summary.contentBlocks?.find( + (b): b is Extract => b.type === 'summary_meta' + ) + const boundary = metaBlock?.bufferBoundaryTimestamp ?? summary.timestamp + messages = getMessagesAfterSummary(conversationId, boundary - 1) + } else { + messages = getAllUserAssistantMessages(conversationId) + } // 构建 token 计算的消息列表 const historyForTokenCount: Array<{ role: string; content: string }> = [] @@ -116,43 +147,107 @@ export async function checkAndCompress( // 确定缓冲区(保留最近 N% 的消息) const bufferTokenBudget = Math.floor(contextWindow * (config.bufferSizePercent / 100)) - const { bufferMessages, messagesToCompress } = splitMessagesForCompression(messages, summary, bufferTokenBudget) + const { bufferMessages, messagesToCompress } = splitMessagesForCompression(messages, bufferTokenBudget) - if (messagesToCompress.length === 0) { + aiLogger.info('Compression', `Split result`, { + totalMessages: messages.length, + messagesToCompress: messagesToCompress.length, + bufferMessages: bufferMessages.length, + bufferTokenBudget, + hasPreviousSummary: !!summary, + }) + + // 待压缩消息数过少时跳过,避免生成低质量摘要(仅保留 1-2 条消息时 LLM 倾向于原文照搬) + const MIN_MESSAGES_TO_COMPRESS = 3 + if (messagesToCompress.length < MIN_MESSAGES_TO_COMPRESS) { + aiLogger.info( + 'Compression', + `Skipping: only ${messagesToCompress.length} messages to compress (min: ${MIN_MESSAGES_TO_COMPRESS})` + ) return { compressed: false, reason: 'skipped_below_threshold', tokensBefore: currentTokens } } // 构建压缩输入文本 + const isProgressive = !!summary const compressInput = buildCompressionInput(messagesToCompress, summary) const targetTokens = Math.min(Math.floor(contextWindow * 0.1), 16384) - // 三级降级:独立模型 → 当前模型 → 强制截断 + aiLogger.info('Compression', `Compression params`, { + mode: isProgressive ? 'progressive' : 'initial', + targetTokens, + inputLength: compressInput.length, + inputTokensEstimate: countTokens(compressInput), + }) + + // DEBUG 模式下输出原始消息列表和完整压缩输入 + if (isDebugMode()) { + aiLogger.debug('Compression', 'Messages to compress (raw)', { + messages: messagesToCompress.map((m, i) => ({ + index: i, + role: m.role, + contentLength: m.content.length, + contentPreview: m.content.slice(0, 200), + })), + }) + aiLogger.debug('Compression', 'Buffer messages (kept as-is)', { + messages: bufferMessages.map((m, i) => ({ + index: i, + role: m.role, + contentLength: m.content.length, + contentPreview: m.content.slice(0, 200), + })), + }) + aiLogger.debug('Compression', 'Full compression input sent to LLM', compressInput) + if (summary) { + aiLogger.debug('Compression', 'Previous summary content', summary.content) + } + } + + // 使用默认助手模型压缩,失败则强制截断 let summaryText: string | null = null - // 尝试用配置的压缩模型 - if (config.compressionModelConfigId) { - summaryText = await tryCompress(config.compressionModelConfigId, compressInput, targetTokens) - } + summaryText = await tryCompressWithConfig(activeAIConfig, compressInput, targetTokens, isProgressive) - // 降级到当前模型 - if (!summaryText) { - summaryText = await tryCompressWithConfig(activeAIConfig, compressInput, targetTokens) - } - - // 最终降级:强制截断 if (!summaryText) { aiLogger.warn('Compression', 'LLM compression failed, falling back to truncation') summaryText = forceTruncate(compressInput, targetTokens) + aiLogger.info('Compression', 'Truncation fallback applied', { + outputLength: summaryText.length, + }) + } else { + aiLogger.info('Compression', 'LLM compression succeeded', { + outputLength: summaryText.length, + outputTokensEstimate: countTokens(summaryText), + }) + if (isDebugMode()) { + aiLogger.debug('Compression', 'Generated summary content', summaryText) + } } - // 写入 summary - addSummaryMessage(conversationId, summaryText) + // 写入 summary:时间戳 = NOW(UI 中显示在触发压缩的位置) + // buffer 边界 + 压缩消息数存入 content_blocks 的 summary_meta block + const bufferBoundary = + bufferMessages.length > 0 + ? bufferMessages[0].timestamp + : messagesToCompress[messagesToCompress.length - 1]!.timestamp + 1 - // Thrashing 检查:压缩后重新计算 token - const afterMessages = getMessagesAfterSummary(conversationId, Date.now() / 1000 - 1) + const summaryMeta = { + bufferBoundaryTimestamp: bufferBoundary, + compressedMessageCount: messagesToCompress.length, + } + + aiLogger.info('Compression', 'Writing summary', { + bufferBoundary, + compressedCount: messagesToCompress.length, + bufferCount: bufferMessages.length, + }) + + addSummaryMessage(conversationId, summaryText, summaryMeta) + + // Thrashing 检查:压缩后重新计算 token(summary + buffer 消息) const afterTokenCount: Array<{ role: string; content: string }> = [ { role: 'assistant', content: summaryText }, - ...afterMessages.map((m) => ({ role: m.role, content: m.content })), + ...bufferMessages.map((m) => ({ role: m.role, content: m.content })), ] const tokensAfter = countMessagesTokens(afterTokenCount, systemPrompt) @@ -217,7 +312,6 @@ interface SplitResult { function splitMessagesForCompression( messages: Array<{ role: string; content: string; timestamp: number }>, - summary: { content: string } | null, bufferTokenBudget: number ): SplitResult { let bufferTokens = 0 @@ -249,7 +343,8 @@ function buildCompressionInput( const parts: string[] = [] if (existingSummary) { - parts.push(`[Previous Summary]\n${existingSummary.content}\n`) + parts.push(`[PREVIOUS SUMMARY — MUST PRESERVE]\n${existingSummary.content}\n`) + parts.push(`[NEW MESSAGES — SUMMARIZE AND MERGE]`) } for (const msg of messagesToCompress) { @@ -260,28 +355,16 @@ function buildCompressionInput( return parts.join('\n\n') } -async function tryCompress(configId: string, input: string, targetTokens: number): Promise { - try { - const { getAllConfigs } = await import('../llm') - const allConfigs = getAllConfigs() - const config = allConfigs.find((c) => c.id === configId) - if (!config) return null - - return await tryCompressWithConfig(config, input, targetTokens) - } catch (error) { - aiLogger.warn('Compression', `Compression with config ${configId} failed`, { error: String(error) }) - return null - } -} - async function tryCompressWithConfig( aiConfig: AIServiceConfig, input: string, - targetTokens: number + targetTokens: number, + isProgressive: boolean ): Promise { try { const piModel = buildPiModel(aiConfig) - const prompt = DEFAULT_COMPRESSION_PROMPT.replace('{maxTokens}', String(targetTokens)).replace('{messages}', input) + const template = isProgressive ? PROGRESSIVE_COMPRESSION_PROMPT : INITIAL_COMPRESSION_PROMPT + const prompt = template.replace('{maxTokens}', String(targetTokens)).replace('{messages}', input) const result = await completeSimple( piModel, diff --git a/electron/main/ai/conversations.ts b/electron/main/ai/conversations.ts index 6eaaa330..044c28f5 100644 --- a/electron/main/ai/conversations.ts +++ b/electron/main/ai/conversations.ts @@ -6,6 +6,7 @@ import Database from 'better-sqlite3' import * as path from 'path' import { getAiDataDir, ensureDir } from '../paths' +import { aiLogger } from './logger' const DEFAULT_GENERAL_ID = 'general_cn' @@ -210,7 +211,7 @@ export interface AIConversation { */ export type ContentBlock = | { type: 'text'; text: string } - | { type: 'think'; tag: string; text: string; durationMs?: number } // 思考内容块 + | { type: 'think'; tag: string; text: string; durationMs?: number } | { type: 'tool' tool: { @@ -220,6 +221,11 @@ export type ContentBlock = params?: Record } } + | { + type: 'summary_meta' + bufferBoundaryTimestamp: number + compressedMessageCount: number + } /** * AI 消息类型 @@ -505,11 +511,11 @@ export function getConversationTokenUsage(conversationId: string): TokenUsageDat * 为 Agent 提供对话历史 * * 返回简化的 {role, content} 格式,按时间升序排列。 - * 当存在 summary 消息时,返回最新 summary + summary 之后的 user/assistant 消息, - * 以避免重复加载已被压缩的旧消息。 + * 当存在 summary 消息时,返回:[summary] + [buffer 消息] + [新消息]。 + * Buffer 边界从 summary 的 content_blocks 中的 summary_meta block 获取。 * * @param conversationId 对话 ID - * @param maxMessages 最大返回条数(取最近 N 条,仅对 system 摘要之后的消息生效) + * @param maxMessages 最大返回条数(取最近 N 条,仅对 buffer+新消息生效) */ export function getHistoryForAgent( conversationId: string, @@ -520,19 +526,40 @@ export function getHistoryForAgent( (m) => (m.role === 'user' || m.role === 'assistant' || m.role === 'system') && m.content?.trim() ) - // 查找最新的 system 消息位置(压缩摘要) - let systemIndex = -1 + // 查找最新的 system (summary) 消息 + let systemMsg: AIMessage | undefined for (let i = validMessages.length - 1; i >= 0; i--) { if (validMessages[i].role === 'system') { - systemIndex = i + systemMsg = validMessages[i] break } } let result: Array<{ role: 'user' | 'assistant' | 'system'; content: string }> - if (systemIndex >= 0) { - result = validMessages.slice(systemIndex).map((m) => ({ role: m.role, content: m.content })) + if (systemMsg) { + // 从 content_blocks 中解析 summary_meta 获取 buffer 边界 + const metaBlock = systemMsg.contentBlocks?.find( + (b): b is Extract => b.type === 'summary_meta' + ) + const bufferBoundary = metaBlock?.bufferBoundaryTimestamp + + if (!metaBlock) { + aiLogger.warn('Conversations', 'system message missing summary_meta; agent context will be summary-only', { + conversationId, + messageId: systemMsg.id, + }) + } + + // 取 timestamp >= boundary 的 user/assistant 消息(buffer + 新消息) + const contextMessages = bufferBoundary + ? validMessages.filter((m) => m.role !== 'system' && m.timestamp >= bufferBoundary) + : [] + + result = [ + { role: 'system' as const, content: systemMsg.content }, + ...contextMessages.map((m) => ({ role: m.role, content: m.content })), + ] } else { result = validMessages.map((m) => ({ role: m.role, content: m.content })) } @@ -552,13 +579,28 @@ export function getHistoryForAgent( /** * 添加 system 消息并替换旧的 system(每个对话只保留一条最新压缩摘要) + * + * Summary 时间戳 = NOW(UI 中显示在触发压缩的位置)。 + * Buffer 边界信息存入 content_blocks 的 summary_meta block 中,供 getHistoryForAgent 使用。 */ -export function addSummaryMessage(conversationId: string, content: string): AIMessage { +export function addSummaryMessage( + conversationId: string, + content: string, + meta: { bufferBoundaryTimestamp: number; compressedMessageCount: number } +): AIMessage { const db = getAiDb() db.prepare("DELETE FROM ai_message WHERE conversation_id = ? AND role = 'system'").run(conversationId) - return addMessage(conversationId, 'system', content) + const contentBlocks: ContentBlock[] = [ + { + type: 'summary_meta', + bufferBoundaryTimestamp: meta.bufferBoundaryTimestamp, + compressedMessageCount: meta.compressedMessageCount, + }, + ] + + return addMessage(conversationId, 'system', content, undefined, undefined, contentBlocks) } /** @@ -666,11 +708,18 @@ export function getMessageCountAfterSummary(conversationId: string): number { .get(conversationId) as { count: number } return row.count } + + // 从 summary_meta 获取 buffer 边界,统计 >= boundary 的消息数 + const metaBlock = summary.contentBlocks?.find( + (b): b is Extract => b.type === 'summary_meta' + ) + const boundary = metaBlock?.bufferBoundaryTimestamp ?? summary.timestamp + const db = getAiDb() const row = db .prepare( - "SELECT COUNT(*) as count FROM ai_message WHERE conversation_id = ? AND timestamp > ? AND role IN ('user', 'assistant')" + "SELECT COUNT(*) as count FROM ai_message WHERE conversation_id = ? AND timestamp >= ? AND role IN ('user', 'assistant')" ) - .get(conversationId, summary.timestamp) as { count: number } + .get(conversationId, boundary) as { count: number } return row.count } diff --git a/electron/main/ai/llm/index.ts b/electron/main/ai/llm/index.ts index 6c8a1775..d3668a65 100644 --- a/electron/main/ai/llm/index.ts +++ b/electron/main/ai/llm/index.ts @@ -81,10 +81,6 @@ function providerDefinitionToInfo(def: ProviderDefinition): ProviderInfo { } } -/** - * 所有 provider 信息(兼容旧格式) - * @deprecated 使用 BUILTIN_PROVIDERS 代替 - */ export const PROVIDERS: ProviderInfo[] = BUILTIN_PROVIDERS.map(providerDefinitionToInfo) // 配置文件路径 @@ -127,7 +123,8 @@ function migrateLegacyConfig(legacy: LegacyStoredConfig): AIConfigStore { return { configs: [newConfig], - activeConfigId: newConfig.id, + defaultAssistant: { configId: newConfig.id, modelId: newConfig.model || '' }, + fastModel: null, } } @@ -137,7 +134,7 @@ import { addCustomProvider as _addCustomProviderDirect } from './custom-provider import { addCustomModel as _addCustomModelDirect } from './custom-model-store' import { getBuiltinModelById } from './model-catalog' -const CURRENT_SCHEMA_VERSION = 2 +const CURRENT_SCHEMA_VERSION = 3 /** * MiniMax 等旧 provider 的兼容映射表 @@ -208,6 +205,34 @@ function migrateToSchemaV2(store: AIConfigStore): AIConfigStore { } } +/** + * Schema v2 → v3:activeConfigId → defaultAssistant { configId, modelId } + */ +function migrateToSchemaV3(store: AIConfigStore & { activeConfigId?: string | null }): AIConfigStore { + aiLogger.info('LLM', 'Migrating config store to schema v3 (dual-slot model selection)') + const legacyActiveId = store.activeConfigId ?? null + const resolvedConfig = + legacyActiveId && store.configs.find((c) => c.id === legacyActiveId) + ? store.configs.find((c) => c.id === legacyActiveId)! + : (store.configs[0] ?? null) + + return { + configs: store.configs, + defaultAssistant: resolvedConfig ? { configId: resolvedConfig.id, modelId: resolvedConfig.model || '' } : null, + fastModel: null, + } +} + +/** 解析 ModelSlot:如果 configId 无效,回退到 configs[0] */ +function resolveSlot( + slot: import('./model-types').ModelSlot | null | undefined, + configs: AIServiceConfig[] +): import('./model-types').ModelSlot | null { + if (slot && configs.some((c) => c.id === slot.configId)) return slot + const fallback = configs[0] + return fallback ? { configId: fallback.id, modelId: fallback.model || '' } : null +} + // ==================== 多配置管理 ==================== /** @@ -217,7 +242,7 @@ export function loadConfigStore(): AIConfigStore { const configPath = getConfigPath() if (!fs.existsSync(configPath)) { - return { configs: [], activeConfigId: null } + return { configs: [], defaultAssistant: null, fastModel: null } } try { @@ -231,12 +256,19 @@ export function loadConfigStore(): AIConfigStore { return loadConfigStore() } - let store = data as AIConfigStore & { schemaVersion?: number } + let store = data as AIConfigStore & { schemaVersion?: number; activeConfigId?: string | null } + let needsSchemaSave = false // Schema v1 → v2 迁移 - if (!store.schemaVersion || store.schemaVersion < CURRENT_SCHEMA_VERSION) { - store = { ...migrateToSchemaV2(store), schemaVersion: CURRENT_SCHEMA_VERSION } as typeof store - // 先解密再保存(migrateToSchemaV2 不改 apiKey 格式) + if (!store.schemaVersion || store.schemaVersion < 2) { + store = { ...migrateToSchemaV2(store), schemaVersion: 2 } as typeof store + needsSchemaSave = true + } + + // Schema v2 → v3 迁移 + if (store.schemaVersion < 3) { + store = { ...migrateToSchemaV3(store), schemaVersion: CURRENT_SCHEMA_VERSION } as typeof store + needsSchemaSave = true } let needsEncryptionMigration = false @@ -251,7 +283,7 @@ export function loadConfigStore(): AIConfigStore { } }) - if (needsEncryptionMigration || (!data.schemaVersion && store.schemaVersion)) { + if (needsEncryptionMigration || needsSchemaSave) { aiLogger.info('LLM', 'Saving migrated config store') saveConfigStoreRaw({ ...store, @@ -268,7 +300,7 @@ export function loadConfigStore(): AIConfigStore { } } catch (error) { aiLogger.error('LLM', 'Failed to load configs', error) - return { configs: [], activeConfigId: null } + return { configs: [], defaultAssistant: null, fastModel: null } } } @@ -301,10 +333,37 @@ export function getAllConfigs(): AIServiceConfig[] { return loadConfigStore().configs } -export function getActiveConfig(): AIServiceConfig | null { +/** 获取默认助手 slot(含 configId + modelId) */ +export function getDefaultAssistantSlot(): import('./model-types').ModelSlot | null { const store = loadConfigStore() - if (!store.activeConfigId) return null - return store.configs.find((c) => c.id === store.activeConfigId) || null + return resolveSlot(store.defaultAssistant, store.configs) +} + +/** 获取默认助手模型配置(AI 对话、工具调用、SQL 助手、上下文压缩)。自动覆盖 config.model 为 slot.modelId */ +export function getDefaultAssistantConfig(): AIServiceConfig | null { + const store = loadConfigStore() + const slot = resolveSlot(store.defaultAssistant, store.configs) + if (!slot) return null + const config = store.configs.find((c) => c.id === slot.configId) + if (!config) return null + return { ...config, model: slot.modelId || config.model } +} + +/** 获取快速模型 slot */ +export function getFastModelSlot(): import('./model-types').ModelSlot | null { + const store = loadConfigStore() + return resolveSlot(store.fastModel, store.configs) +} + +/** 获取快速模型配置(会话摘要),未配置时回退到默认助手 */ +export function getFastModelConfig(): AIServiceConfig | null { + const store = loadConfigStore() + const slot = resolveSlot(store.fastModel, store.configs) + if (slot) { + const config = store.configs.find((c) => c.id === slot.configId) + if (config) return { ...config, model: slot.modelId || config.model } + } + return getDefaultAssistantConfig() } export function getConfigById(id: string): AIServiceConfig | null { @@ -334,7 +393,7 @@ export function addConfig(config: Omit 0 ? store.configs[0].id : null + const fallback = store.configs[0] + if (store.defaultAssistant?.configId === id) { + store.defaultAssistant = fallback ? { configId: fallback.id, modelId: fallback.model || '' } : null + } + if (store.fastModel?.configId === id) { + store.fastModel = fallback ? { configId: fallback.id, modelId: fallback.model || '' } : null } saveConfigStore(store) return { success: true } } -export function setActiveConfig(id: string): { success: boolean; error?: string } { +/** 设置默认助手模型(configId + modelId) */ +export function setDefaultAssistantModel(configId: string, modelId: string): { success: boolean; error?: string } { const store = loadConfigStore() - const config = store.configs.find((c) => c.id === id) + const config = store.configs.find((c) => c.id === configId) if (!config) { return { success: false, error: t('llm.configNotFound') } } - store.activeConfigId = id + store.defaultAssistant = { configId, modelId } + saveConfigStore(store) + return { success: true } +} + +/** 设置快速模型(configId + modelId),传 null 表示跟随默认助手 */ +export function setFastModel(slot: import('./model-types').ModelSlot | null): { success: boolean; error?: string } { + const store = loadConfigStore() + + if (slot !== null) { + const config = store.configs.find((c) => c.id === slot.configId) + if (!config) { + return { success: false, error: t('llm.configNotFound') } + } + } + + store.fastModel = slot saveConfigStore(store) return { success: true } } export function hasActiveConfig(): boolean { - const config = getActiveConfig() - return config !== null + return getDefaultAssistantConfig() !== null } function validateProviderBaseUrl(provider: LLMProvider, baseUrl?: string): void { @@ -425,10 +504,6 @@ function validateProviderBaseUrl(provider: LLMProvider, baseUrl?: string): void } } -/** - * 获取提供商信息(兼容旧调用) - * @deprecated 使用 getBuiltinProviderById 代替 - */ export function getProviderInfo(provider: LLMProvider): ProviderInfo | null { return PROVIDERS.find((p) => p.id === provider) || null } diff --git a/electron/main/ai/llm/model-types.ts b/electron/main/ai/llm/model-types.ts index f5ac9eff..8173e40b 100644 --- a/electron/main/ai/llm/model-types.ts +++ b/electron/main/ai/llm/model-types.ts @@ -83,8 +83,14 @@ export interface ModelCatalogStore { models: ModelDefinition[] } +export interface ModelSlot { + configId: string + modelId: string +} + export interface LLMConnectionStore { configs: LLMConnectionConfigCompat[] - activeConfigId: string | null + defaultAssistant: ModelSlot | null + fastModel: ModelSlot | null schemaVersion: number } diff --git a/electron/main/ai/llm/types.ts b/electron/main/ai/llm/types.ts index 61a2f180..ca37ed37 100644 --- a/electron/main/ai/llm/types.ts +++ b/electron/main/ai/llm/types.ts @@ -1,21 +1,11 @@ /** * LLM 服务类型定义 - * 新类型系统基于 model-types.ts,此文件保留兼容别名 */ -// 重新导出新类型系统 export * from './model-types' -// ==================== 兼容别名 ==================== - -/** - * @deprecated 使用 ProviderDefinition.id (string) 代替 - */ export type LLMProvider = string -/** - * @deprecated 使用 ProviderDefinition 代替 - */ export interface ProviderInfo { id: string name: string @@ -27,11 +17,6 @@ export interface ProviderInfo { }> } -// ==================== 旧配置类型(兼容期保留) ==================== - -/** - * @deprecated 使用 LLMConnectionConfigCompat 代替 - */ export interface AIServiceConfig { id: string name: string @@ -48,12 +33,10 @@ export interface AIServiceConfig { updatedAt: number } -/** - * @deprecated 使用 LLMConnectionStore 代替 - */ export interface AIConfigStore { configs: AIServiceConfig[] - activeConfigId: string | null + defaultAssistant: import('./model-types').ModelSlot | null + fastModel: import('./model-types').ModelSlot | null } export const MAX_CONFIG_COUNT = 99 diff --git a/electron/main/ai/rag/embedding/index.ts b/electron/main/ai/rag/embedding/index.ts index 7321491d..d76d486a 100644 --- a/electron/main/ai/rag/embedding/index.ts +++ b/electron/main/ai/rag/embedding/index.ts @@ -69,7 +69,7 @@ function resolveApiConfig(config: EmbeddingServiceConfig): { } { if (config.apiSource === 'reuse_llm') { // 复用当前 LLM 配置 - const llmConfig = llm.getActiveConfig() + const llmConfig = llm.getDefaultAssistantConfig() if (!llmConfig) { throw new Error('未找到激活的 LLM 配置,请先在「模型配置」中添加 AI 服务') diff --git a/electron/main/ai/rag/pipeline/semantic.ts b/electron/main/ai/rag/pipeline/semantic.ts index 08e3aedc..6c56c94b 100644 --- a/electron/main/ai/rag/pipeline/semantic.ts +++ b/electron/main/ai/rag/pipeline/semantic.ts @@ -11,7 +11,7 @@ import { getVectorStore } from '../store' import { getSessionChunks } from '../chunking' import { loadRAGConfig } from '../config' import { completeSimple, type TextContent as PiTextContent } from '@mariozechner/pi-ai' -import { getActiveConfig, buildPiModel } from '../../llm' +import { getDefaultAssistantConfig, buildPiModel } from '../../llm' import { aiLogger as logger } from '../../logger' /** @@ -34,7 +34,7 @@ const QUERY_REWRITE_PROMPT = `你是一个查询优化专家。请将用户的 */ async function rewriteQuery(query: string, abortSignal?: AbortSignal): Promise { try { - const activeConfig = getActiveConfig() + const activeConfig = getDefaultAssistantConfig() if (!activeConfig) return query const piModel = buildPiModel(activeConfig) diff --git a/electron/main/ai/summary/index.ts b/electron/main/ai/summary/index.ts index 101b3d86..5d2a53a4 100644 --- a/electron/main/ai/summary/index.ts +++ b/electron/main/ai/summary/index.ts @@ -9,7 +9,7 @@ import Database from 'better-sqlite3' import { completeSimple, type TextContent as PiTextContent } from '@mariozechner/pi-ai' -import { getActiveConfig, buildPiModel } from '../llm' +import { getFastModelConfig, buildPiModel } from '../llm' import { getDbPath, openDatabase } from '../../database/core' import { aiLogger } from '../logger' import { t } from '../../i18n' @@ -20,12 +20,12 @@ async function llmComplete( userPrompt: string, options?: { temperature?: number; maxTokens?: number } ): Promise { - const activeConfig = getActiveConfig() - if (!activeConfig) { + const fastConfig = getFastModelConfig() + if (!fastConfig) { throw new Error(t('llm.notConfigured')) } - const piModel = buildPiModel(activeConfig) + const piModel = buildPiModel(fastConfig) const now = Date.now() const result = await completeSimple( @@ -35,7 +35,7 @@ async function llmComplete( messages: [{ role: 'user', content: userPrompt, timestamp: now }], }, { - apiKey: activeConfig.apiKey, + apiKey: fastConfig.apiKey, temperature: options?.temperature, maxTokens: options?.maxTokens, } diff --git a/electron/main/ipc/ai.ts b/electron/main/ipc/ai.ts index 38ec29e8..73f48859 100644 --- a/electron/main/ipc/ai.ts +++ b/electron/main/ipc/ai.ts @@ -10,7 +10,7 @@ import { serializeError } from '../ai/serialize-error' import { getLogsDir } from '../paths' import { Agent, type AgentStreamChunk, type SkillContext } from '../ai/agent' import { getDefaultGeneralAssistantId } from '../ai/assistant/defaultGeneral' -import { getActiveConfig, buildPiModel } from '../ai/llm' +import { getDefaultAssistantConfig, buildPiModel } from '../ai/llm' import { checkAndCompress, manualCompress, type CompressionConfig } from '../ai/compression' import { countMessagesTokens } from '../ai/tokenizer' import * as assistantManager from '../ai/assistant' @@ -406,11 +406,17 @@ export function registerAIHandlers({ win }: IpcContext): void { }) /** - * 获取当前激活的配置 ID + * 获取默认助手 slot */ - ipcMain.handle('llm:getActiveConfigId', async () => { - const config = llm.getActiveConfig() - return config?.id || null + ipcMain.handle('llm:getDefaultAssistantSlot', async () => { + return llm.getDefaultAssistantSlot() + }) + + /** + * 获取快速模型 slot + */ + ipcMain.handle('llm:getFastModelSlot', async () => { + return llm.getFastModelSlot() }) /** @@ -489,11 +495,10 @@ export function registerAIHandlers({ win }: IpcContext): void { */ ipcMain.handle('llm:deleteConfig', async (_, id?: string) => { try { - // 如果没有传 id,删除当前激活的配置 if (!id) { - const activeConfig = llm.getActiveConfig() - if (activeConfig) { - return llm.deleteConfig(activeConfig.id) + const defaultConfig = llm.getDefaultAssistantConfig() + if (defaultConfig) { + return llm.deleteConfig(defaultConfig.id) } return { success: false, error: t('llm.noActiveConfig') } } @@ -505,13 +510,25 @@ export function registerAIHandlers({ win }: IpcContext): void { }) /** - * 设置激活的配置 + * 设置默认助手模型(configId + modelId) */ - ipcMain.handle('llm:setActiveConfig', async (_, id: string) => { + ipcMain.handle('llm:setDefaultAssistantModel', async (_, configId: string, modelId: string) => { try { - return llm.setActiveConfig(id) + return llm.setDefaultAssistantModel(configId, modelId) } catch (error) { - console.error('Failed to set active config:', 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) } } }) @@ -637,7 +654,7 @@ export function registerAIHandlers({ win }: IpcContext): void { options?: { temperature?: number; maxTokens?: number } ) => { try { - const activeConfig = getActiveConfig() + const activeConfig = getDefaultAssistantConfig() if (!activeConfig) { return { success: false, error: t('llm.notConfigured') } } @@ -684,7 +701,7 @@ export function registerAIHandlers({ win }: IpcContext): void { options?: { temperature?: number; maxTokens?: number } ) => { try { - const activeConfig = getActiveConfig() + const activeConfig = getDefaultAssistantConfig() if (!activeConfig) { return { success: false, error: t('llm.notConfigured') } } @@ -1079,7 +1096,7 @@ export function registerAIHandlers({ win }: IpcContext): void { const abortController = new AbortController() activeAgentRequests.set(requestId, abortController) - const activeAIConfig = getActiveConfig() + const activeAIConfig = getDefaultAssistantConfig() if (!activeAIConfig) { return { success: false, error: t('llm.notConfigured') } } @@ -1323,7 +1340,7 @@ export function registerAIHandlers({ win }: IpcContext): void { 'ai:compressContext', async (_, conversationId: string, compressionConfig: CompressionConfig, systemPrompt: string) => { try { - const activeAIConfig = getActiveConfig() + const activeAIConfig = getDefaultAssistantConfig() if (!activeAIConfig) { return { success: false, error: t('llm.notConfigured') } } diff --git a/electron/preload/apis/ai.ts b/electron/preload/apis/ai.ts index 6eaa392b..fc7487a0 100644 --- a/electron/preload/apis/ai.ts +++ b/electron/preload/apis/ai.ts @@ -578,7 +578,6 @@ export const aiApi = { enabled: boolean tokenThresholdPercent: number bufferSizePercent: number - compressionModelConfigId?: string maxToolResultPercent?: number }, systemPrompt: string @@ -667,10 +666,17 @@ export const llmApi = { }, /** - * 获取当前激活的配置 ID + * 获取默认助手 slot(configId + modelId) */ - getActiveConfigId: (): Promise => { - return ipcRenderer.invoke('llm:getActiveConfigId') + getDefaultAssistantSlot: (): Promise<{ configId: string; modelId: string } | null> => { + return ipcRenderer.invoke('llm:getDefaultAssistantSlot') + }, + + /** + * 获取快速模型 slot + */ + getFastModelSlot: (): Promise<{ configId: string; modelId: string } | null> => { + return ipcRenderer.invoke('llm:getFastModelSlot') }, /** @@ -720,10 +726,17 @@ export const llmApi = { }, /** - * 设置激活的配置 + * 设置默认助手模型(configId + modelId) */ - setActiveConfig: (id: string): Promise<{ success: boolean; error?: string }> => { - return ipcRenderer.invoke('llm:setActiveConfig', id) + setDefaultAssistantModel: (configId: string, modelId: string): Promise<{ success: boolean; error?: string }> => { + return ipcRenderer.invoke('llm:setDefaultAssistantModel', configId, modelId) + }, + + /** + * 设置快速模型 + */ + setFastModel: (slot: { configId: string; modelId: string } | null): Promise<{ success: boolean; error?: string }> => { + return ipcRenderer.invoke('llm:setFastModel', slot) }, /** @@ -1000,7 +1013,6 @@ export const agentApi = { enabled: boolean tokenThresholdPercent: number bufferSizePercent: number - compressionModelConfigId?: string maxToolResultPercent?: number } ): { diff --git a/electron/preload/index.d.ts b/electron/preload/index.d.ts index 54337f11..4f2bbc76 100644 --- a/electron/preload/index.d.ts +++ b/electron/preload/index.d.ts @@ -304,6 +304,11 @@ type AIContentBlock = } } | { type: 'skill'; skillId: string; skillName: string } + | { + type: 'summary_meta' + bufferBoundaryTimestamp: number + compressedMessageCount: number + } type AIMessageRole = 'user' | 'assistant' | 'system' @@ -410,7 +415,6 @@ interface AiApi { enabled: boolean tokenThresholdPercent: number bufferSizePercent: number - compressionModelConfigId?: string maxToolResultPercent?: number }, systemPrompt: string @@ -565,12 +569,12 @@ interface LlmApi { ) => Promise<{ success: boolean; error?: string }> deleteCustomModel: (providerId: string, modelId: string) => Promise<{ success: boolean; error?: string }> - /** @deprecated 使用 getProviderRegistry 代替 */ getProviders: () => Promise // 多配置管理 API getAllConfigs: () => Promise - getActiveConfigId: () => Promise + getDefaultAssistantSlot: () => Promise<{ configId: string; modelId: string } | null> + getFastModelSlot: () => Promise<{ configId: string; modelId: string } | null> addConfig: (config: { name: string provider: string @@ -599,7 +603,8 @@ interface LlmApi { } ) => Promise<{ success: boolean; error?: string }> deleteConfig: (id?: string) => Promise<{ success: boolean; error?: string }> - setActiveConfig: (id: string) => Promise<{ success: boolean; error?: string }> + setDefaultAssistantModel: (configId: string, modelId: string) => Promise<{ success: boolean; error?: string }> + setFastModel: (slot: { configId: string; modelId: string } | null) => Promise<{ success: boolean; error?: string }> // 验证和检查 validateApiKey: ( @@ -834,7 +839,6 @@ interface AgentApi { enabled: boolean tokenThresholdPercent: number bufferSizePercent: number - compressionModelConfigId?: string maxToolResultPercent?: number } ) => { requestId: string; promise: Promise<{ success: boolean; result?: AgentResult; error?: SerializedErrorInfo }> } diff --git a/src/components/AIChat/chat/ChatStatusBar.vue b/src/components/AIChat/chat/ChatStatusBar.vue index f18421fe..9494c44d 100644 --- a/src/components/AIChat/chat/ChatStatusBar.vue +++ b/src/components/AIChat/chat/ChatStatusBar.vue @@ -25,7 +25,7 @@ const props = defineProps<{ const promptStore = usePromptStore() const llmStore = useLLMStore() const { aiGlobalSettings } = storeToRefs(promptStore) -const { configs, activeConfig, isLoading: isLoadingLLM } = storeToRefs(llmStore) +const { configs, defaultAssistantConfig, isLoading: isLoadingLLM } = storeToRefs(llmStore) // 下拉菜单状态 const isModelPopoverOpen = ref(false) @@ -86,10 +86,10 @@ const contextTokens = computed(() => { }) const modelContextWindow = computed(() => { - if (!activeConfig.value) return 128000 + if (!defaultAssistantConfig.value) return 128000 const model = - llmStore.getModelById(activeConfig.value.provider, activeConfig.value.model) || - llmStore.findModelAcrossProviders(activeConfig.value.model) + llmStore.getModelById(defaultAssistantConfig.value.provider, defaultAssistantConfig.value.model) || + llmStore.findModelAcrossProviders(defaultAssistantConfig.value.model) return model?.contextWindow ?? 128000 }) @@ -129,7 +129,8 @@ function openChatSettings() { // 切换 AI 模型配置 async function switchModelConfig(configId: string) { - const success = await llmStore.setActiveConfig(configId) + const config = llmStore.configs.find((c) => c.id === configId) + const success = await llmStore.setDefaultAssistantModel(configId, config?.model || '') if (success) { isModelPopoverOpen.value = false } else { @@ -233,7 +234,7 @@ async function openAiLogFile() { > - {{ activeConfig?.name || t('ai.chat.statusBar.model.notConfigured') }} + {{ defaultAssistantConfig?.name || t('ai.chat.statusBar.model.notConfigured') }} @@ -250,16 +251,18 @@ async function openAiLogFile() { :key="config.id" class="flex w-full items-center gap-2 px-3 py-2 text-left text-sm transition-colors hover:bg-gray-100 dark:hover:bg-gray-800" :class="[ - config.id === activeConfig?.id + config.id === defaultAssistantConfig?.id ? 'text-pink-600 dark:text-pink-400' : 'text-gray-700 dark:text-gray-300', ]" @click="switchModelConfig(config.id)" >
{{ config.name }} diff --git a/src/components/common/Settings/AI/AIDefaultModelTab.vue b/src/components/common/Settings/AI/AIDefaultModelTab.vue new file mode 100644 index 00000000..16a66440 --- /dev/null +++ b/src/components/common/Settings/AI/AIDefaultModelTab.vue @@ -0,0 +1,222 @@ + + + diff --git a/src/components/common/Settings/AI/AIModelConfigTab.vue b/src/components/common/Settings/AI/AIModelConfigTab.vue index 2cc511d8..9e10c875 100644 --- a/src/components/common/Settings/AI/AIModelConfigTab.vue +++ b/src/components/common/Settings/AI/AIModelConfigTab.vue @@ -24,7 +24,7 @@ const aiTips = computed(() => { // ============ Store ============ const llmStore = useLLMStore() -const { configs, providers, providerRegistry, activeConfigId, isLoading, isMaxConfigs } = storeToRefs(llmStore) +const { configs, providers, providerRegistry, isLoading, isMaxConfigs } = storeToRefs(llmStore) // 弹窗状态 const showEditModal = ref(false) @@ -64,13 +64,6 @@ async function deleteConfig(id: string) { } } -async function setActive(id: string) { - const success = await llmStore.setActiveConfig(id) - if (success) { - emit('config-changed') - } -} - function getProviderName(providerId: string): string { const key = `providers.${providerId}.name` const translated = t(key) @@ -119,86 +112,78 @@ onMounted(() => {
-
- -

- - {{ t('settings.aiConfig.title') }} -

- - -
-
- -
+
+
+

+ + {{ t('settings.aiConfig.title') }} +

+ +
+ +
- -
-
- {{ config.name }} - - - {{ getProviderName(config.provider) }} - - {{ getProviderKindLabel(config.provider) }} - - · - {{ getModelDisplayName(config.provider, config.model) }} - +
+
+ +
+
+ + {{ config.name }} + + + + {{ getProviderName(config.provider) }} + + {{ getProviderKindLabel(config.provider) }} + + · + {{ getModelDisplayName(config.provider, config.model) }} + +
+
+ +
+ + +
- -
- - + +
+ +

{{ t('settings.aiConfig.empty.title') }}

+

{{ t('settings.aiConfig.empty.description') }}

+
+ + +
+ + + {{ isMaxConfigs ? t('settings.aiConfig.maxConfigs') : t('settings.aiConfig.addConfig') }} +
- - -
- -

{{ t('settings.aiConfig.empty.title') }}

-

{{ t('settings.aiConfig.empty.description') }}

-
- - -
- - - {{ isMaxConfigs ? t('settings.aiConfig.maxConfigs') : t('settings.aiConfig.addConfig') }} - -
diff --git a/src/components/common/Settings/AISettingsTab.vue b/src/components/common/Settings/AISettingsTab.vue index a6849467..52c0db0e 100644 --- a/src/components/common/Settings/AISettingsTab.vue +++ b/src/components/common/Settings/AISettingsTab.vue @@ -2,6 +2,7 @@ import { ref, computed } from 'vue' import { useI18n } from 'vue-i18n' import AIModelConfigTab from './AI/AIModelConfigTab.vue' +import AIDefaultModelTab from './AI/AIDefaultModelTab.vue' import AIPromptConfigTab from './AI/AIPromptConfigTab.vue' import AIPreprocessTab from './AI/AIPreprocessTab.vue' // TODO: 向量模型暂时隐藏,待功能完善后恢复 @@ -19,6 +20,7 @@ const emit = defineEmits<{ // 导航配置 const navItems = computed(() => [ { id: 'model', label: t('settings.tabs.aiConfig') }, + { id: 'defaultModel', label: t('settings.tabs.aiDefaultModel') }, // TODO: 向量模型暂时隐藏,待功能完善后恢复 // { id: 'rag', label: t('settings.tabs.aiRAG') }, { id: 'chat', label: t('settings.tabs.aiPrompt') }, @@ -66,6 +68,14 @@ void aiModelConfigRef.value
+ +
+ + +
+ +
+