feat: 上下文压缩行为优化

This commit is contained in:
digua
2026-05-02 00:01:30 +08:00
committed by digua
parent 0c3bbcb37a
commit df0b2a36d2
23 changed files with 795 additions and 268 deletions
+3 -3
View File
@@ -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<AgentResult> {
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<AgentResult> {
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)
+130 -47
View File
@@ -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<ContentBlock, { type: 'summary_meta' }> => 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 检查:压缩后重新计算 tokensummary + 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<string | null> {
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<string | null> {
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,
+62 -13
View File
@@ -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<string, unknown>
}
}
| {
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<ContentBlock, { type: 'summary_meta' }> => 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<ContentBlock, { type: 'summary_meta' }> => 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
}
+103 -28
View File
@@ -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 → v3activeConfigId → 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<AIServiceConfig, 'id' | 'createdAt' | 'up
store.configs.push(newConfig)
if (store.configs.length === 1) {
store.activeConfigId = newConfig.id
store.defaultAssistant = { configId: newConfig.id, modelId: newConfig.model || '' }
}
saveConfigStore(store)
@@ -372,30 +431,50 @@ export function deleteConfig(id: string): { success: boolean; error?: string } {
store.configs.splice(index, 1)
if (store.activeConfigId === id) {
store.activeConfigId = store.configs.length > 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
}
+7 -1
View File
@@ -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
}
+2 -19
View File
@@ -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
+1 -1
View File
@@ -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 服务')
+2 -2
View File
@@ -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<string> {
try {
const activeConfig = getActiveConfig()
const activeConfig = getDefaultAssistantConfig()
if (!activeConfig) return query
const piModel = buildPiModel(activeConfig)
+5 -5
View File
@@ -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<string> {
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,
}
+34 -17
View File
@@ -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') }
}
+20 -8
View File
@@ -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
* 获取默认助手 slotconfigId + modelId
*/
getActiveConfigId: (): Promise<string | null> => {
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
}
): {
+9 -5
View File
@@ -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<LLMProviderInfo[]>
// 多配置管理 API
getAllConfigs: () => Promise<AIServiceConfigDisplay[]>
getActiveConfigId: () => Promise<string | null>
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 }> }
+12 -9
View File
@@ -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() {
>
<UIcon name="i-heroicons-cpu-chip" class="h-3.5 w-3.5" />
<span class="max-w-[120px] truncate">
{{ activeConfig?.name || t('ai.chat.statusBar.model.notConfigured') }}
{{ defaultAssistantConfig?.name || t('ai.chat.statusBar.model.notConfigured') }}
</span>
<UIcon name="i-heroicons-chevron-down" class="h-3 w-3" />
</button>
@@ -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)"
>
<UIcon
:name="config.id === activeConfig?.id ? 'i-heroicons-check-circle-solid' : 'i-heroicons-cpu-chip'"
:name="
config.id === defaultAssistantConfig?.id ? 'i-heroicons-check-circle-solid' : 'i-heroicons-cpu-chip'
"
class="h-4 w-4 shrink-0"
:class="[config.id === activeConfig?.id ? 'text-pink-500' : 'text-gray-400']"
:class="[config.id === defaultAssistantConfig?.id ? 'text-pink-500' : 'text-gray-400']"
/>
<div class="flex flex-col truncate">
<span class="truncate">{{ config.name }}</span>
@@ -0,0 +1,222 @@
<script setup lang="ts">
import { computed, onMounted } from 'vue'
import { storeToRefs } from 'pinia'
import { useI18n } from 'vue-i18n'
import { useLLMStore } from '@/stores/llm'
const { t } = useI18n()
const emit = defineEmits<{
'config-changed': []
}>()
const llmStore = useLLMStore()
const { configs, defaultAssistant, fastModel, modelCatalog } = storeToRefs(llmStore)
function getModelsForConfig(configId: string) {
const config = configs.value.find((c) => c.id === configId)
if (!config) return []
if (config.customModels && config.customModels.length > 0) {
return config.customModels.map((m) => ({ label: m.name || m.id, value: m.id }))
}
const models = modelCatalog.value.filter(
(m) =>
m.providerId === config.provider && !m.capabilities.includes('embedding') && !m.capabilities.includes('ranking')
)
const options = models.map((m) => ({ label: m.name || m.id, value: m.id }))
if (config.model && !options.some((o) => o.value === config.model)) {
options.unshift({ label: config.model, value: config.model })
}
return options
}
const configOptions = computed(() =>
configs.value.map((c) => ({
label: c.name,
value: c.id,
}))
)
// ========== 助手模型 ==========
const assistantModelOptions = computed(() => {
const id = defaultAssistant.value?.configId
return id ? getModelsForConfig(id) : []
})
const internalAssistantConfigId = computed({
get: () => defaultAssistant.value?.configId ?? '',
set: (val: string) => {
if (!val) return
const models = getModelsForConfig(val)
const modelId = models[0]?.value ?? ''
llmStore.setDefaultAssistantModel(val, modelId)
emit('config-changed')
},
})
const internalAssistantModelId = computed({
get: () => defaultAssistant.value?.modelId ?? '',
set: (val: string) => {
const configId = defaultAssistant.value?.configId
if (configId && val) {
llmStore.setDefaultAssistantModel(configId, val)
emit('config-changed')
}
},
})
// ========== 快速模型 ==========
const FOLLOW_VALUE = '__follow__'
const fastConfigSelectOptions = computed(() => [
{ label: t('settings.defaultModel.fastModel.followAssistant'), value: FOLLOW_VALUE },
...configOptions.value,
])
const fastModelOptions = computed(() => {
const id = fastModel.value?.configId
return id ? getModelsForConfig(id) : []
})
const showFastModelSelector = computed(() => fastModel.value !== null)
const internalFastConfigId = computed({
get: () => fastModel.value?.configId ?? FOLLOW_VALUE,
set: (val: string) => {
if (val === FOLLOW_VALUE) {
llmStore.setFastModel(null)
} else {
const models = getModelsForConfig(val)
const modelId = models[0]?.value ?? ''
llmStore.setFastModel({ configId: val, modelId })
}
emit('config-changed')
},
})
const internalFastModelId = computed({
get: () => fastModel.value?.modelId ?? '',
set: (val: string) => {
const configId = fastModel.value?.configId
if (configId && val) {
llmStore.setFastModel({ configId, modelId: val })
emit('config-changed')
}
},
})
onMounted(() => {
if (!llmStore.isInitialized) {
llmStore.init()
}
})
</script>
<template>
<div class="space-y-6">
<div
v-if="configs.length === 0"
class="rounded-lg border-2 border-dashed border-gray-200 py-8 text-center dark:border-gray-700"
>
<UIcon name="i-heroicons-cpu-chip" class="mx-auto mb-2 h-8 w-8 text-gray-300 dark:text-gray-600" />
<p class="text-sm text-gray-500 dark:text-gray-400">{{ t('settings.defaultModel.noConfigs') }}</p>
</div>
<div v-else>
<h4 class="mb-3 flex items-center gap-2 text-sm font-semibold text-gray-900 dark:text-white">
<UIcon name="i-heroicons-cpu-chip" class="h-4 w-4 text-violet-500" />
{{ t('settings.defaultModel.title') }}
</h4>
<div class="space-y-5 rounded-lg border border-gray-200 bg-gray-50 p-4 dark:border-gray-700 dark:bg-gray-800/50">
<!-- 默认助手模型 -->
<div>
<div class="mb-2">
<p class="text-sm font-medium text-gray-900 dark:text-white">
{{ t('settings.defaultModel.assistantModel.label') }}
</p>
<p class="text-xs text-gray-500 dark:text-gray-400">
{{ t('settings.defaultModel.assistantModel.description') }}
</p>
</div>
<div class="flex items-center gap-3">
<div class="flex-1">
<span class="mb-1 block text-xs text-gray-400 dark:text-gray-500">
{{ t('settings.defaultModel.selectProvider') }}
</span>
<USelect
v-model="internalAssistantConfigId"
:items="configOptions"
:ui="{ content: 'z-[200]' }"
class="w-full"
size="sm"
/>
</div>
<div class="flex-1">
<span class="mb-1 block text-xs text-gray-400 dark:text-gray-500">
{{ t('settings.defaultModel.selectModel') }}
</span>
<USelect
v-if="assistantModelOptions.length > 0"
v-model="internalAssistantModelId"
:items="assistantModelOptions"
:ui="{ content: 'z-[200]' }"
class="w-full"
size="sm"
/>
<USelect v-else :items="[]" disabled class="w-full" size="sm" />
</div>
</div>
</div>
<!-- 分隔线 -->
<div class="border-t border-gray-200 dark:border-gray-700" />
<!-- 快速模型 -->
<div>
<div class="mb-2">
<p class="text-sm font-medium text-gray-900 dark:text-white">
{{ t('settings.defaultModel.fastModel.label') }}
</p>
<p class="text-xs text-gray-500 dark:text-gray-400">
{{ t('settings.defaultModel.fastModel.description') }}
</p>
</div>
<div class="flex items-center gap-3">
<div class="flex-1">
<span class="mb-1 block text-xs text-gray-400 dark:text-gray-500">
{{ t('settings.defaultModel.selectProvider') }}
</span>
<USelect
v-model="internalFastConfigId"
:items="fastConfigSelectOptions"
:ui="{ content: 'z-[200]' }"
class="w-full"
size="sm"
/>
</div>
<div class="flex-1">
<span class="mb-1 block text-xs text-gray-400 dark:text-gray-500">
{{ t('settings.defaultModel.selectModel') }}
</span>
<USelect
v-if="showFastModelSelector && fastModelOptions.length > 0"
v-model="internalFastModelId"
:items="fastModelOptions"
:ui="{ content: 'z-[200]' }"
class="w-full"
size="sm"
/>
<USelect v-else :items="[]" disabled class="w-full" size="sm" />
</div>
</div>
</div>
</div>
</div>
</div>
</template>
@@ -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(() => {
</div>
<!-- 配置列表视图 -->
<div v-else class="space-y-4">
<!-- 标题 -->
<h4 class="flex items-center gap-2 text-sm font-semibold text-gray-900 dark:text-white">
<UIcon name="i-heroicons-sparkles" class="h-4 w-4 text-violet-500" />
{{ t('settings.aiConfig.title') }}
</h4>
<AlertTips v-if="configs.length === 0 && aiTips.configTab?.show" :content="aiTips.configTab?.content" />
<!-- 配置列表 -->
<div v-if="configs.length > 0" class="overflow-hidden rounded-lg border border-gray-200 dark:border-gray-700 divide-y divide-gray-100 dark:divide-gray-800">
<div
v-for="config in configs"
:key="config.id"
class="group flex cursor-pointer items-center justify-between px-3 py-2 transition-colors"
:class="[
config.id === activeConfigId
? 'bg-primary-50/60 dark:bg-primary-900/20'
: 'bg-card-bg hover:bg-gray-50 dark:bg-card-dark dark:hover:bg-gray-800/50',
]"
@click="setActive(config.id)"
>
<!-- 配置信息 -->
<div class="flex items-center gap-3">
<div v-else class="space-y-6">
<div>
<h4 class="mb-3 flex items-center gap-2 text-sm font-semibold text-gray-900 dark:text-white">
<UIcon name="i-heroicons-sparkles" class="h-4 w-4 text-violet-500" />
{{ t('settings.aiConfig.title') }}
</h4>
<AlertTips v-if="configs.length === 0 && aiTips.configTab?.show" :content="aiTips.configTab?.content" />
<div class="rounded-lg border border-gray-200 bg-gray-50 dark:border-gray-700 dark:bg-gray-800/50">
<!-- 配置列表 -->
<div v-if="configs.length > 0" class="divide-y divide-gray-200 dark:divide-gray-700">
<div
class="flex h-6 w-6 shrink-0 items-center justify-center rounded-full"
:class="[
config.id === activeConfigId
? 'bg-primary-500 text-white'
: 'bg-gray-200 text-gray-500 dark:bg-gray-700 dark:text-gray-400',
]"
v-for="config in configs"
:key="config.id"
class="group flex items-center justify-between px-4 py-2.5 transition-colors hover:bg-gray-100/50 dark:hover:bg-gray-700/30"
>
<UIcon
:name="config.id === activeConfigId ? 'i-heroicons-check' : 'i-heroicons-sparkles'"
class="h-3.5 w-3.5"
/>
</div>
<div class="flex min-w-0 items-center gap-2">
<span class="w-28 shrink-0 truncate font-medium text-sm text-gray-900 dark:text-white">{{ config.name }}</span>
<span class="h-3.5 w-px shrink-0 bg-gray-200 dark:bg-gray-700" />
<span class="flex min-w-0 items-center gap-1.5 text-xs text-gray-500 dark:text-gray-400">
<span class="truncate">{{ getProviderName(config.provider) }}</span>
<UBadge v-if="getProviderKindLabel(config.provider)" color="neutral" variant="subtle" size="xs">
{{ getProviderKindLabel(config.provider) }}
</UBadge>
<span class="shrink-0">·</span>
<span class="truncate">{{ getModelDisplayName(config.provider, config.model) }}</span>
</span>
<div class="flex items-center gap-3">
<div
class="flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-gray-200 text-gray-500 dark:bg-gray-700 dark:text-gray-400"
>
<UIcon name="i-heroicons-sparkles" class="h-3.5 w-3.5" />
</div>
<div class="flex min-w-0 items-center gap-2">
<span class="w-28 shrink-0 truncate text-sm font-medium text-gray-900 dark:text-white">
{{ config.name }}
</span>
<span class="h-3.5 w-px shrink-0 bg-gray-200 dark:bg-gray-700" />
<span class="flex min-w-0 items-center gap-1.5 text-xs text-gray-500 dark:text-gray-400">
<span class="truncate">{{ getProviderName(config.provider) }}</span>
<UBadge v-if="getProviderKindLabel(config.provider)" color="neutral" variant="subtle" size="xs">
{{ getProviderKindLabel(config.provider) }}
</UBadge>
<span class="shrink-0">·</span>
<span class="truncate">{{ getModelDisplayName(config.provider, config.model) }}</span>
</span>
</div>
</div>
<div class="flex items-center gap-1">
<UButton
size="xs"
color="neutral"
variant="ghost"
icon="i-heroicons-pencil-square"
@click="openEditModal(config)"
/>
<UButton
size="xs"
color="error"
variant="ghost"
icon="i-heroicons-trash"
@click="deleteConfig(config.id)"
/>
</div>
</div>
</div>
<!-- 操作按钮 -->
<div class="flex items-center gap-1 opacity-0 transition-opacity group-hover:opacity-100" @click.stop>
<UButton
size="xs"
color="neutral"
variant="ghost"
icon="i-heroicons-pencil-square"
@click="openEditModal(config)"
/>
<UButton size="xs" color="error" variant="ghost" icon="i-heroicons-trash" @click="deleteConfig(config.id)" />
<!-- 空状态 -->
<div v-else class="flex flex-col items-center justify-center py-8">
<UIcon name="i-heroicons-sparkles" class="h-8 w-8 text-gray-300 dark:text-gray-600" />
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">{{ t('settings.aiConfig.empty.title') }}</p>
<p class="text-xs text-gray-400 dark:text-gray-500">{{ t('settings.aiConfig.empty.description') }}</p>
</div>
<!-- 添加按钮 -->
<div class="border-t border-gray-200 px-4 py-3 dark:border-gray-700">
<UButton variant="soft" :disabled="isMaxConfigs" size="sm" @click="openAddModal">
<UIcon name="i-heroicons-plus" class="mr-1.5 h-3.5 w-3.5" />
{{ isMaxConfigs ? t('settings.aiConfig.maxConfigs') : t('settings.aiConfig.addConfig') }}
</UButton>
</div>
</div>
</div>
<!-- 空状态 -->
<div
v-else
class="flex flex-col items-center justify-center rounded-lg border-2 border-dashed border-gray-200 py-8 dark:border-gray-700"
>
<UIcon name="i-heroicons-sparkles" class="h-10 w-10 text-gray-300 dark:text-gray-600" />
<p class="mt-3 text-sm text-gray-500 dark:text-gray-400">{{ t('settings.aiConfig.empty.title') }}</p>
<p class="text-xs text-gray-400 dark:text-gray-500">{{ t('settings.aiConfig.empty.description') }}</p>
</div>
<!-- 添加按钮 -->
<div class="flex justify-center">
<UButton variant="soft" :disabled="isMaxConfigs" size="sm" @click="openAddModal">
<UIcon name="i-heroicons-plus" class="mr-2 h-4 w-4" />
{{ isMaxConfigs ? t('settings.aiConfig.maxConfigs') : t('settings.aiConfig.addConfig') }}
</UButton>
</div>
</div>
<!-- 编辑/添加弹窗 -->
@@ -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
<AIModelConfigTab ref="aiModelConfigRef" @config-changed="handleAIConfigChanged" />
</div>
<!-- 分隔线 -->
<div class="border-t border-gray-200 dark:border-gray-700" />
<!-- 默认模型 -->
<div :ref="(el) => setSectionRef('defaultModel', el as HTMLElement)">
<AIDefaultModelTab @config-changed="handleAIConfigChanged" />
</div>
<!-- TODO: 向量模型暂时隐藏待功能完善后恢复 -->
<!--
<div class="border-t border-gray-200 dark:border-gray-700" />
+16
View File
@@ -4,6 +4,7 @@
"basic": "General",
"ai": "AI Settings",
"aiConfig": "Model Service",
"aiDefaultModel": "Default Model",
"aiRAG": "Vector Model",
"aiPrompt": "Chat Config",
"aiPreprocess": "Preprocess",
@@ -79,6 +80,21 @@
"saveFailed": "Save failed"
}
},
"defaultModel": {
"title": "Default Model",
"noConfigs": "Please add a model configuration in \"Model Service\" first",
"selectProvider": "Select provider",
"selectModel": "Select model",
"assistantModel": {
"label": "Default Assistant Model",
"description": "Used for AI chat, tool calls, SQL assistant, and context compression"
},
"fastModel": {
"label": "Fast Model",
"description": "Used for session summary generation. Falls back to the default assistant model if not set",
"followAssistant": "Follow default assistant model"
}
},
"aiConfig": {
"title": "Model Services",
"providerTitle": "Service Providers",
+16
View File
@@ -4,6 +4,7 @@
"basic": "基本設定",
"ai": "AI 設定",
"aiConfig": "モデルサービス",
"aiDefaultModel": "デフォルトモデル",
"aiRAG": "埋め込みモデル",
"aiPrompt": "チャット設定",
"aiPreprocess": "前処理",
@@ -79,6 +80,21 @@
"saveFailed": "保存に失敗しました"
}
},
"defaultModel": {
"title": "デフォルトモデル",
"noConfigs": "先に「モデルサービス」でモデル設定を追加してください",
"selectProvider": "プロバイダーを選択",
"selectModel": "モデルを選択",
"assistantModel": {
"label": "デフォルトアシスタントモデル",
"description": "AIチャット、ツール呼び出し、SQLアシスタント、コンテキスト圧縮に使用"
},
"fastModel": {
"label": "高速モデル",
"description": "セッション要約生成に使用。未設定の場合はデフォルトアシスタントモデルを使用",
"followAssistant": "デフォルトアシスタントモデルに従う"
}
},
"aiConfig": {
"title": "モデルサービス",
"providerTitle": "サービスプロバイダー",
+16
View File
@@ -4,6 +4,7 @@
"basic": "基础设置",
"ai": "AI 设置",
"aiConfig": "模型服务",
"aiDefaultModel": "默认模型",
"aiRAG": "向量模型",
"aiPrompt": "对话配置",
"aiPreprocess": "预处理",
@@ -79,6 +80,21 @@
"saveFailed": "保存失败"
}
},
"defaultModel": {
"title": "默认模型",
"noConfigs": "请先在「模型服务」中添加模型配置",
"selectProvider": "选择提供商",
"selectModel": "选择模型",
"assistantModel": {
"label": "默认助手模型",
"description": "用于 AI 对话、工具调用、SQL 助手、上下文压缩"
},
"fastModel": {
"label": "快速模型",
"description": "用于会话摘要生成,未配置时使用默认助手模型",
"followAssistant": "跟随默认助手模型"
}
},
"aiConfig": {
"title": "模型服务",
"providerTitle": "服务提供商",
+16
View File
@@ -4,6 +4,7 @@
"basic": "基本設定",
"ai": "AI 設定",
"aiConfig": "模型服務",
"aiDefaultModel": "預設模型",
"aiRAG": "向量模型",
"aiPrompt": "聊天設定",
"aiPreprocess": "前處理",
@@ -79,6 +80,21 @@
"saveFailed": "儲存失敗"
}
},
"defaultModel": {
"title": "預設模型",
"noConfigs": "請先在「模型服務」中新增模型設定",
"selectProvider": "選擇提供商",
"selectModel": "選擇模型",
"assistantModel": {
"label": "預設助手模型",
"description": "用於 AI 對話、工具呼叫、SQL 助手、上下文壓縮"
},
"fastModel": {
"label": "快速模型",
"description": "用於會話摘要生成,未設定時使用預設助手模型",
"followAssistant": "跟隨預設助手模型"
}
},
"aiConfig": {
"title": "模型服務",
"providerTitle": "服務提供商",
+5 -1
View File
@@ -50,6 +50,11 @@ export type ContentBlock =
}
| { type: 'skill'; skillId: string; skillName: string }
| { type: 'error'; error: SerializedErrorInfo }
| {
type: 'summary_meta'
bufferBoundaryTimestamp: number
compressedMessageCount: number
}
// 消息类型
export interface ChatMessage {
@@ -920,7 +925,6 @@ export const useAIChatStore = defineStore('aiChatRuntime', () => {
enabled: aiGlobalSettings.value.contextCompression?.enabled ?? false,
tokenThresholdPercent: aiGlobalSettings.value.contextCompression?.tokenThresholdPercent ?? 75,
bufferSizePercent: aiGlobalSettings.value.contextCompression?.bufferSizePercent ?? 20,
compressionModelConfigId: aiGlobalSettings.value.contextCompression?.compressionModelConfigId,
maxToolResultPercent: aiGlobalSettings.value.contextCompression?.maxToolResultPercent ?? 50,
}
)
+39 -27
View File
@@ -12,13 +12,11 @@ export interface AIServiceConfigDisplay {
apiKeySet: boolean
model?: string
baseUrl?: string
customModels?: Array<{ id: string; name: string }>
createdAt: number
updatedAt: number
}
/**
* @deprecated 使用 ProviderDefinition 代替
*/
export interface LLMProvider {
id: string
name: string
@@ -26,35 +24,29 @@ export interface LLMProvider {
models: Array<{ id: string; name: string; description?: string }>
}
/**
* LLM 配置状态管理
*/
export const useLLMStore = defineStore('llm', () => {
// ============ 状态 ============
const configs = ref<AIServiceConfigDisplay[]>([])
/** @deprecated 使用 providerRegistry 代替 */
const providers = ref<LLMProvider[]>([])
const activeConfigId = ref<string | null>(null)
const defaultAssistant = ref<{ configId: string; modelId: string } | null>(null)
const fastModel = ref<{ configId: string; modelId: string } | null>(null)
const isLoading = ref(false)
const isInitialized = ref(false)
/** 新模型系统:Provider Registry(内置 + 自定义) */
const providerRegistry = ref<ProviderDefinition[]>([])
/** 新模型系统:Model Catalog(内置 + 自定义) */
const modelCatalog = ref<ModelDefinition[]>([])
// ============ 计算属性 ============
const activeConfig = computed(() => configs.value.find((c) => c.id === activeConfigId.value) || null)
const hasConfig = computed(() => !!activeConfigId.value)
const defaultAssistantConfig = computed(
() => configs.value.find((c) => c.id === defaultAssistant.value?.configId) || null
)
const fastModelConfig = computed(() => configs.value.find((c) => c.id === fastModel.value?.configId) || null)
const hasConfig = computed(() => !!defaultAssistant.value)
const isMaxConfigs = computed(() => configs.value.length >= 99)
// ============ 新模型系统 computed ============
function getProviderById(id: string): ProviderDefinition | undefined {
return providerRegistry.value.find((p) => p.id === id)
}
@@ -82,18 +74,20 @@ export const useLLMStore = defineStore('llm', () => {
async function loadConfigs() {
isLoading.value = true
try {
const [providersData, registryData, catalogData, configsData, activeId] = await Promise.all([
const [providersData, registryData, catalogData, configsData, assistantSlot, fastSlot] = await Promise.all([
window.llmApi.getProviders(),
window.llmApi.getProviderRegistry(),
window.llmApi.getModelCatalog(),
window.llmApi.getAllConfigs(),
window.llmApi.getActiveConfigId(),
window.llmApi.getDefaultAssistantSlot(),
window.llmApi.getFastModelSlot(),
])
providers.value = providersData
providerRegistry.value = registryData
modelCatalog.value = catalogData
configs.value = configsData
activeConfigId.value = activeId
defaultAssistant.value = assistantSlot
fastModel.value = fastSlot
} catch (error) {
console.error('[LLM Store] 加载配置失败:', error)
} finally {
@@ -101,17 +95,32 @@ export const useLLMStore = defineStore('llm', () => {
}
}
async function setActiveConfig(id: string): Promise<boolean> {
async function setDefaultAssistantModel(configId: string, modelId: string): Promise<boolean> {
try {
const result = await window.llmApi.setActiveConfig(id)
const result = await window.llmApi.setDefaultAssistantModel(configId, modelId)
if (result.success) {
activeConfigId.value = id
defaultAssistant.value = { configId, modelId }
return true
}
console.error('[LLM Store] 设置激活配置失败:', result.error)
console.error('[LLM Store] 设置默认助手模型失败:', result.error)
return false
} catch (error) {
console.error('[LLM Store] 设置激活配置失败:', error)
console.error('[LLM Store] 设置默认助手模型失败:', error)
return false
}
}
async function setFastModel(slot: { configId: string; modelId: string } | null): Promise<boolean> {
try {
const result = await window.llmApi.setFastModel(slot)
if (result.success) {
fastModel.value = slot
return true
}
console.error('[LLM Store] 设置快速模型失败:', result.error)
return false
} catch (error) {
console.error('[LLM Store] 设置快速模型失败:', error)
return false
}
}
@@ -132,17 +141,20 @@ export const useLLMStore = defineStore('llm', () => {
providers,
providerRegistry,
modelCatalog,
activeConfigId,
defaultAssistant,
fastModel,
isLoading,
isInitialized,
// 计算属性
activeConfig,
defaultAssistantConfig,
fastModelConfig,
hasConfig,
isMaxConfigs,
// 方法
init,
loadConfigs,
setActiveConfig,
setDefaultAssistantModel,
setFastModel,
refreshConfigs,
getProviderName,
getProviderById,
-2
View File
@@ -20,7 +20,6 @@ export const usePromptStore = defineStore(
enabled: true,
tokenThresholdPercent: 75,
bufferSizePercent: 20,
compressionModelConfigId: undefined as string | undefined,
maxToolResultPercent: 50,
},
})
@@ -49,7 +48,6 @@ export const usePromptStore = defineStore(
enabled: boolean
tokenThresholdPercent: number
bufferSizePercent: number
compressionModelConfigId?: string
maxToolResultPercent?: number
}
}>