diff --git a/apps/cli/src/ai/agent-stream-runner.ts b/apps/cli/src/ai/agent-stream-runner.ts index 45417909..75fed93e 100644 --- a/apps/cli/src/ai/agent-stream-runner.ts +++ b/apps/cli/src/ai/agent-stream-runner.ts @@ -3,11 +3,19 @@ * for the shared HTTP route context. */ -import type { DatabaseManager, AIChatManager, AgentStreamChunk, SemanticIndexRuntime } from '@openchatlab/node-runtime' +import type { + DatabaseManager, + AIChatManager, + AgentStreamChunk, + LLMConfigStore, + SemanticIndexRuntime, +} from '@openchatlab/node-runtime' import { CHART_CAPABILITY_CORE_TOOLS, SkillManager, + buildPiModel, buildSkillMenuWithBuiltinChart, + createLlmRuntimeStores, createActivateSkillTool, createDataSnapshotFromOverview, getAllowedBuiltinToolsForChartAutoSkill, @@ -22,7 +30,6 @@ import type { AgentStreamRequest } from '@openchatlab/http-routes' import { AGENT_TOOL_REGISTRY, SEMANTIC_SEARCH_TOOL_NAME } from '@openchatlab/tools' import { buildSemanticSearchGuidance } from '@openchatlab/node-runtime' import { adaptToolsForAgent } from './tool-adapter' -import { getDefaultAssistantConfig, buildPiModel } from './llm-config' import { loadAssistantConfig } from './assistant-loader' import { runServerAgent } from './agent' @@ -65,8 +72,15 @@ export function getAllowedToolSet( export function createCliRunAgentStream( dbManager: DatabaseManager, aiChatManager: AIChatManager, - semanticIndexService?: SemanticIndexRuntime + options: { + llmConfigStore?: LLMConfigStore + semanticIndexService?: SemanticIndexRuntime + } = {} ): (params: AgentStreamRequest, onEvent: (chunk: AgentStreamChunk) => void, abortSignal: AbortSignal) => Promise { + const aiDataDir = getAiDir(dbManager) + const llmConfigStore = options.llmConfigStore ?? createLlmRuntimeStores(aiDataDir).llmConfigStore + const semanticIndexService = options.semanticIndexService + return async (params, onEvent, abortSignal) => { const { userMessage, @@ -85,8 +99,6 @@ export function createCliRunAgentStream( thinkingLevel, } = params - const aiDataDir = getAiDir(dbManager) - let assistantSystemPrompt: string | undefined let assistantAllowedTools: string[] | undefined if (assistantId) { @@ -97,7 +109,7 @@ export function createCliRunAgentStream( assistantAllowedTools = assistantConfig?.allowedBuiltinTools } - const llmConfig = getDefaultAssistantConfig(aiDataDir) + const llmConfig = llmConfigStore.getDefaultAssistantConfig() const maxToolResultPercent = compressionConfig?.maxToolResultPercent ?? 50 const contextWindow = llmConfig ? (buildPiModel(llmConfig).contextWindow ?? 128000) : 128000 const maxToolResultTokens = Math.floor(contextWindow * (maxToolResultPercent / 100)) @@ -213,7 +225,7 @@ export function createCliRunAgentStream( skillDef: resolvedSkillDef, compressionConfig: resolvedCompression, tools: agentTools, - aiDataDir, + llmConfig, aiChatManager, onEvent, abortSignal, diff --git a/apps/cli/src/ai/agent.ts b/apps/cli/src/ai/agent.ts index de421c06..67280198 100644 --- a/apps/cli/src/ai/agent.ts +++ b/apps/cli/src/ai/agent.ts @@ -7,6 +7,7 @@ import { DEFAULT_MAX_TOOL_ROUNDS, + buildPiModel, buildPlanGuidance, createAnalysisPlanner, createLlmRouteDecider, @@ -29,12 +30,12 @@ import { type CompressionConfig, type AgentTool, type DataSnapshot, + type AIServiceConfig, type OwnerInfo, type MentionedMember, } from '@openchatlab/node-runtime' import type { ChartAutoMode } from '@openchatlab/shared-types' -import { getDefaultAssistantConfig, buildPiModel } from './llm-config' import { getServerAiLogger } from './logger' export type { AgentStreamChunk } @@ -50,7 +51,7 @@ export interface RunAgentOptions { skillDef?: { name: string; prompt: string } compressionConfig?: CompressionConfig tools?: AgentTool[] - aiDataDir: string + llmConfig: AIServiceConfig | null aiChatManager: AIChatManager onEvent: (event: AgentStreamChunk) => void abortSignal?: AbortSignal @@ -73,7 +74,7 @@ export async function runServerAgent(options: RunAgentOptions): Promise { skillDef, compressionConfig, tools = [], - aiDataDir, + llmConfig, aiChatManager, onEvent, abortSignal, @@ -89,7 +90,6 @@ export async function runServerAgent(options: RunAgentOptions): Promise { // 确保 tokenizer rank 表已加载(compression + agent 路径均依赖) await initTokenizer() - const llmConfig = getDefaultAssistantConfig(aiDataDir) if (!llmConfig) { onEvent({ type: 'error', error: { name: 'ConfigError', message: 'LLM service not configured' } }) onEvent({ type: 'done', isFinished: true }) diff --git a/apps/cli/src/ai/custom-store.ts b/apps/cli/src/ai/custom-store.ts deleted file mode 100644 index 00a5fe70..00000000 --- a/apps/cli/src/ai/custom-store.ts +++ /dev/null @@ -1,142 +0,0 @@ -/** - * 自定义 Provider / Model 持久化存储(平台无关) - * - * 文件位置: - * - {aiDataDir}/custom-providers.json - * - {aiDataDir}/custom-models.json - */ - -import * as fs from 'fs' -import * as path from 'path' -import { randomUUID } from 'crypto' -import type { ProviderDefinition, ModelDefinition } from '@openchatlab/core' - -// ==================== Custom Providers ==================== - -function readProviders(aiDataDir: string): ProviderDefinition[] { - const storePath = path.join(aiDataDir, 'custom-providers.json') - if (!fs.existsSync(storePath)) return [] - try { - const content = fs.readFileSync(storePath, 'utf-8') - return JSON.parse(content) as ProviderDefinition[] - } catch { - return [] - } -} - -function writeProviders(aiDataDir: string, providers: ProviderDefinition[]): void { - const storePath = path.join(aiDataDir, 'custom-providers.json') - const dir = path.dirname(storePath) - if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }) - fs.writeFileSync(storePath, JSON.stringify(providers, null, 2), 'utf-8') -} - -export function addCustomProvider( - aiDataDir: string, - input: Omit -): ProviderDefinition { - const providers = readProviders(aiDataDir) - const newProvider: ProviderDefinition = { - ...input, - id: `custom:${randomUUID()}`, - builtin: false, - enabledByDefault: false, - } - providers.push(newProvider) - writeProviders(aiDataDir, providers) - return newProvider -} - -export function updateCustomProvider( - aiDataDir: string, - id: string, - updates: Partial> -): { success: boolean; error?: string } { - const providers = readProviders(aiDataDir) - const index = providers.findIndex((p) => p.id === id) - if (index === -1) return { success: false, error: 'Custom provider not found' } - - providers[index] = { ...providers[index], ...updates } - writeProviders(aiDataDir, providers) - return { success: true } -} - -export function deleteCustomProvider(aiDataDir: string, id: string): { success: boolean; error?: string } { - const providers = readProviders(aiDataDir) - const index = providers.findIndex((p) => p.id === id) - if (index === -1) return { success: false, error: 'Custom provider not found' } - - providers.splice(index, 1) - writeProviders(aiDataDir, providers) - return { success: true } -} - -// ==================== Custom Models ==================== - -function readModels(aiDataDir: string): ModelDefinition[] { - const storePath = path.join(aiDataDir, 'custom-models.json') - if (!fs.existsSync(storePath)) return [] - try { - const content = fs.readFileSync(storePath, 'utf-8') - return JSON.parse(content) as ModelDefinition[] - } catch { - return [] - } -} - -function writeModels(aiDataDir: string, models: ModelDefinition[]): void { - const storePath = path.join(aiDataDir, 'custom-models.json') - const dir = path.dirname(storePath) - if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }) - fs.writeFileSync(storePath, JSON.stringify(models, null, 2), 'utf-8') -} - -export function addCustomModel( - aiDataDir: string, - input: Omit -): { success: boolean; model?: ModelDefinition; error?: string } { - const models = readModels(aiDataDir) - - const existing = models.find((m) => m.id === input.id && m.providerId === input.providerId) - if (existing) { - return { success: false, error: `Model "${input.id}" already exists under provider "${input.providerId}"` } - } - - const newModel: ModelDefinition = { - ...input, - builtin: false, - editable: true, - } - models.push(newModel) - writeModels(aiDataDir, models) - return { success: true, model: newModel } -} - -export function updateCustomModel( - aiDataDir: string, - providerId: string, - modelId: string, - updates: Partial> -): { success: boolean; error?: string } { - const models = readModels(aiDataDir) - const index = models.findIndex((m) => m.id === modelId && m.providerId === providerId) - if (index === -1) return { success: false, error: 'Custom model not found' } - - models[index] = { ...models[index], ...updates } - writeModels(aiDataDir, models) - return { success: true } -} - -export function deleteCustomModel( - aiDataDir: string, - providerId: string, - modelId: string -): { success: boolean; error?: string } { - const models = readModels(aiDataDir) - const index = models.findIndex((m) => m.id === modelId && m.providerId === providerId) - if (index === -1) return { success: false, error: 'Custom model not found' } - - models.splice(index, 1) - writeModels(aiDataDir, models) - return { success: true } -} diff --git a/apps/cli/src/ai/llm-config.ts b/apps/cli/src/ai/llm-config.ts deleted file mode 100644 index 47d72bbc..00000000 --- a/apps/cli/src/ai/llm-config.ts +++ /dev/null @@ -1,256 +0,0 @@ -/** - * 服务端 LLM 配置加载 - * - * API Key 从 ~/.chatlab/auth-profiles.json 读取, - * 匹配顺序:config.authProfile 精确匹配 > config.provider 兜底匹配。 - * - * 模型/Provider 等配置仍从 llm-config.json 读取。 - */ - -import * as fs from 'fs' -import * as path from 'path' -import type { PiModel, PiApi } from '@openchatlab/node-runtime' -import { buildPiModel as buildPiModelCore, type PiModelConfig } from '@openchatlab/node-runtime' -import { resolveApiKey, writeAuthProfile } from '@openchatlab/config' -import { randomUUID } from 'crypto' - -// ==================== Types ==================== - -export interface AIServiceConfig { - id: string - name: string - provider: string - apiKey: string - model?: string - baseUrl?: string - maxTokens?: number - apiFormat?: string - customModels?: Array<{ id: string; name: string }> - authProfile?: string -} - -interface ModelSlot { - configId: string - modelId?: string -} - -interface AIConfigStore { - configs: AIServiceConfig[] - defaultAssistant: ModelSlot | null - fastModel: ModelSlot | null -} - -// ==================== Config Loading ==================== - -export function loadLlmConfig(aiDataDir: string): AIConfigStore { - const configPath = path.join(aiDataDir, 'llm-config.json') - if (!fs.existsSync(configPath)) { - return { configs: [], defaultAssistant: null, fastModel: null } - } - - try { - const data = JSON.parse(fs.readFileSync(configPath, 'utf-8')) - - const configs = (data.configs || []).map((c: Record) => { - const provider = (c.provider as string) || '' - const authProfile = c.authProfile as string | undefined - const profileKey = resolveApiKey(provider, authProfile) - const rawKey = (c.apiKey as string) || '' - const fallbackKey = rawKey.startsWith('enc:') ? '' : rawKey - return { - ...c, - apiKey: profileKey || fallbackKey, - } - }) - return { - configs, - defaultAssistant: data.defaultAssistant ?? null, - fastModel: data.fastModel ?? null, - } - } catch { - return { configs: [], defaultAssistant: null, fastModel: null } - } -} - -function resolveSlot(slot: ModelSlot | null | undefined, configs: AIServiceConfig[]): ModelSlot | null { - if (slot && configs.some((c) => c.id === slot.configId)) return slot - const fallback = configs[0] - if (!fallback) return null - return { configId: fallback.id, modelId: fallback.model } -} - -export function getDefaultAssistantConfig(aiDataDir: string): AIServiceConfig | null { - const store = loadLlmConfig(aiDataDir) - 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 } -} - -// ==================== Config Write Operations ==================== - -const MAX_CONFIG_COUNT = 99 - -function saveLlmConfig(aiDataDir: string, store: AIConfigStore): void { - const configPath = path.join(aiDataDir, 'llm-config.json') - const toSave = { - ...store, - configs: store.configs.map((c) => { - const { apiKey: _k, ...rest } = c - return rest - }), - schemaVersion: 3, - } - fs.writeFileSync(configPath, JSON.stringify(toSave, null, 2), 'utf-8') -} - -export function addLlmConfig( - aiDataDir: string, - config: Omit -): { success: boolean; config?: AIServiceConfig; error?: string } { - const store = loadLlmConfig(aiDataDir) - - if (store.configs.length >= MAX_CONFIG_COUNT) { - return { success: false, error: `Maximum ${MAX_CONFIG_COUNT} configs reached` } - } - - const newConfig: AIServiceConfig = { - ...config, - id: randomUUID(), - } - - const storeForSave = loadRawConfigStore(aiDataDir) - storeForSave.configs.push({ - ...newConfig, - apiKey: '', - }) - - if (storeForSave.configs.length === 1) { - storeForSave.defaultAssistant = { configId: newConfig.id, modelId: newConfig.model || '' } - } - - if (config.apiKey) { - const profileName = config.name?.toLowerCase().replace(/\s+/g, '-') || config.provider - writeAuthProfile(profileName, { - type: 'api_key', - provider: config.provider, - key: config.apiKey, - }) - ;(storeForSave.configs[storeForSave.configs.length - 1] as unknown as Record).authProfile = - profileName - } - - saveLlmConfig(aiDataDir, storeForSave) - return { success: true, config: { ...newConfig, apiKey: '' } } -} - -export function updateLlmConfig( - aiDataDir: string, - id: string, - updates: Partial> -): { success: boolean; error?: string } { - const storeForSave = loadRawConfigStore(aiDataDir) - const index = storeForSave.configs.findIndex((c) => c.id === id) - - if (index === -1) { - return { success: false, error: 'Config not found' } - } - - const { apiKey: newApiKey, ...restUpdates } = updates - const updated = { - ...storeForSave.configs[index], - ...restUpdates, - } - storeForSave.configs[index] = updated - - if (newApiKey) { - const profileName = updated.name?.toLowerCase().replace(/\s+/g, '-') || updated.provider - writeAuthProfile(profileName, { - type: 'api_key', - provider: updated.provider, - key: newApiKey, - }) - ;(storeForSave.configs[index] as unknown as Record).authProfile = profileName - } - - saveLlmConfig(aiDataDir, storeForSave) - return { success: true } -} - -export function deleteLlmConfig(aiDataDir: string, id: string): { success: boolean; error?: string } { - const storeForSave = loadRawConfigStore(aiDataDir) - const index = storeForSave.configs.findIndex((c) => c.id === id) - - if (index === -1) { - return { success: false, error: 'Config not found' } - } - - storeForSave.configs.splice(index, 1) - - const fallback = storeForSave.configs[0] - if (storeForSave.defaultAssistant?.configId === id) { - storeForSave.defaultAssistant = fallback ? { configId: fallback.id, modelId: fallback.model || '' } : null - } - if (storeForSave.fastModel?.configId === id) { - storeForSave.fastModel = fallback ? { configId: fallback.id, modelId: fallback.model || '' } : null - } - - saveLlmConfig(aiDataDir, storeForSave) - return { success: true } -} - -export function setDefaultAssistantSlot( - aiDataDir: string, - configId: string, - modelId: string -): { success: boolean; error?: string } { - const storeForSave = loadRawConfigStore(aiDataDir) - const config = storeForSave.configs.find((c) => c.id === configId) - - if (!config) { - return { success: false, error: 'Config not found' } - } - - storeForSave.defaultAssistant = { configId, modelId } - saveLlmConfig(aiDataDir, storeForSave) - return { success: true } -} - -export function setFastModelSlot(aiDataDir: string, slot: ModelSlot | null): { success: boolean; error?: string } { - const storeForSave = loadRawConfigStore(aiDataDir) - - if (slot !== null) { - const config = storeForSave.configs.find((c) => c.id === slot.configId) - if (!config) { - return { success: false, error: 'Config not found' } - } - } - - storeForSave.fastModel = slot - saveLlmConfig(aiDataDir, storeForSave) - return { success: true } -} - -function loadRawConfigStore(aiDataDir: string): AIConfigStore { - const configPath = path.join(aiDataDir, 'llm-config.json') - if (!fs.existsSync(configPath)) { - return { configs: [], defaultAssistant: null, fastModel: null } - } - try { - const data = JSON.parse(fs.readFileSync(configPath, 'utf-8')) - return { - configs: data.configs || [], - defaultAssistant: data.defaultAssistant ?? null, - fastModel: data.fastModel ?? null, - } - } catch { - return { configs: [], defaultAssistant: null, fastModel: null } - } -} - -// ==================== PiModel Builder ==================== - -export function buildPiModel(config: AIServiceConfig): PiModel { - return buildPiModelCore(config as PiModelConfig) -} diff --git a/apps/cli/src/http/index.ts b/apps/cli/src/http/index.ts index d8863e9c..43a782bb 100644 --- a/apps/cli/src/http/index.ts +++ b/apps/cli/src/http/index.ts @@ -14,10 +14,7 @@ import { NodePathProvider, DatabaseManager, AIChatManager, - CustomProviderStore, - CustomModelStore, - createFileConfigStorage, - createAuthProfileLlmConfigStore, + createLlmRuntimeStores, applyPendingNodeDataDirMigrationIfNeeded, hasPendingElectronDataWarning, verifyCliDataPath, @@ -144,11 +141,12 @@ export async function startHttpServer(options?: HttpServerOptions): Promise<{ const assistantManager = getAssistantManager(aiDataDir) const skillManagerCore = getSkillManagerCore(aiDataDir) - const configStorage = createFileConfigStorage(aiDataDir) - const llmConfigStore = createAuthProfileLlmConfigStore(configStorage) + const llmRuntimeStores = createLlmRuntimeStores(aiDataDir) + const { llmConfigStore, customProviderStore, customModelStore } = llmRuntimeStores initAppLogger(pathProvider.getLogsDir()) initServerAiLogger(pathProvider.getLogsDir()) + appLogger.info('ai-config', 'Shared LLM configuration store initialized', { aiDataDir }) appLogger.info('temp-workspace', 'Temporary workspace initialized', { root: pathProvider.getTempDir() }) appLogger.info('server', `HTTP server starting on ${host}:${port}`) // 记录 Rust native parser 可用性(导入是否走 Rust 内核),便于按日志排查回退原因 @@ -192,9 +190,12 @@ export async function startHttpServer(options?: HttpServerOptions): Promise<{ assistantManager, skillManagerCore, llmConfigStore, - customProviderStore: new CustomProviderStore(configStorage), - customModelStore: new CustomModelStore(configStorage), - runAgentStream: createCliRunAgentStream(dbManager, aiChatManager, semanticIndexService), + customProviderStore, + customModelStore, + runAgentStream: createCliRunAgentStream(dbManager, aiChatManager, { + llmConfigStore, + semanticIndexService, + }), }, }) diff --git a/apps/cli/src/http/routes/web/summaries.ts b/apps/cli/src/http/routes/web/summaries.ts deleted file mode 100644 index fc8d7bee..00000000 --- a/apps/cli/src/http/routes/web/summaries.ts +++ /dev/null @@ -1,64 +0,0 @@ -import type { FastifyInstance } from 'fastify' -import type { DatabaseManager, SessionRuntimeAdapter, SummaryServiceDeps } from '@openchatlab/node-runtime' -import { summaryService } from '@openchatlab/node-runtime' -import { getAiDataDir } from './helpers' -import { getDefaultAssistantConfig, buildPiModel } from '../../../ai/llm-config' - -function createSummaryDeps(dbManager: DatabaseManager): SummaryServiceDeps { - const aiDataDir = getAiDataDir(dbManager) - return { - getLlmConfig() { - return getDefaultAssistantConfig(aiDataDir) - }, - buildPiModel(config) { - return buildPiModel(config as ReturnType & object) - }, - } -} - -export function registerSummaryRoutes( - server: FastifyInstance, - dbManager: DatabaseManager, - adapter: SessionRuntimeAdapter -): void { - const deps = createSummaryDeps(dbManager) - - server.post<{ - Params: { id: string } - Body: { segmentId: number; locale?: string; forceRegenerate?: boolean; strategy?: 'brief' | 'standard' } - }>('/_web/sessions/:id/summaries/generate', async (request, reply) => { - const { segmentId, locale, forceRegenerate, strategy } = request.body - const result = await summaryService.generateSummary(adapter, request.params.id, segmentId, deps, { - locale, - forceRegenerate, - strategy, - }) - if ('error' in result && !result.success) { - return reply.code(400).send({ error: result.error }) - } - return result - }) - - server.post<{ - Params: { id: string } - Body: { locale?: string; forceRegenerate?: boolean } - }>('/_web/sessions/:id/summaries/generate-all', async (request, reply) => { - const { locale, forceRegenerate } = request.body - const result = await summaryService.generateAllSummaries(adapter, request.params.id, deps, { - locale, - forceRegenerate, - }) - if (result.error) { - return reply.code(400).send({ error: result.error }) - } - return result - }) - - server.post<{ - Params: { id: string } - Body: { segmentIds: number[] } - }>('/_web/sessions/:id/summaries/check-can-generate', async (request) => { - const { segmentIds } = request.body - return summaryService.checkCanGenerate(adapter, request.params.id, segmentIds) - }) -} diff --git a/apps/desktop/main/ai/agent-stream-runner.ts b/apps/desktop/main/ai/agent-stream-runner.ts index 226714dc..42e0aced 100644 --- a/apps/desktop/main/ai/agent-stream-runner.ts +++ b/apps/desktop/main/ai/agent-stream-runner.ts @@ -6,7 +6,12 @@ * but outputs events via callback instead of IPC. */ -import type { AgentStreamChunk as SharedAgentStreamChunk, SemanticIndexRuntime } from '@openchatlab/node-runtime' +import type { + AgentStreamChunk as SharedAgentStreamChunk, + AIServiceConfig, + LLMConfigStore, + SemanticIndexRuntime, +} from '@openchatlab/node-runtime' import type { AgentStreamRequest } from '@openchatlab/http-routes' import { getDefaultGeneralAssistantId, type ChartAutoMode } from '@openchatlab/shared-types' import { @@ -25,8 +30,7 @@ import { import type { CompressionConfig, CompressionLlmAdapter, AgentRuntimeStatus } from '@openchatlab/node-runtime' import { Agent, type AgentStreamChunk, type SkillContext } from './agent' import type { ToolContext } from './tools/types' -import { getDefaultAssistantConfig, buildPiModel, findModelDefinition } from './llm' -import type { AIServiceConfig } from './llm/types' +import { buildPiModel, findModelDefinition } from './llm' import * as assistantManager from './assistant/manager' import type { AssistantConfig } from '@openchatlab/node-runtime' import * as skillManager from './skills/manager' @@ -62,6 +66,7 @@ const compressionLogger = { } export function createElectronRunAgentStream( + llmConfigStore: LLMConfigStore, semanticIndexService?: SemanticIndexRuntime ): ( params: AgentStreamRequest, @@ -101,7 +106,7 @@ export function createElectronRunAgentStream( enableAutoSkill: enableAutoSkill ?? false, }) - const activeAIConfig = getDefaultAssistantConfig() + const activeAIConfig = llmConfigStore.getDefaultAssistantConfig() if (!activeAIConfig) { onEvent({ type: 'error', error: { name: 'ConfigError', message: t('llm.notConfigured') } }) return diff --git a/apps/desktop/main/ai/agent/index.ts b/apps/desktop/main/ai/agent/index.ts index c3ef3c67..1b646f80 100644 --- a/apps/desktop/main/ai/agent/index.ts +++ b/apps/desktop/main/ai/agent/index.ts @@ -3,7 +3,6 @@ * 编排 runAgentCore 的对话流程(工具调用、流式输出、中止控制) */ -import { getDefaultAssistantConfig, buildPiModel } from '../llm' import { getAllTools, createActivateSkillTool } from '../tools' import type { ToolContext } from '../tools/types' import { getHistoryForAgent, setPendingDebugContext } from '../chats' @@ -375,42 +374,3 @@ export class Agent { } } } - -/** - * 创建 Agent 并执行对话(便捷函数) - */ -export async function runAgent( - userMessage: string, - context: ToolContext, - config?: AgentConfig, - chatType?: 'group' | 'private', - locale?: string, - assistantConfig?: AssistantConfig, - skillCtx?: SkillContext -): Promise { - 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) - return agent.execute(userMessage) -} - -/** - * 创建 Agent 并流式执行对话(便捷函数) - */ -export async function runAgentStream( - userMessage: string, - context: ToolContext, - onChunk: (chunk: AgentStreamChunk) => void, - config?: AgentConfig, - chatType?: 'group' | 'private', - locale?: string, - assistantConfig?: AssistantConfig, - skillCtx?: SkillContext -): Promise { - 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) - return agent.executeStream(userMessage, onChunk) -} diff --git a/apps/desktop/main/ai/llm/custom-model-store.ts b/apps/desktop/main/ai/llm/custom-model-store.ts deleted file mode 100644 index 66a2804f..00000000 --- a/apps/desktop/main/ai/llm/custom-model-store.ts +++ /dev/null @@ -1,82 +0,0 @@ -/** - * 自定义 Model 持久化存储 - * 文件位置: {aiDataDir}/custom-models.json - */ - -import * as fs from 'fs' -import * as path from 'path' -import { getPathProvider } from '../../paths/provider' -import { aiLogger } from '../logger' -import type { ModelDefinition } from './model-types' - -function getStorePath(): string { - return path.join(getPathProvider().getAiDataDir(), 'custom-models.json') -} - -function readStore(): ModelDefinition[] { - const storePath = getStorePath() - if (!fs.existsSync(storePath)) return [] - - try { - const content = fs.readFileSync(storePath, 'utf-8') - return JSON.parse(content) as ModelDefinition[] - } catch (error) { - aiLogger.error('CustomModelStore', 'Failed to read store', error) - return [] - } -} - -function writeStore(models: ModelDefinition[]): void { - const storePath = getStorePath() - const dir = path.dirname(storePath) - if (!fs.existsSync(dir)) { - fs.mkdirSync(dir, { recursive: true }) - } - fs.writeFileSync(storePath, JSON.stringify(models, null, 2), 'utf-8') -} - -export function loadCustomModels(): ModelDefinition[] { - return readStore() -} - -export function addCustomModel(input: Omit): ModelDefinition { - const models = readStore() - - const existing = models.find((m) => m.id === input.id && m.providerId === input.providerId) - if (existing) { - throw new Error(`Model "${input.id}" already exists under provider "${input.providerId}"`) - } - - const newModel: ModelDefinition = { - ...input, - builtin: false, - editable: true, - } - models.push(newModel) - writeStore(models) - return newModel -} - -export function updateCustomModel( - providerId: string, - modelId: string, - updates: Partial> -): { success: boolean; error?: string } { - const models = readStore() - const index = models.findIndex((m) => m.id === modelId && m.providerId === providerId) - if (index === -1) return { success: false, error: 'Custom model not found' } - - models[index] = { ...models[index], ...updates } - writeStore(models) - return { success: true } -} - -export function deleteCustomModel(providerId: string, modelId: string): { success: boolean; error?: string } { - const models = readStore() - const index = models.findIndex((m) => m.id === modelId && m.providerId === providerId) - if (index === -1) return { success: false, error: 'Custom model not found' } - - models.splice(index, 1) - writeStore(models) - return { success: true } -} diff --git a/apps/desktop/main/ai/llm/custom-provider-store.ts b/apps/desktop/main/ai/llm/custom-provider-store.ts deleted file mode 100644 index d46b9c2f..00000000 --- a/apps/desktop/main/ai/llm/custom-provider-store.ts +++ /dev/null @@ -1,79 +0,0 @@ -/** - * 自定义 Provider 持久化存储 - * 文件位置: {aiDataDir}/custom-providers.json - */ - -import * as fs from 'fs' -import * as path from 'path' -import { randomUUID } from 'crypto' -import { getPathProvider } from '../../paths/provider' -import { aiLogger } from '../logger' -import type { ProviderDefinition } from './model-types' - -function getStorePath(): string { - return path.join(getPathProvider().getAiDataDir(), 'custom-providers.json') -} - -function readStore(): ProviderDefinition[] { - const storePath = getStorePath() - if (!fs.existsSync(storePath)) return [] - - try { - const content = fs.readFileSync(storePath, 'utf-8') - return JSON.parse(content) as ProviderDefinition[] - } catch (error) { - aiLogger.error('CustomProviderStore', 'Failed to read store', error) - return [] - } -} - -function writeStore(providers: ProviderDefinition[]): void { - const storePath = getStorePath() - const dir = path.dirname(storePath) - if (!fs.existsSync(dir)) { - fs.mkdirSync(dir, { recursive: true }) - } - fs.writeFileSync(storePath, JSON.stringify(providers, null, 2), 'utf-8') -} - -export function loadCustomProviders(): ProviderDefinition[] { - return readStore() -} - -export function addCustomProvider( - input: Omit -): ProviderDefinition { - const providers = readStore() - const newProvider: ProviderDefinition = { - ...input, - id: `custom:${randomUUID()}`, - builtin: false, - enabledByDefault: false, - } - providers.push(newProvider) - writeStore(providers) - return newProvider -} - -export function updateCustomProvider( - id: string, - updates: Partial> -): { success: boolean; error?: string } { - const providers = readStore() - const index = providers.findIndex((p) => p.id === id) - if (index === -1) return { success: false, error: 'Custom provider not found' } - - providers[index] = { ...providers[index], ...updates } - writeStore(providers) - return { success: true } -} - -export function deleteCustomProvider(id: string): { success: boolean; error?: string } { - const providers = readStore() - const index = providers.findIndex((p) => p.id === id) - if (index === -1) return { success: false, error: 'Custom provider not found' } - - providers.splice(index, 1) - writeStore(providers) - return { success: true } -} diff --git a/apps/desktop/main/ai/llm/index.ts b/apps/desktop/main/ai/llm/index.ts index 204f8f9f..4ddc2fbb 100644 --- a/apps/desktop/main/ai/llm/index.ts +++ b/apps/desktop/main/ai/llm/index.ts @@ -1,400 +1,110 @@ /** - * LLM 服务模块入口 - * 提供统一的 LLM 服务管理(支持多配置) + * Desktop LLM runtime integration. + * + * Configuration persistence is owned by @openchatlab/node-runtime so Desktop, + * CLI, and CLI Web use the same files and behavior under ~/.chatlab/ai. */ -import * as fs from 'fs' -import * as path from 'path' -import { randomUUID } from 'crypto' -import { getPathProvider } from '../../paths/provider' -import type { LLMProvider, ProviderInfo, AIServiceConfig, AIConfigStore } from './types' -import { MAX_CONFIG_COUNT } from './types' -import { aiLogger } from '../logger' -import { resolveApiKey, writeAuthProfile } from '@openchatlab/config' -import { buildChatLabUserAgentHeaders } from '../../utils/httpHeaders' -import { t } from '../../i18n' +import { + BUILTIN_MODELS, + BUILTIN_PROVIDERS, + getBuiltinModelById, + getBuiltinModelsByProvider, + getBuiltinProviderById, + type ModelDefinition, + type ProviderDefinition, +} from '@openchatlab/core' import { buildPiModel as buildPiModelCore, + createLlmRuntimeStores, fetchRemoteModels as fetchRemoteModelsCore, validateApiKey as validateApiKeyCore, - type PiModel, - type PiApi, + type AIServiceConfig, type FetchRemoteModelsResult, + type LlmRuntimeStores, + type PiApi, + type PiModel, } from '@openchatlab/node-runtime' +import { getPathProvider } from '../../paths/provider' +import { buildChatLabUserAgentHeaders } from '../../utils/httpHeaders' +import { t } from '../../i18n' +import { aiLogger } from '../logger' +import type { LLMProvider, ProviderInfo } from './types' -export { - loadCustomProviders, - addCustomProvider, - updateCustomProvider, - deleteCustomProvider, -} from './custom-provider-store' -export { loadCustomModels, addCustomModel, updateCustomModel, deleteCustomModel } from './custom-model-store' export * from './model-types' - -// 兼容类型导出 export * from './types' -// ==================== 合并 Registry / Catalog(内置 + 自定义)==================== +let runtime: + | { + aiDataDir: string + stores: LlmRuntimeStores + } + | undefined -import { BUILTIN_PROVIDERS, getBuiltinProviderById } from '@openchatlab/core' -import { BUILTIN_MODELS, getBuiltinModelsByProvider } from '@openchatlab/core' -import { loadCustomProviders } from './custom-provider-store' -import { loadCustomModels } from './custom-model-store' -import type { ProviderDefinition, ModelDefinition } from './model-types' +export function getDesktopLlmRuntimeStores(aiDataDir = getPathProvider().getAiDataDir()): LlmRuntimeStores { + if (!runtime || runtime.aiDataDir !== aiDataDir) { + runtime = { + aiDataDir, + stores: createLlmRuntimeStores(aiDataDir, { + t: (key, options) => t(key, options), + }), + } + } + return runtime.stores +} -/** 获取完整 provider registry(内置 + 自定义) */ export function getProviderRegistry(): ProviderDefinition[] { - return [...BUILTIN_PROVIDERS, ...loadCustomProviders()] + return [...BUILTIN_PROVIDERS, ...getDesktopLlmRuntimeStores().customProviderStore.getAll()] } -/** 获取完整 model catalog(内置 + 自定义) */ export function getModelCatalog(): ModelDefinition[] { - return [...BUILTIN_MODELS, ...loadCustomModels()] + return [...BUILTIN_MODELS, ...getDesktopLlmRuntimeStores().customModelStore.getAll()] } -/** 获取指定 provider 下的全部模型(内置 + 自定义) */ export function getModelsByProvider(providerId: string): ModelDefinition[] { - return [...getBuiltinModelsByProvider(providerId), ...loadCustomModels().filter((m) => m.providerId === providerId)] + return [ + ...getBuiltinModelsByProvider(providerId), + ...getDesktopLlmRuntimeStores() + .customModelStore.getAll() + .filter((model) => model.providerId === providerId), + ] } -/** 按 id 查找 provider(内置优先) */ export function getProviderDefinitionById(id: string): ProviderDefinition | null { - return getBuiltinProviderById(id) || loadCustomProviders().find((p) => p.id === id) || null -} - -/** 按 providerId + modelId 查找模型定义(内置优先,再查自定义,最后跨 provider 兜底) */ -export function findModelDefinition(providerId: string, modelId: string): ModelDefinition | null { return ( - getBuiltinModelById(providerId, modelId) || - loadCustomModels().find((m) => m.providerId === providerId && m.id === modelId) || - BUILTIN_MODELS.find((m) => m.id === modelId) || - loadCustomModels().find((m) => m.id === modelId) || + getBuiltinProviderById(id) || + getDesktopLlmRuntimeStores() + .customProviderStore.getAll() + .find((provider) => provider.id === id) || null ) } -function providerDefinitionToInfo(def: ProviderDefinition): ProviderInfo { - const models = getBuiltinModelsByProvider(def.id) +export function findModelDefinition(providerId: string, modelId: string): ModelDefinition | null { + const customModels = getDesktopLlmRuntimeStores().customModelStore.getAll() + return ( + getBuiltinModelById(providerId, modelId) || + customModels.find((model) => model.providerId === providerId && model.id === modelId) || + BUILTIN_MODELS.find((model) => model.id === modelId) || + customModels.find((model) => model.id === modelId) || + null + ) +} + +function providerDefinitionToInfo(definition: ProviderDefinition): ProviderInfo { + const models = getBuiltinModelsByProvider(definition.id) return { - id: def.id, - name: def.name, - defaultBaseUrl: def.defaultBaseUrl, + id: definition.id, + name: definition.name, + defaultBaseUrl: definition.defaultBaseUrl, models: models - .filter((m) => !m.capabilities.includes('embedding') && !m.capabilities.includes('ranking')) - .map((m) => ({ id: m.id, name: m.name, description: m.description })), + .filter((model) => !model.capabilities.includes('embedding') && !model.capabilities.includes('ranking')) + .map((model) => ({ id: model.id, name: model.name, description: model.description })), } } export const PROVIDERS: ProviderInfo[] = BUILTIN_PROVIDERS.map(providerDefinitionToInfo) -// 配置文件路径 -let CONFIG_PATH: string | null = null - -function getConfigPath(): string { - if (CONFIG_PATH) return CONFIG_PATH - CONFIG_PATH = path.join(getPathProvider().getAiDataDir(), 'llm-config.json') - return CONFIG_PATH -} - -// ==================== Electron-specific 增强迁移 ==================== -// Migration Runner (packages/config/src/migrations) 处理核心数据迁移。 -// 以下仅处理 Electron 特有的自定义 provider/model 注册(v2 迁移的补充步骤)。 - -import { addCustomProvider as _addCustomProviderDirect } from './custom-provider-store' -import { addCustomModel as _addCustomModelDirect } from './custom-model-store' -import { getBuiltinModelById } from '@openchatlab/core' - -const LEGACY_PROVIDER_FALLBACKS: Record = { - minimax: { name: 'MiniMax', defaultBaseUrl: 'https://api.minimaxi.com/v1' }, -} - -function ensureCustomProvidersAndModels(configs: AIServiceConfig[]): void { - for (const config of configs) { - const providerId = config.provider - - if (!getBuiltinProviderById(providerId)) { - const fallback = LEGACY_PROVIDER_FALLBACKS[providerId] - if (fallback) { - try { - _addCustomProviderDirect({ - name: fallback.name, - kind: 'openai-compatible', - defaultBaseUrl: fallback.defaultBaseUrl, - authMode: 'api-key', - supportsCustomModels: true, - modelIds: [], - }) - } catch { - // already exists - } - } - } - - if (config.model && getBuiltinProviderById(providerId)) { - if (!getBuiltinModelById(providerId, config.model)) { - try { - _addCustomModelDirect({ - id: config.model, - providerId, - name: config.model, - capabilities: ['chat'], - recommendedFor: ['chat'], - status: 'stable', - }) - } catch { - // already exists - } - } - } - } -} - -/** 解析 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 -} - -// ==================== 多配置管理 ==================== - -/** - * 加载配置存储 - * - * 数据迁移由 MigrationRunner 在应用启动时统一处理。 - * 此函数只读取最新格式,并从 auth-profiles.json 解析 API Key。 - */ -export function loadConfigStore(): AIConfigStore { - const configPath = getConfigPath() - - if (!fs.existsSync(configPath)) { - return { configs: [], defaultAssistant: null, fastModel: null } - } - - try { - const content = fs.readFileSync(configPath, 'utf-8') - const store = JSON.parse(content) as AIConfigStore - - ensureCustomProvidersAndModels(store.configs) - - const resolvedConfigs = store.configs.map((config) => { - const profileKey = resolveApiKey( - config.provider, - (config as unknown as Record).authProfile as string | undefined - ) - return { ...config, apiKey: profileKey || config.apiKey || '' } - }) - - return { - ...store, - configs: resolvedConfigs, - defaultAssistant: resolveSlot(store.defaultAssistant, resolvedConfigs), - fastModel: resolveSlot(store.fastModel, resolvedConfigs), - } - } catch (error) { - aiLogger.error('LLM', 'Failed to load configs', error) - return { configs: [], defaultAssistant: null, fastModel: null } - } -} - -/** - * 保存配置存储 - * API Key 不再存储在 llm-config.json 中,统一由 auth-profiles.json 管理 - */ -export function saveConfigStore(store: AIConfigStore): void { - saveConfigStoreRaw({ - ...store, - configs: store.configs.map((config) => ({ - ...config, - apiKey: '', - })), - }) -} - -function saveConfigStoreRaw(store: AIConfigStore): void { - const configPath = getConfigPath() - const dir = path.dirname(configPath) - - if (!fs.existsSync(dir)) { - fs.mkdirSync(dir, { recursive: true }) - } - - fs.writeFileSync(configPath, JSON.stringify(store, null, 2), 'utf-8') -} - -export function getAllConfigs(): AIServiceConfig[] { - return loadConfigStore().configs -} - -/** 获取默认助手 slot(含 configId + modelId) */ -export function getDefaultAssistantSlot(): import('./model-types').ModelSlot | null { - const store = loadConfigStore() - 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() - if (store.fastModel === null) return null - return resolveSlot(store.fastModel, store.configs) -} - -/** 获取快速模型配置(会话摘要),未配置时回退到默认助手 */ -export function getFastModelConfig(): AIServiceConfig | null { - const store = loadConfigStore() - if (store.fastModel === null) return getDefaultAssistantConfig() - - 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 { - const store = loadConfigStore() - return store.configs.find((c) => c.id === id) || null -} - -export function addConfig(config: Omit): { - success: boolean - config?: AIServiceConfig - error?: string -} { - const store = loadConfigStore() - - if (store.configs.length >= MAX_CONFIG_COUNT) { - return { success: false, error: t('llm.maxConfigs', { count: MAX_CONFIG_COUNT }) } - } - - const now = Date.now() - const newConfig: AIServiceConfig = { - ...config, - id: randomUUID(), - createdAt: now, - updatedAt: now, - } - - store.configs.push(newConfig) - - if (store.configs.length === 1) { - store.defaultAssistant = { configId: newConfig.id, modelId: newConfig.model || '' } - } - - if (newConfig.apiKey) { - const profileName = newConfig.name?.toLowerCase().replace(/\s+/g, '-') || newConfig.provider - writeAuthProfile(profileName, { - type: 'api_key', - provider: newConfig.provider, - key: newConfig.apiKey, - }) - } - - saveConfigStore(store) - return { success: true, config: newConfig } -} - -export function updateConfig( - id: string, - updates: Partial> -): { success: boolean; error?: string } { - const store = loadConfigStore() - const index = store.configs.findIndex((c) => c.id === id) - - if (index === -1) { - return { success: false, error: t('llm.configNotFound') } - } - - const updated = { - ...store.configs[index], - ...updates, - updatedAt: Date.now(), - } - store.configs[index] = updated - - if (updates.apiKey) { - const profileName = updated.name?.toLowerCase().replace(/\s+/g, '-') || updated.provider - writeAuthProfile(profileName, { - type: 'api_key', - provider: updated.provider, - key: updates.apiKey, - }) - } - - saveConfigStore(store) - return { success: true } -} - -export function deleteConfig(id: string): { success: boolean; error?: string } { - const store = loadConfigStore() - const index = store.configs.findIndex((c) => c.id === id) - - if (index === -1) { - return { success: false, error: t('llm.configNotFound') } - } - - store.configs.splice(index, 1) - - 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 } -} - -/** 设置默认助手模型(configId + modelId) */ -export function setDefaultAssistantModel(configId: string, modelId: string): { success: boolean; error?: string } { - const store = loadConfigStore() - const config = store.configs.find((c) => c.id === configId) - - if (!config) { - return { success: false, error: t('llm.configNotFound') } - } - - 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 { - return getDefaultAssistantConfig() !== null -} - function validateProviderBaseUrl(provider: LLMProvider, baseUrl?: string): void { if (!baseUrl) return @@ -423,11 +133,9 @@ function validateProviderBaseUrl(provider: LLMProvider, baseUrl?: string): void } export function getProviderInfo(provider: LLMProvider): ProviderInfo | null { - return PROVIDERS.find((p) => p.id === provider) || null + return PROVIDERS.find((item) => item.id === provider) || null } -// ==================== pi-ai Model 构建 ==================== - export function buildPiModel(config: AIServiceConfig): PiModel { validateProviderBaseUrl(config.provider, config.baseUrl) @@ -437,8 +145,6 @@ export function buildPiModel(config: AIServiceConfig): PiModel { }) } -// ==================== Remote Model API ==================== - const electronRemoteApiOptions = () => ({ headers: buildChatLabUserAgentHeaders(), onLog: (level: 'info' | 'error', tag: string, message: string, data?: unknown) => { diff --git a/apps/desktop/main/ai/llm/types.ts b/apps/desktop/main/ai/llm/types.ts index 8d4ba12d..3943a1db 100644 --- a/apps/desktop/main/ai/llm/types.ts +++ b/apps/desktop/main/ai/llm/types.ts @@ -1,6 +1,4 @@ -/** - * LLM 服务类型定义 - */ +/** Desktop-only LLM presentation types. */ export * from './model-types' @@ -16,25 +14,3 @@ export interface ProviderInfo { description?: string }> } - -export interface AIServiceConfig { - id: string - name: string - provider: LLMProvider - apiKey: string - model?: string - baseUrl?: string - maxTokens?: number - apiFormat?: string - customModels?: Array<{ id: string; name: string }> - createdAt: number - updatedAt: number -} - -export interface AIConfigStore { - configs: AIServiceConfig[] - defaultAssistant: import('./model-types').ModelSlot | null - fastModel: import('./model-types').ModelSlot | null -} - -export const MAX_CONFIG_COUNT = 99 diff --git a/apps/desktop/main/internal-api/server.ts b/apps/desktop/main/internal-api/server.ts index d17d25bf..8c2490cc 100644 --- a/apps/desktop/main/internal-api/server.ts +++ b/apps/desktop/main/internal-api/server.ts @@ -16,10 +16,6 @@ import type { PathProvider } from '@openchatlab/core' import { DatabaseManager, createDatabaseManagerAdapter, - CustomProviderStore, - CustomModelStore, - createFileConfigStorage, - createAuthProfileLlmConfigStore, deletePendingDataDirCleanup, dismissPendingDataDirCleanupNotice, getPendingDataDirCleanups, @@ -42,6 +38,7 @@ import { getManager as getAssistantManager } from '../ai/assistant/manager' import { getManager as getSkillManager } from '../ai/skills/manager' import { aiLogger } from '../ai/logger' import { createElectronRunAgentStream } from '../ai/agent-stream-runner' +import { getDesktopLlmRuntimeStores } from '../ai/llm' import { createExecuteElectronAiTool } from '../ai/tools/debug-executor' import { assertDesktopDataDirCompatible, getDesktopAppVersion } from '../runtime/compat' import { resolveDesktopNativeBinding } from '../runtime/native-sqlite' @@ -90,8 +87,9 @@ export async function startInternalServer( const sessionAdapter = createDatabaseManagerAdapter(newDbManager) const aiDataDir = pathProvider.getAiDataDir() - const configStorage = createFileConfigStorage(aiDataDir) - const llmConfigStore = createAuthProfileLlmConfigStore(configStorage) + const llmRuntimeStores = getDesktopLlmRuntimeStores(aiDataDir) + const { llmConfigStore, customProviderStore, customModelStore } = llmRuntimeStores + appLogger.info('ai-config', 'Shared LLM configuration store initialized', { aiDataDir }) const newMergeCache = new MergeSessionCache(pathProvider, { nativeBinding }) newMergeCache.cleanupOrphans() @@ -155,8 +153,8 @@ export async function startInternalServer( assistantManager: getAssistantManager(), skillManagerCore: getSkillManager(), llmConfigStore, - customProviderStore: new CustomProviderStore(configStorage), - customModelStore: new CustomModelStore(configStorage), + customProviderStore, + customModelStore, getCurrentAiLogPath: () => aiLogger.getExistingLogPath(), automation: { dsManager: dependencies.getDataSourceManager(), @@ -180,7 +178,7 @@ export async function startInternalServer( deletePendingDataDirCleanup: (cleanupId) => { return deletePendingDataDirCleanup(pathProvider.getSystemDir(), pathProvider.getUserDataDir(), cleanupId) }, - runAgentStream: createElectronRunAgentStream(newSemanticIndexService ?? undefined), + runAgentStream: createElectronRunAgentStream(llmConfigStore, newSemanticIndexService ?? undefined), executeAiTool: createExecuteElectronAiTool(newSemanticIndexService ?? undefined), } diff --git a/packages/config/src/atomic-json-file-concurrency.test.ts b/packages/config/src/atomic-json-file-concurrency.test.ts new file mode 100644 index 00000000..06ab9f6f --- /dev/null +++ b/packages/config/src/atomic-json-file-concurrency.test.ts @@ -0,0 +1,86 @@ +import assert from 'node:assert/strict' +import { spawn } from 'node:child_process' +import { createHash } from 'node:crypto' +import * as fs from 'node:fs' +import * as os from 'node:os' +import * as path from 'node:path' +import test from 'node:test' +import { fileURLToPath } from 'node:url' + +function runJsonWriterProcess(filePath: string, workerIndex: number, count: number): Promise { + return new Promise((resolve, reject) => { + const child = spawn( + process.execPath, + [ + '--eval', + ` + require('tsx/cjs/api').register() + const { readJsonFile, withFileLock, writeJsonFileAtomically } = + require(process.env.CHATLAB_ATOMIC_JSON_MODULE) + for (let index = 0; index < Number(process.env.CHATLAB_WRITE_COUNT); index++) { + withFileLock(process.env.CHATLAB_JSON_PATH, () => { + const data = readJsonFile(process.env.CHATLAB_JSON_PATH) || { values: [] } + data.values.push(\`worker-\${process.env.CHATLAB_WORKER_INDEX}-\${index}\`) + writeJsonFileAtomically(process.env.CHATLAB_JSON_PATH, data) + }) + } + `, + ], + { + env: { + ...process.env, + CHATLAB_ATOMIC_JSON_MODULE: fileURLToPath(new URL('./atomic-json-file.ts', import.meta.url)), + CHATLAB_JSON_PATH: filePath, + CHATLAB_WORKER_INDEX: String(workerIndex), + CHATLAB_WRITE_COUNT: String(count), + }, + stdio: ['ignore', 'ignore', 'pipe'], + } + ) + let stderr = '' + child.stderr.setEncoding('utf-8') + child.stderr.on('data', (chunk: string) => { + stderr += chunk + }) + child.once('error', reject) + child.once('exit', (code) => { + if (code === 0) resolve() + else reject(new Error(`Atomic JSON writer exited with code ${code}: ${stderr}`)) + }) + }) +} + +test('stale lock recovery keeps concurrent JSON updates serialized', async (t) => { + const rootDir = fs.mkdtempSync(path.join(os.tmpdir(), 'chatlab-atomic-json-stale-lock-')) + const filePath = path.join(rootDir, 'config.json') + const lockPath = `${filePath}.lock` + t.after(() => fs.rmSync(rootDir, { recursive: true, force: true })) + + fs.writeFileSync(filePath, JSON.stringify({ values: [] }), 'utf-8') + const staleLockToken = '2147483647:abandoned-lock' + fs.writeFileSync(lockPath, staleLockToken, 'utf-8') + const staleTokenHash = createHash('sha256').update(`${lockPath}\0${staleLockToken}`).digest('hex') + fs.writeFileSync( + path.join(rootDir, `.chatlab-lock-recovery-${staleTokenHash}`), + '2147483647:abandoned-recovery', + 'utf-8' + ) + + const workerCount = 8 + const writesPerWorker = 10 + await Promise.all( + Array.from({ length: workerCount }, (_, workerIndex) => + runJsonWriterProcess(filePath, workerIndex, writesPerWorker) + ) + ) + + const data = JSON.parse(fs.readFileSync(filePath, 'utf-8')) as { values: string[] } + assert.equal(data.values.length, workerCount * writesPerWorker) + assert.equal(new Set(data.values).size, workerCount * writesPerWorker) + assert.deepEqual( + fs + .readdirSync(rootDir) + .filter((name) => name.includes('.lock') || name.startsWith('.chatlab-lock-recovery-') || name.includes('.tmp-')), + [] + ) +}) diff --git a/packages/config/src/atomic-json-file.ts b/packages/config/src/atomic-json-file.ts new file mode 100644 index 00000000..283af8dc --- /dev/null +++ b/packages/config/src/atomic-json-file.ts @@ -0,0 +1,122 @@ +import * as fs from 'node:fs' +import * as path from 'node:path' +import { createHash, randomUUID } from 'node:crypto' + +const LOCK_RETRY_MS = 10 +const LOCK_TIMEOUT_MS = 10_000 +const LOCK_STALE_MS = 30_000 +const waitBuffer = new Int32Array(new SharedArrayBuffer(4)) + +function waitForRetry(): void { + Atomics.wait(waitBuffer, 0, 0, LOCK_RETRY_MS) +} + +function isProcessRunning(pid: number): boolean { + if (!Number.isInteger(pid) || pid <= 0) return false + try { + process.kill(pid, 0) + return true + } catch (error) { + return (error as NodeJS.ErrnoException).code === 'EPERM' + } +} + +function getRemovableLockToken(lockPath: string): string | null { + try { + const owner = fs.readFileSync(lockPath, 'utf-8') + const ownerPid = Number.parseInt(owner.split(':', 1)[0] || '', 10) + const ageMs = Date.now() - fs.statSync(lockPath).mtimeMs + if (!Number.isInteger(ownerPid) || ownerPid <= 0) { + return ageMs > LOCK_STALE_MS ? owner : null + } + if (!isProcessRunning(ownerPid)) return owner + return null + } catch { + // A competing process may have created the lock before finishing its write. + return null + } +} + +function tryRecoverStaleLock(lockPath: string, staleToken: string, deadline: number): boolean { + const staleTokenHash = createHash('sha256').update(`${lockPath}\0${staleToken}`).digest('hex') + const recoveryPath = path.join(path.dirname(lockPath), `.chatlab-lock-recovery-${staleTokenHash}`) + const recoveryLock = acquireLockPath(recoveryPath, deadline) + try { + if (fs.readFileSync(lockPath, 'utf-8') !== staleToken) return false + fs.rmSync(lockPath, { force: true }) + return true + } catch { + return false + } finally { + releaseFileLock(recoveryLock.lockPath, recoveryLock.token) + } +} + +function acquireLockPath(lockPath: string, deadline: number): { lockPath: string; token: string } { + const token = `${process.pid}:${randomUUID()}` + fs.mkdirSync(path.dirname(lockPath), { recursive: true }) + + while (true) { + try { + fs.writeFileSync(lockPath, token, { encoding: 'utf-8', flag: 'wx', mode: 0o600 }) + return { lockPath, token } + } catch (error) { + const code = (error as NodeJS.ErrnoException).code + if (code !== 'EEXIST') throw error + + const removableToken = getRemovableLockToken(lockPath) + if (removableToken !== null && tryRecoverStaleLock(lockPath, removableToken, deadline)) { + continue + } + if (Date.now() >= deadline) { + throw new Error(`Timed out waiting for configuration file lock: ${lockPath}`) + } + waitForRetry() + } + } +} + +function acquireFileLock(filePath: string): { lockPath: string; token: string } { + return acquireLockPath(`${filePath}.lock`, Date.now() + LOCK_TIMEOUT_MS) +} + +function releaseFileLock(lockPath: string, token: string): void { + try { + if (fs.readFileSync(lockPath, 'utf-8') === token) { + fs.rmSync(lockPath, { force: true }) + } + } catch { + // The lock may already have been removed after becoming stale. + } +} + +export function withFileLock(filePath: string, action: () => T): T { + const lock = acquireFileLock(filePath) + try { + return action() + } finally { + releaseFileLock(lock.lockPath, lock.token) + } +} + +export function readJsonFile(filePath: string): T | null { + try { + return JSON.parse(fs.readFileSync(filePath, 'utf-8')) as T + } catch { + return null + } +} + +export function writeJsonFileAtomically(filePath: string, data: T, mode?: number): void { + fs.mkdirSync(path.dirname(filePath), { recursive: true }) + const tempPath = `${filePath}.tmp-${process.pid}-${randomUUID()}` + try { + fs.writeFileSync(tempPath, JSON.stringify(data, null, 2), { + encoding: 'utf-8', + ...(mode === undefined ? {} : { mode }), + }) + fs.renameSync(tempPath, filePath) + } finally { + fs.rmSync(tempPath, { force: true }) + } +} diff --git a/packages/config/src/auth-profiles-concurrency.test.ts b/packages/config/src/auth-profiles-concurrency.test.ts new file mode 100644 index 00000000..2dc44b06 --- /dev/null +++ b/packages/config/src/auth-profiles-concurrency.test.ts @@ -0,0 +1,73 @@ +import assert from 'node:assert/strict' +import { spawn } from 'node:child_process' +import * as fs from 'node:fs' +import * as os from 'node:os' +import * as path from 'node:path' +import test from 'node:test' +import { fileURLToPath } from 'node:url' +import type { AuthProfilesData } from './auth-profiles' + +function runAuthProfileWriterProcess(homeDir: string, workerIndex: number, count: number): Promise { + return new Promise((resolve, reject) => { + const child = spawn( + process.execPath, + [ + '--eval', + ` + require('tsx/cjs/api').register() + const { writeAuthProfile } = require(process.env.CHATLAB_AUTH_PROFILES_MODULE) + for (let index = 0; index < Number(process.env.CHATLAB_PROFILE_COUNT); index++) { + writeAuthProfile(\`worker-\${process.env.CHATLAB_WORKER_INDEX}-\${index}\`, { + type: 'api_key', + provider: 'openai', + key: \`secret-\${process.env.CHATLAB_WORKER_INDEX}-\${index}\`, + }) + } + `, + ], + { + env: { + ...process.env, + CHATLAB_AUTH_PROFILES_MODULE: fileURLToPath(new URL('./auth-profiles.ts', import.meta.url)), + CHATLAB_PROFILE_COUNT: String(count), + CHATLAB_WORKER_INDEX: String(workerIndex), + HOME: homeDir, + }, + stdio: ['ignore', 'ignore', 'pipe'], + } + ) + let stderr = '' + child.stderr.setEncoding('utf-8') + child.stderr.on('data', (chunk: string) => { + stderr += chunk + }) + child.once('error', reject) + child.once('exit', (code) => { + if (code === 0) resolve() + else reject(new Error(`Auth profile writer exited with code ${code}: ${stderr}`)) + }) + }) +} + +test('auth profile writes preserve updates from concurrent runtime instances', async (t) => { + const homeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'chatlab-auth-profiles-concurrent-')) + t.after(() => fs.rmSync(homeDir, { recursive: true, force: true })) + + const workerCount = 4 + const profilesPerWorker = 20 + await Promise.all( + Array.from({ length: workerCount }, (_, workerIndex) => + runAuthProfileWriterProcess(homeDir, workerIndex, profilesPerWorker) + ) + ) + + const configDir = path.join(homeDir, '.chatlab') + const data = JSON.parse(fs.readFileSync(path.join(configDir, 'auth-profiles.json'), 'utf-8')) as AuthProfilesData + assert.equal(Object.keys(data.profiles).length, workerCount * profilesPerWorker) + assert.deepEqual( + fs + .readdirSync(configDir) + .filter((name) => name.includes('.lock') || name.startsWith('.chatlab-lock-recovery-') || name.includes('.tmp-')), + [] + ) +}) diff --git a/packages/config/src/auth-profiles.ts b/packages/config/src/auth-profiles.ts index 5df9bf35..3cdc192d 100644 --- a/packages/config/src/auth-profiles.ts +++ b/packages/config/src/auth-profiles.ts @@ -5,9 +5,9 @@ * 用途:将 API Key 等敏感凭证与主配置文件分离 */ -import * as fs from 'fs' import * as path from 'path' import { getConfigDir } from './loader' +import { readJsonFile, withFileLock, writeJsonFileAtomically } from './atomic-json-file' export interface AuthProfile { type: 'api_key' @@ -22,6 +22,26 @@ export interface AuthProfilesData { const AUTH_PROFILES_FILE = 'auth-profiles.json' +export function deriveAuthProfileName(provider: string, config: { baseUrl?: unknown; name?: unknown }): string { + if (provider !== 'openai-compatible') { + return provider.toLowerCase().replace(/\s+/g, '-') + } + + if (typeof config.baseUrl === 'string' && config.baseUrl) { + try { + return new URL(config.baseUrl).hostname.toLowerCase() + } catch { + // Invalid URLs fall back to the config name. + } + } + + if (typeof config.name === 'string' && config.name) { + return config.name.toLowerCase().replace(/\s+/g, '-') + } + + return 'custom' +} + function getAuthProfilesPath(): string { return path.join(getConfigDir(), AUTH_PROFILES_FILE) } @@ -30,21 +50,11 @@ function getAuthProfilesPath(): string { * 加载 auth-profiles.json */ export function loadAuthProfiles(): AuthProfilesData { - const filePath = getAuthProfilesPath() - if (!fs.existsSync(filePath)) { - return { version: 1, profiles: {} } - } - - try { - const content = fs.readFileSync(filePath, 'utf-8') - const data = JSON.parse(content) as AuthProfilesData - if (!data.profiles || typeof data.profiles !== 'object') { - return { version: 1, profiles: {} } - } - return data - } catch { + const data = readJsonFile(getAuthProfilesPath()) + if (!data?.profiles || typeof data.profiles !== 'object') { return { version: 1, profiles: {} } } + return data } /** @@ -84,27 +94,25 @@ export function resolveApiKey(provider: string, authProfile?: string): string { * 写入/更新一个 auth profile */ export function writeAuthProfile(name: string, profile: AuthProfile): void { - const configDir = getConfigDir() - if (!fs.existsSync(configDir)) { - fs.mkdirSync(configDir, { recursive: true }) - } - - const data = loadAuthProfiles() - data.profiles[name] = profile - const filePath = getAuthProfilesPath() - fs.writeFileSync(filePath, JSON.stringify(data, null, 2), { encoding: 'utf-8', mode: 0o600 }) + withFileLock(filePath, () => { + const data = loadAuthProfiles() + data.profiles[name] = profile + writeJsonFileAtomically(filePath, data, 0o600) + }) } /** * 删除一个 auth profile */ export function deleteAuthProfile(name: string): boolean { - const data = loadAuthProfiles() - if (!(name in data.profiles)) return false - - delete data.profiles[name] const filePath = getAuthProfilesPath() - fs.writeFileSync(filePath, JSON.stringify(data, null, 2), { encoding: 'utf-8', mode: 0o600 }) - return true + return withFileLock(filePath, () => { + const data = loadAuthProfiles() + if (!(name in data.profiles)) return false + + delete data.profiles[name] + writeJsonFileAtomically(filePath, data, 0o600) + return true + }) } diff --git a/packages/config/src/index.ts b/packages/config/src/index.ts index 22979078..78f22c82 100644 --- a/packages/config/src/index.ts +++ b/packages/config/src/index.ts @@ -16,7 +16,9 @@ export { resolveApiKey, writeAuthProfile, deleteAuthProfile, + deriveAuthProfileName, } from './auth-profiles' export type { AuthProfile, AuthProfilesData } from './auth-profiles' +export { readJsonFile, withFileLock, writeJsonFileAtomically } from './atomic-json-file' export { MigrationRunner, ALL_MIGRATIONS } from './migrations' export type { Migration, MigrationContext, Logger as MigrationLogger } from './migrations' diff --git a/packages/config/src/migrations/auth-profiles.test.ts b/packages/config/src/migrations/auth-profiles.test.ts index b044a2d0..06c94ec0 100644 --- a/packages/config/src/migrations/auth-profiles.test.ts +++ b/packages/config/src/migrations/auth-profiles.test.ts @@ -106,6 +106,10 @@ describe('m004 encrypted keys to auth profiles migration', () => { readLlmConfig().configs.map((config) => config.apiKey), ['', '', ''] ) + assert.deepEqual( + readLlmConfig().configs.map((config) => config.authProfile), + ['deepseek', 'deepseek-2', 'api.example.com'] + ) assert.equal(readFileSync(join(chatlabDir, '.migration-version'), 'utf-8'), '4') }) diff --git a/packages/config/src/migrations/llm-config.test.ts b/packages/config/src/migrations/llm-config.test.ts new file mode 100644 index 00000000..1308ee3f --- /dev/null +++ b/packages/config/src/migrations/llm-config.test.ts @@ -0,0 +1,63 @@ +import assert from 'node:assert/strict' +import * as fs from 'node:fs' +import * as os from 'node:os' +import * as path from 'node:path' +import test from 'node:test' +import { m001LegacyToMultiConfig } from './m001-legacy-to-multi-config' +import { m002SchemaV2 } from './m002-schema-v2' +import { m003SchemaV3 } from './m003-schema-v3' +import { MigrationRunner } from './runner' + +const logger = { + info: () => undefined, + warn: () => undefined, + error: () => undefined, +} + +test('LLM config migrations use the shared locked storage path through schema v3', async (t) => { + const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'chatlab-llm-config-migrations-')) + const aiDataDir = path.join(dataDir, 'ai') + const configPath = path.join(aiDataDir, 'llm-config.json') + t.after(() => fs.rmSync(dataDir, { recursive: true, force: true })) + + fs.mkdirSync(aiDataDir, { recursive: true }) + fs.writeFileSync( + configPath, + JSON.stringify({ + provider: 'openai', + apiKey: 'legacy-secret', + model: 'gpt-test', + disableThinking: true, + isReasoningModel: true, + }), + 'utf-8' + ) + + const runner = new MigrationRunner([m001LegacyToMultiConfig, m002SchemaV2, m003SchemaV3], { + dataDir, + aiDataDir, + logger, + }) + assert.deepEqual(await runner.run(), { executed: 3, currentVersion: 3 }) + + const migrated = JSON.parse(fs.readFileSync(configPath, 'utf-8')) as { + schemaVersion: number + configs: Array> + defaultAssistant: { configId: string; modelId: string } + fastModel: null + } + assert.equal(migrated.schemaVersion, 3) + assert.equal(migrated.configs.length, 1) + assert.equal(migrated.configs[0].apiKey, 'legacy-secret') + assert.equal(migrated.configs[0].disableThinking, undefined) + assert.equal(migrated.configs[0].isReasoningModel, undefined) + assert.equal(migrated.defaultAssistant.configId, migrated.configs[0].id) + assert.equal(migrated.defaultAssistant.modelId, 'gpt-test') + assert.equal(migrated.fastModel, null) + assert.deepEqual( + fs + .readdirSync(aiDataDir) + .filter((name) => name.includes('.lock') || name.startsWith('.chatlab-lock-recovery-') || name.includes('.tmp-')), + [] + ) +}) diff --git a/packages/config/src/migrations/m001-legacy-to-multi-config.ts b/packages/config/src/migrations/m001-legacy-to-multi-config.ts index daf346a1..52a569fe 100644 --- a/packages/config/src/migrations/m001-legacy-to-multi-config.ts +++ b/packages/config/src/migrations/m001-legacy-to-multi-config.ts @@ -5,9 +5,9 @@ * 新格式:{ configs: [{ id, name, provider, apiKey, model, ... }], defaultAssistant, fastModel } */ -import * as fs from 'fs' import * as path from 'path' import { randomUUID } from 'crypto' +import { readJsonFile, withFileLock, writeJsonFileAtomically } from '../atomic-json-file' import type { Migration, MigrationContext } from './types' interface LegacyFlatConfig { @@ -31,40 +31,33 @@ export const m001LegacyToMultiConfig: Migration = { async up(ctx: MigrationContext) { const configPath = path.join(ctx.aiDataDir, 'llm-config.json') - if (!fs.existsSync(configPath)) return + withFileLock(configPath, () => { + const data = readJsonFile(configPath) + if (!isLegacyFlatConfig(data)) return - const raw = fs.readFileSync(configPath, 'utf-8') - let data: unknown - try { - data = JSON.parse(raw) - } catch { - return - } + ctx.logger.info('Migration', 'Converting legacy flat config to multi-config format') - if (!isLegacyFlatConfig(data)) return + const now = Date.now() + const newConfig = { + id: randomUUID(), + name: data.provider, + provider: data.provider, + apiKey: data.apiKey || '', + model: data.model || '', + maxTokens: data.maxTokens, + createdAt: now, + updatedAt: now, + } - ctx.logger.info('Migration', 'Converting legacy flat config to multi-config format') + const migrated = { + schemaVersion: 1, + configs: [newConfig], + defaultAssistant: { configId: newConfig.id, modelId: newConfig.model }, + fastModel: null, + } - const now = Date.now() - const newConfig = { - id: randomUUID(), - name: data.provider, - provider: data.provider, - apiKey: data.apiKey || '', - model: data.model || '', - maxTokens: data.maxTokens, - createdAt: now, - updatedAt: now, - } - - const migrated = { - schemaVersion: 1, - configs: [newConfig], - defaultAssistant: { configId: newConfig.id, modelId: newConfig.model }, - fastModel: null, - } - - fs.writeFileSync(configPath, JSON.stringify(migrated, null, 2), 'utf-8') - ctx.logger.info('Migration', 'Legacy config migrated to multi-config format') + writeJsonFileAtomically(configPath, migrated) + ctx.logger.info('Migration', 'Legacy config migrated to multi-config format') + }) }, } diff --git a/packages/config/src/migrations/m002-schema-v2.ts b/packages/config/src/migrations/m002-schema-v2.ts index f40f027a..662da7f0 100644 --- a/packages/config/src/migrations/m002-schema-v2.ts +++ b/packages/config/src/migrations/m002-schema-v2.ts @@ -4,13 +4,12 @@ * - 移除 configs 中的 disableThinking / isReasoningModel 字段 * - 更新 schemaVersion 为 2 * - * 注意:原 Electron 版还包含自定义 provider/model 创建逻辑, - * 该逻辑依赖 Electron-specific 模块,在共享 migration 中跳过。 - * Electron 端在 loadConfigStore 中已有回退处理。 + * Legacy custom model registration depends on the shared runtime catalog. + * createLlmRuntimeStores handles that step after loading the migrated store. */ -import * as fs from 'fs' import * as path from 'path' +import { readJsonFile, withFileLock, writeJsonFileAtomically } from '../atomic-json-file' import type { Migration, MigrationContext } from './types' export const m002SchemaV2: Migration = { @@ -20,29 +19,25 @@ export const m002SchemaV2: Migration = { async up(ctx: MigrationContext) { const configPath = path.join(ctx.aiDataDir, 'llm-config.json') - if (!fs.existsSync(configPath)) return + withFileLock(configPath, () => { + const data = readJsonFile>(configPath) + if (!data) return - let data: Record - try { - data = JSON.parse(fs.readFileSync(configPath, 'utf-8')) - } catch { - return - } + const schemaVersion = (data.schemaVersion as number) || 0 + if (schemaVersion >= 2) return - const schemaVersion = (data.schemaVersion as number) || 0 - if (schemaVersion >= 2) return + ctx.logger.info('Migration', 'Upgrading LLM config to schema v2') - ctx.logger.info('Migration', 'Upgrading LLM config to schema v2') + const configs = (data.configs as Record[]) || [] + const cleanedConfigs = configs.map((c) => { + const { disableThinking: _dt, isReasoningModel: _rm, ...rest } = c + return rest + }) - const configs = (data.configs as Record[]) || [] - const cleanedConfigs = configs.map((c) => { - const { disableThinking: _dt, isReasoningModel: _rm, ...rest } = c - return rest + data.configs = cleanedConfigs + data.schemaVersion = 2 + + writeJsonFileAtomically(configPath, data) }) - - data.configs = cleanedConfigs - data.schemaVersion = 2 - - fs.writeFileSync(configPath, JSON.stringify(data, null, 2), 'utf-8') }, } diff --git a/packages/config/src/migrations/m003-schema-v3.ts b/packages/config/src/migrations/m003-schema-v3.ts index 94e57ae4..53da3671 100644 --- a/packages/config/src/migrations/m003-schema-v3.ts +++ b/packages/config/src/migrations/m003-schema-v3.ts @@ -6,8 +6,8 @@ * - 更新 schemaVersion 为 3 */ -import * as fs from 'fs' import * as path from 'path' +import { readJsonFile, withFileLock, writeJsonFileAtomically } from '../atomic-json-file' import type { Migration, MigrationContext } from './types' export const m003SchemaV3: Migration = { @@ -17,33 +17,31 @@ export const m003SchemaV3: Migration = { async up(ctx: MigrationContext) { const configPath = path.join(ctx.aiDataDir, 'llm-config.json') - if (!fs.existsSync(configPath)) return + withFileLock(configPath, () => { + const data = readJsonFile>(configPath) + if (!data) return - let data: Record - try { - data = JSON.parse(fs.readFileSync(configPath, 'utf-8')) - } catch { - return - } + const schemaVersion = (data.schemaVersion as number) || 0 + if (schemaVersion >= 3) return - const schemaVersion = (data.schemaVersion as number) || 0 - if (schemaVersion >= 3) return + ctx.logger.info('Migration', 'Upgrading LLM config to schema v3 (dual-slot)') - ctx.logger.info('Migration', 'Upgrading LLM config to schema v3 (dual-slot)') + const configs = (data.configs as Array<{ id: string; model?: string }>) || [] + const activeConfigId = data.activeConfigId as string | null | undefined - const configs = (data.configs as Array<{ id: string; model?: string }>) || [] - const activeConfigId = data.activeConfigId as string | null | undefined + const resolvedConfig = + activeConfigId && configs.find((c) => c.id === activeConfigId) + ? configs.find((c) => c.id === activeConfigId)! + : (configs[0] ?? null) - const resolvedConfig = - activeConfigId && configs.find((c) => c.id === activeConfigId) - ? configs.find((c) => c.id === activeConfigId)! - : (configs[0] ?? null) + data.defaultAssistant = resolvedConfig + ? { configId: resolvedConfig.id, modelId: resolvedConfig.model || '' } + : null + data.fastModel = data.fastModel ?? null + data.schemaVersion = 3 + delete data.activeConfigId - data.defaultAssistant = resolvedConfig ? { configId: resolvedConfig.id, modelId: resolvedConfig.model || '' } : null - data.fastModel = data.fastModel ?? null - data.schemaVersion = 3 - delete data.activeConfigId - - fs.writeFileSync(configPath, JSON.stringify(data, null, 2), 'utf-8') + writeJsonFileAtomically(configPath, data) + }) }, } diff --git a/packages/config/src/migrations/m004-encrypted-keys-to-auth-profiles.ts b/packages/config/src/migrations/m004-encrypted-keys-to-auth-profiles.ts index e8919a18..79ae56ec 100644 --- a/packages/config/src/migrations/m004-encrypted-keys-to-auth-profiles.ts +++ b/packages/config/src/migrations/m004-encrypted-keys-to-auth-profiles.ts @@ -7,36 +7,11 @@ * - 清空 llm-config.json 中的 apiKey 字段 */ -import * as fs from 'fs' import * as path from 'path' import type { Migration, MigrationContext } from './types' import { isEncrypted, decryptApiKey } from './crypto-legacy' -import { writeAuthProfile } from '../auth-profiles' - -/** - * Derive a human-readable profile name from config fields. - * Official providers → provider id (e.g. "deepseek") - * openai-compatible → hostname from baseUrl (e.g. "api.example.com") or config name - */ -function deriveProfileName(provider: string, config: Record): string { - if (provider !== 'openai-compatible') { - return provider.toLowerCase().replace(/\s+/g, '-') - } - - const baseUrl = config.baseUrl as string - if (baseUrl) { - try { - return new URL(baseUrl).hostname.toLowerCase() - } catch { - // invalid URL, fall through - } - } - - const name = config.name as string - if (name) return name.toLowerCase().replace(/\s+/g, '-') - - return 'custom' -} +import { deriveAuthProfileName, writeAuthProfile } from '../auth-profiles' +import { readJsonFile, withFileLock, writeJsonFileAtomically } from '../atomic-json-file' export const m004EncryptedKeysToAuthProfiles: Migration = { version: 4, @@ -45,57 +20,54 @@ export const m004EncryptedKeysToAuthProfiles: Migration = { async up(ctx: MigrationContext) { const configPath = path.join(ctx.aiDataDir, 'llm-config.json') - if (!fs.existsSync(configPath)) return + withFileLock(configPath, () => { + const data = readJsonFile>(configPath) + if (!data) return - let data: Record - try { - data = JSON.parse(fs.readFileSync(configPath, 'utf-8')) - } catch { - return - } + const configs = (data.configs as Array>) || [] + let migrated = false + const usedProfileNames = new Set() - const configs = (data.configs as Array>) || [] - let migrated = false - const usedProfileNames = new Set() + for (const config of configs) { + const apiKey = config.apiKey as string + if (!apiKey) continue - for (const config of configs) { - const apiKey = config.apiKey as string - if (!apiKey) continue + const provider = (config.provider as string) || 'unknown' + let plainKey: string | null = null - const provider = (config.provider as string) || 'unknown' - let plainKey: string | null = null + if (isEncrypted(apiKey)) { + plainKey = decryptApiKey(apiKey) + if (!plainKey) { + ctx.logger.warn('Migration', `Failed to decrypt API key for "${config.name}", skipping`) + continue + } + } else if (apiKey.length > 0) { + plainKey = apiKey + } - if (isEncrypted(apiKey)) { - plainKey = decryptApiKey(apiKey) - if (!plainKey) { - ctx.logger.warn('Migration', `Failed to decrypt API key for "${config.name}", skipping`) - continue + if (plainKey) { + const baseName = deriveAuthProfileName(provider, config) + + let profileName = baseName + if (usedProfileNames.has(profileName)) { + let i = 2 + while (usedProfileNames.has(`${profileName}-${i}`)) i++ + profileName = `${profileName}-${i}` + } + usedProfileNames.add(profileName) + + writeAuthProfile(profileName, { type: 'api_key', provider, key: plainKey }) + config.authProfile = profileName + config.apiKey = '' + migrated = true + ctx.logger.info('Migration', `Migrated API key → profile "${profileName}"`) } - } else if (apiKey.length > 0) { - plainKey = apiKey } - if (plainKey) { - const baseName = deriveProfileName(provider, config) - - let profileName = baseName - if (usedProfileNames.has(profileName)) { - let i = 2 - while (usedProfileNames.has(`${profileName}-${i}`)) i++ - profileName = `${profileName}-${i}` - } - usedProfileNames.add(profileName) - - writeAuthProfile(profileName, { type: 'api_key', provider, key: plainKey }) - config.apiKey = '' - migrated = true - ctx.logger.info('Migration', `Migrated API key → profile "${profileName}"`) + if (migrated) { + writeJsonFileAtomically(configPath, data) + ctx.logger.info('Migration', 'Cleared API keys from llm-config.json') } - } - - if (migrated) { - fs.writeFileSync(configPath, JSON.stringify(data, null, 2), 'utf-8') - ctx.logger.info('Migration', 'Cleared API keys from llm-config.json') - } + }) }, } diff --git a/packages/node-runtime/src/ai/__tests__/llm-config-store.test.ts b/packages/node-runtime/src/ai/__tests__/llm-config-store.test.ts index 5a185155..4f52a607 100644 --- a/packages/node-runtime/src/ai/__tests__/llm-config-store.test.ts +++ b/packages/node-runtime/src/ai/__tests__/llm-config-store.test.ts @@ -34,6 +34,28 @@ describe('LLMConfigStore', () => { assert.equal(store.hasActiveConfig(), false) }) + it('does not expose an unmigrated encrypted API key as a usable key', () => { + storage.data.set('llm-config', { + configs: [ + { + id: 'legacy', + name: 'Legacy', + provider: 'openai', + apiKey: 'enc:iv:tag:ciphertext', + createdAt: 1, + updatedAt: 1, + }, + ], + defaultAssistant: { configId: 'legacy', modelId: '' }, + fastModel: null, + } satisfies AIConfigStore) + const storeWithAuthResolution = new LLMConfigStore(storage, { + resolveApiKey: () => undefined, + }) + + assert.equal(storeWithAuthResolution.getDefaultAssistantConfig()?.apiKey, '') + }) + it('adds a config', () => { const result = store.addConfig({ name: 'Test', diff --git a/packages/node-runtime/src/ai/auth-profile-llm-config-store.ts b/packages/node-runtime/src/ai/auth-profile-llm-config-store.ts index 855fe336..70225e62 100644 --- a/packages/node-runtime/src/ai/auth-profile-llm-config-store.ts +++ b/packages/node-runtime/src/ai/auth-profile-llm-config-store.ts @@ -1,14 +1,24 @@ -import { deleteAuthProfile, resolveApiKey, writeAuthProfile, type AuthProfile } from '@openchatlab/config' -import { LLMConfigStore, type AIServiceConfig, type ConfigStorage } from './llm-config-store' +import { + deleteAuthProfile, + deriveAuthProfileName, + loadAuthProfiles, + resolveApiKey, + writeAuthProfile, + type AuthProfile, + type AuthProfilesData, +} from '@openchatlab/config' +import { LLMConfigStore, type AIServiceConfig, type ConfigStorage, type LLMConfigStoreDeps } from './llm-config-store' -export interface AuthProfileLlmConfigStoreDeps { +export interface AuthProfileLlmConfigStoreDeps extends Pick { resolveApiKey: (provider: string, authProfile?: string) => string | undefined + loadAuthProfiles: () => AuthProfilesData writeAuthProfile: (name: string, profile: AuthProfile) => void deleteAuthProfile: (name: string) => void } const defaultDeps: AuthProfileLlmConfigStoreDeps = { resolveApiKey: (provider, authProfile) => resolveApiKey(provider, authProfile) || undefined, + loadAuthProfiles, writeAuthProfile, deleteAuthProfile, } @@ -17,20 +27,57 @@ function getAuthProfile(config: AIServiceConfig): string | undefined { return (config as unknown as Record).authProfile as string | undefined } +function getNamedAuthProfile(config: Pick): string { + return config.name?.toLowerCase().replace(/\s+/g, '-') || config.provider +} + +function restoreLegacyAuthProfileReferences(configs: AIServiceConfig[], authProfiles: AuthProfilesData): void { + const usedProfiles = new Set(configs.map(getAuthProfile).filter((name): name is string => Boolean(name))) + + for (const config of configs) { + if (getAuthProfile(config)) continue + + const namedProfile = getNamedAuthProfile(config) + const namedProfileValue = authProfiles.profiles[namedProfile] + if (namedProfileValue?.provider === config.provider && !usedProfiles.has(namedProfile)) { + ;(config as unknown as Record).authProfile = namedProfile + usedProfiles.add(namedProfile) + continue + } + + const baseName = deriveAuthProfileName(config.provider, config) + for (let index = 1; index <= Object.keys(authProfiles.profiles).length; index++) { + const candidate = index === 1 ? baseName : `${baseName}-${index}` + const profile = authProfiles.profiles[candidate] + if (!profile || profile.provider !== config.provider || usedProfiles.has(candidate)) continue + ;(config as unknown as Record).authProfile = candidate + usedProfiles.add(candidate) + break + } + } +} + export function createAuthProfileLlmConfigStore( storage: ConfigStorage, - deps: AuthProfileLlmConfigStoreDeps = defaultDeps + deps: Partial = {} ): LLMConfigStore { + const resolvedDeps = { ...defaultDeps, ...deps } return new LLMConfigStore(storage, { - resolveApiKey: deps.resolveApiKey, + t: deps.t, + generateId: deps.generateId, + onStoreLoaded: (configs) => { + restoreLegacyAuthProfileReferences(configs, resolvedDeps.loadAuthProfiles()) + deps.onStoreLoaded?.(configs) + }, + resolveApiKey: resolvedDeps.resolveApiKey, onApiKeyCreated: (config, apiKey) => { - const profileName = config.name?.toLowerCase().replace(/\s+/g, '-') || config.provider - deps.writeAuthProfile(profileName, { type: 'api_key', provider: config.provider, key: apiKey }) + const profileName = getNamedAuthProfile(config) + resolvedDeps.writeAuthProfile(profileName, { type: 'api_key', provider: config.provider, key: apiKey }) return profileName }, onApiKeyDeleted: (config) => { const profileName = getAuthProfile(config) - if (profileName) deps.deleteAuthProfile(profileName) + if (profileName) resolvedDeps.deleteAuthProfile(profileName) }, }) } diff --git a/packages/node-runtime/src/ai/custom-store.ts b/packages/node-runtime/src/ai/custom-store.ts index 8d14d04b..f96bb087 100644 --- a/packages/node-runtime/src/ai/custom-store.ts +++ b/packages/node-runtime/src/ai/custom-store.ts @@ -13,6 +13,10 @@ function generateCustomId(): string { export class CustomProviderStore { constructor(private storage: ConfigStorage) {} + private withLock(action: () => T): T { + return this.storage.withLock ? this.storage.withLock('custom-providers', action) : action() + } + getAll(): ProviderDefinition[] { return this.storage.readJson('custom-providers') ?? [] } @@ -26,47 +30,57 @@ export class CustomProviderStore { website?: string consoleUrl?: string }): ProviderDefinition { - const providers = this.getAll() - const newProvider: ProviderDefinition = { - id: generateCustomId(), - name: input.name, - kind: (input.kind || 'openai-compatible') as ProviderDefinition['kind'], - defaultBaseUrl: input.defaultBaseUrl, - authMode: 'api-key', - supportsCustomModels: input.supportsCustomModels ?? true, - modelIds: input.modelIds ?? [], - builtin: false, - enabledByDefault: false, - website: input.website, - consoleUrl: input.consoleUrl, - } - providers.push(newProvider) - this.storage.writeJson('custom-providers', providers) - return newProvider + return this.withLock(() => { + const providers = this.getAll() + const newProvider: ProviderDefinition = { + id: generateCustomId(), + name: input.name, + kind: (input.kind || 'openai-compatible') as ProviderDefinition['kind'], + defaultBaseUrl: input.defaultBaseUrl, + authMode: 'api-key', + supportsCustomModels: input.supportsCustomModels ?? true, + modelIds: input.modelIds ?? [], + builtin: false, + enabledByDefault: false, + website: input.website, + consoleUrl: input.consoleUrl, + } + providers.push(newProvider) + this.storage.writeJson('custom-providers', providers) + return newProvider + }) } update(id: string, updates: Partial): { success: boolean; error?: string } { - const providers = this.getAll() - const index = providers.findIndex((p) => p.id === id) - if (index === -1) return { success: false, error: 'Custom provider not found' } - providers[index] = { ...providers[index], ...updates } - this.storage.writeJson('custom-providers', providers) - return { success: true } + return this.withLock(() => { + const providers = this.getAll() + const index = providers.findIndex((p) => p.id === id) + if (index === -1) return { success: false, error: 'Custom provider not found' } + providers[index] = { ...providers[index], ...updates } + this.storage.writeJson('custom-providers', providers) + return { success: true } + }) } delete(id: string): { success: boolean; error?: string } { - const providers = this.getAll() - const index = providers.findIndex((p) => p.id === id) - if (index === -1) return { success: false, error: 'Custom provider not found' } - providers.splice(index, 1) - this.storage.writeJson('custom-providers', providers) - return { success: true } + return this.withLock(() => { + const providers = this.getAll() + const index = providers.findIndex((p) => p.id === id) + if (index === -1) return { success: false, error: 'Custom provider not found' } + providers.splice(index, 1) + this.storage.writeJson('custom-providers', providers) + return { success: true } + }) } } export class CustomModelStore { constructor(private storage: ConfigStorage) {} + private withLock(action: () => T): T { + return this.storage.withLock ? this.storage.withLock('custom-models', action) : action() + } + getAll(): ModelDefinition[] { return this.storage.readJson('custom-models') ?? [] } @@ -81,45 +95,51 @@ export class CustomModelStore { recommendedFor?: string[] status?: string }): { success: boolean; model?: ModelDefinition; error?: string } { - const models = this.getAll() - if (models.find((m) => m.id === input.id && m.providerId === input.providerId)) { - return { - success: false, - error: `Model "${input.id}" already exists under provider "${input.providerId}"`, + return this.withLock(() => { + const models = this.getAll() + if (models.find((m) => m.id === input.id && m.providerId === input.providerId)) { + return { + success: false, + error: `Model "${input.id}" already exists under provider "${input.providerId}"`, + } } - } - const newModel: ModelDefinition = { - id: input.id, - providerId: input.providerId, - name: input.name, - description: input.description, - contextWindow: input.contextWindow, - capabilities: (input.capabilities ?? ['chat']) as ModelDefinition['capabilities'], - recommendedFor: (input.recommendedFor ?? ['chat']) as ModelDefinition['recommendedFor'], - status: (input.status ?? 'stable') as ModelDefinition['status'], - builtin: false, - editable: true, - } - models.push(newModel) - this.storage.writeJson('custom-models', models) - return { success: true, model: newModel } + const newModel: ModelDefinition = { + id: input.id, + providerId: input.providerId, + name: input.name, + description: input.description, + contextWindow: input.contextWindow, + capabilities: (input.capabilities ?? ['chat']) as ModelDefinition['capabilities'], + recommendedFor: (input.recommendedFor ?? ['chat']) as ModelDefinition['recommendedFor'], + status: (input.status ?? 'stable') as ModelDefinition['status'], + builtin: false, + editable: true, + } + models.push(newModel) + this.storage.writeJson('custom-models', models) + return { success: true, model: newModel } + }) } update(providerId: string, modelId: string, updates: Partial): { success: boolean; error?: string } { - const models = this.getAll() - const index = models.findIndex((m) => m.id === modelId && m.providerId === providerId) - if (index === -1) return { success: false, error: 'Custom model not found' } - models[index] = { ...models[index], ...updates } - this.storage.writeJson('custom-models', models) - return { success: true } + return this.withLock(() => { + const models = this.getAll() + const index = models.findIndex((m) => m.id === modelId && m.providerId === providerId) + if (index === -1) return { success: false, error: 'Custom model not found' } + models[index] = { ...models[index], ...updates } + this.storage.writeJson('custom-models', models) + return { success: true } + }) } delete(providerId: string, modelId: string): { success: boolean; error?: string } { - const models = this.getAll() - const index = models.findIndex((m) => m.id === modelId && m.providerId === providerId) - if (index === -1) return { success: false, error: 'Custom model not found' } - models.splice(index, 1) - this.storage.writeJson('custom-models', models) - return { success: true } + return this.withLock(() => { + const models = this.getAll() + const index = models.findIndex((m) => m.id === modelId && m.providerId === providerId) + if (index === -1) return { success: false, error: 'Custom model not found' } + models.splice(index, 1) + this.storage.writeJson('custom-models', models) + return { success: true } + }) } } diff --git a/packages/node-runtime/src/ai/file-config-storage.ts b/packages/node-runtime/src/ai/file-config-storage.ts index 771d3b3d..85661596 100644 --- a/packages/node-runtime/src/ai/file-config-storage.ts +++ b/packages/node-runtime/src/ai/file-config-storage.ts @@ -1,19 +1,31 @@ -import * as fs from 'node:fs' import * as path from 'node:path' +import { readJsonFile, withFileLock, writeJsonFileAtomically } from '@openchatlab/config' import type { ConfigStorage } from './llm-config-store' export function createFileConfigStorage(baseDir: string): ConfigStorage { + const heldLocks = new Set() + const getFilePath = (key: string) => path.join(baseDir, `${key}.json`) + return { readJson(key: string): T | null { - try { - return JSON.parse(fs.readFileSync(path.join(baseDir, `${key}.json`), 'utf-8')) as T - } catch { - return null - } + return readJsonFile(getFilePath(key)) }, writeJson(key: string, data: T): void { - fs.mkdirSync(baseDir, { recursive: true }) - fs.writeFileSync(path.join(baseDir, `${key}.json`), JSON.stringify(data, null, 2), 'utf-8') + const filePath = getFilePath(key) + const write = () => writeJsonFileAtomically(filePath, data) + if (heldLocks.has(key)) write() + else withFileLock(filePath, write) + }, + withLock(key: string, action: () => T): T { + if (heldLocks.has(key)) return action() + return withFileLock(getFilePath(key), () => { + heldLocks.add(key) + try { + return action() + } finally { + heldLocks.delete(key) + } + }) }, } } diff --git a/packages/node-runtime/src/ai/index.ts b/packages/node-runtime/src/ai/index.ts index ecbf7f80..a63a5acd 100644 --- a/packages/node-runtime/src/ai/index.ts +++ b/packages/node-runtime/src/ai/index.ts @@ -177,6 +177,8 @@ export type { AIServiceConfig, AIConfigStore, ConfigStorage, LLMConfigStoreDeps export { createFileConfigStorage } from './file-config-storage' export { createAuthProfileLlmConfigStore } from './auth-profile-llm-config-store' export type { AuthProfileLlmConfigStoreDeps } from './auth-profile-llm-config-store' +export { createLlmRuntimeStores } from './llm-runtime-stores' +export type { CreateLlmRuntimeStoresOptions, LlmRuntimeStores } from './llm-runtime-stores' // Custom Provider/Model Store export { CustomProviderStore, CustomModelStore } from './custom-store' diff --git a/packages/node-runtime/src/ai/llm-config-store.ts b/packages/node-runtime/src/ai/llm-config-store.ts index c8174fb6..83634834 100644 --- a/packages/node-runtime/src/ai/llm-config-store.ts +++ b/packages/node-runtime/src/ai/llm-config-store.ts @@ -35,6 +35,7 @@ export const MAX_CONFIG_COUNT = 99 export interface ConfigStorage { readJson(key: string): T | null writeJson(key: string, data: T): void + withLock?(key: string, action: () => T): T } // ==================== Dependencies ==================== @@ -99,6 +100,10 @@ export class LLMConfigStore { this.onStoreLoaded = deps.onStoreLoaded } + private withStoreLock(action: () => T): T { + return this.storage.withLock ? this.storage.withLock('llm-config', action) : action() + } + loadStore(): AIConfigStore { const store = this.storage.readJson('llm-config') if (!store) { @@ -107,15 +112,19 @@ export class LLMConfigStore { this.onStoreLoaded?.(store.configs) + const sanitizedConfigs = store.configs.map((config) => ({ + ...config, + apiKey: config.apiKey?.startsWith('enc:') ? '' : config.apiKey || '', + })) const resolvedConfigs = this.resolveApiKey - ? store.configs.map((config) => { + ? sanitizedConfigs.map((config) => { const profileKey = this.resolveApiKey!( config.provider, (config as unknown as Record).authProfile as string | undefined ) - return { ...config, apiKey: profileKey || config.apiKey || '' } + return { ...config, apiKey: profileKey || config.apiKey } }) - : store.configs + : sanitizedConfigs return { ...store, @@ -181,131 +190,141 @@ export class LLMConfigStore { config?: AIServiceConfig error?: string } { - const store = this.loadStore() + return this.withStoreLock(() => { + const store = this.loadStore() - if (store.configs.length >= MAX_CONFIG_COUNT) { - return { success: false, error: this.t('llm.maxConfigs', { count: MAX_CONFIG_COUNT }) } - } - - const now = Date.now() - const newConfig: AIServiceConfig = { - ...config, - id: this.generateId(), - createdAt: now, - updatedAt: now, - } - - store.configs.push(newConfig) - - if (store.configs.length === 1) { - store.defaultAssistant = { configId: newConfig.id, modelId: newConfig.model || '' } - } - - if (newConfig.apiKey && this.onApiKeyCreated) { - const profileName = this.onApiKeyCreated(newConfig, newConfig.apiKey) - if (profileName) { - ;(newConfig as unknown as Record).authProfile = profileName + if (store.configs.length >= MAX_CONFIG_COUNT) { + return { success: false, error: this.t('llm.maxConfigs', { count: MAX_CONFIG_COUNT }) } } - } - this.saveStore(store) - return { success: true, config: { ...newConfig, apiKey: '' } } + const now = Date.now() + const newConfig: AIServiceConfig = { + ...config, + id: this.generateId(), + createdAt: now, + updatedAt: now, + } + + store.configs.push(newConfig) + + if (store.configs.length === 1) { + store.defaultAssistant = { configId: newConfig.id, modelId: newConfig.model || '' } + } + + if (newConfig.apiKey && this.onApiKeyCreated) { + const profileName = this.onApiKeyCreated(newConfig, newConfig.apiKey) + if (profileName) { + ;(newConfig as unknown as Record).authProfile = profileName + } + } + + this.saveStore(store) + return { success: true, config: { ...newConfig, apiKey: '' } } + }) } updateConfig( id: string, updates: Partial> ): { success: boolean; error?: string } { - const store = this.loadStore() - const index = store.configs.findIndex((c) => c.id === id) + return this.withStoreLock(() => { + const store = this.loadStore() + const index = store.configs.findIndex((c) => c.id === id) - if (index === -1) { - return { success: false, error: this.t('llm.configNotFound') } - } + if (index === -1) { + return { success: false, error: this.t('llm.configNotFound') } + } - const oldConfig = store.configs[index] - const oldProfileName = (oldConfig as unknown as Record).authProfile as string | undefined + const oldConfig = store.configs[index] + const oldProfileName = (oldConfig as unknown as Record).authProfile as string | undefined - const updated = { - ...oldConfig, - ...updates, - updatedAt: Date.now(), - } - store.configs[index] = updated - let oldProfileToDelete: AIServiceConfig | null = null + const updated = { + ...oldConfig, + ...updates, + updatedAt: Date.now(), + } + store.configs[index] = updated + let oldProfileToDelete: AIServiceConfig | null = null - if (updates.apiKey && this.onApiKeyCreated) { - const profileName = this.onApiKeyCreated(updated, updates.apiKey) - if (profileName) { - ;(store.configs[index] as unknown as Record).authProfile = profileName - if ( - oldProfileName && - oldProfileName !== profileName && - !isAuthProfileUsed(store.configs, oldProfileName, oldConfig.provider) - ) { - oldProfileToDelete = oldConfig + if (updates.apiKey && this.onApiKeyCreated) { + const profileName = this.onApiKeyCreated(updated, updates.apiKey) + if (profileName) { + ;(store.configs[index] as unknown as Record).authProfile = profileName + if ( + oldProfileName && + oldProfileName !== profileName && + !isAuthProfileUsed(store.configs, oldProfileName, oldConfig.provider) + ) { + oldProfileToDelete = oldConfig + } } } - } - this.saveStore(store) - if (oldProfileToDelete) this.onApiKeyDeleted?.(oldProfileToDelete) - return { success: true } + this.saveStore(store) + if (oldProfileToDelete) this.onApiKeyDeleted?.(oldProfileToDelete) + return { success: true } + }) } deleteConfig(id: string): { success: boolean; error?: string } { - const store = this.loadStore() - const index = store.configs.findIndex((c) => c.id === id) + return this.withStoreLock(() => { + const store = this.loadStore() + const index = store.configs.findIndex((c) => c.id === id) - if (index === -1) { - return { success: false, error: this.t('llm.configNotFound') } - } + if (index === -1) { + return { success: false, error: this.t('llm.configNotFound') } + } - const deleted = store.configs[index] - store.configs.splice(index, 1) + const deleted = store.configs[index] + store.configs.splice(index, 1) - 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 - } + 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 + } - this.saveStore(store) - const deletedProfileName = getAuthProfile(deleted) - if (!deletedProfileName || !isAuthProfileUsed(store.configs, deletedProfileName, deleted.provider)) { - this.onApiKeyDeleted?.(deleted) - } - return { success: true } + this.saveStore(store) + const deletedProfileName = getAuthProfile(deleted) + if (!deletedProfileName || !isAuthProfileUsed(store.configs, deletedProfileName, deleted.provider)) { + this.onApiKeyDeleted?.(deleted) + } + return { success: true } + }) } setDefaultAssistantModel(configId: string, modelId: string): { success: boolean; error?: string } { - const store = this.loadStore() - const config = store.configs.find((c) => c.id === configId) + return this.withStoreLock(() => { + const store = this.loadStore() + const config = store.configs.find((c) => c.id === configId) - if (!config) { - return { success: false, error: this.t('llm.configNotFound') } - } - - store.defaultAssistant = { configId, modelId } - this.saveStore(store) - return { success: true } - } - - setFastModel(slot: ModelSlot | null): { success: boolean; error?: string } { - const store = this.loadStore() - - if (slot !== null) { - const config = store.configs.find((c) => c.id === slot.configId) if (!config) { return { success: false, error: this.t('llm.configNotFound') } } - } - store.fastModel = slot - this.saveStore(store) - return { success: true } + store.defaultAssistant = { configId, modelId } + this.saveStore(store) + return { success: true } + }) + } + + setFastModel(slot: ModelSlot | null): { success: boolean; error?: string } { + return this.withStoreLock(() => { + const store = this.loadStore() + + if (slot !== null) { + const config = store.configs.find((c) => c.id === slot.configId) + if (!config) { + return { success: false, error: this.t('llm.configNotFound') } + } + } + + store.fastModel = slot + this.saveStore(store) + return { success: true } + }) } hasActiveConfig(): boolean { diff --git a/packages/node-runtime/src/ai/llm-runtime-stores.test.ts b/packages/node-runtime/src/ai/llm-runtime-stores.test.ts new file mode 100644 index 00000000..fd2108be --- /dev/null +++ b/packages/node-runtime/src/ai/llm-runtime-stores.test.ts @@ -0,0 +1,274 @@ +import assert from 'node:assert/strict' +import * as fs from 'node:fs' +import * as os from 'node:os' +import * as path from 'node:path' +import test from 'node:test' +import { fileURLToPath } from 'node:url' +import { Worker } from 'node:worker_threads' +import type { AuthProfile } from '@openchatlab/config' +import { createFileConfigStorage } from './file-config-storage' +import { createLlmRuntimeStores } from './llm-runtime-stores' + +function runConfigWriterWorker(aiDataDir: string, workerIndex: number, count: number): Promise { + return new Promise((resolve, reject) => { + let completed = false + const worker = new Worker( + ` + const { parentPort, workerData } = require('node:worker_threads') + ;(async () => { + require('tsx/cjs/api').register() + const { createLlmRuntimeStores } = require(workerData.modulePath) + const { llmConfigStore } = createLlmRuntimeStores(workerData.aiDataDir) + for (let index = 0; index < workerData.count; index++) { + const result = llmConfigStore.addConfig({ + name: \`worker-\${workerData.workerIndex}-\${index}\`, + provider: 'openai', + apiKey: '', + }) + if (!result.success) throw new Error(result.error || 'Failed to add config') + } + parentPort.postMessage('done') + })().catch((error) => { + throw error + }) + `, + { + eval: true, + workerData: { + aiDataDir, + count, + modulePath: fileURLToPath(new URL('./llm-runtime-stores.ts', import.meta.url)), + workerIndex, + }, + } + ) + worker.once('message', () => { + completed = true + }) + worker.once('error', reject) + worker.once('exit', (code) => { + if (code !== 0) { + reject(new Error(`Config writer worker exited with code ${code}`)) + } else if (!completed) { + reject(new Error('Config writer worker exited before completing')) + } else { + resolve() + } + }) + }) +} + +test('createLlmRuntimeStores shares configs and catalogs without persisting API keys', (t) => { + const rootDir = fs.mkdtempSync(path.join(os.tmpdir(), 'chatlab-llm-runtime-stores-')) + const aiDataDir = path.join(rootDir, 'ai') + t.after(() => fs.rmSync(rootDir, { recursive: true, force: true })) + + const profiles = new Map([ + ['shared-openai', { type: 'api_key', provider: 'openai', key: 'existing-secret' }], + ]) + const deletedProfiles: string[] = [] + const authProfileDeps = { + resolveApiKey: (_provider: string, profileName?: string) => + profileName && profiles.get(profileName)?.type === 'api_key' + ? (profiles.get(profileName) as Extract).key + : undefined, + writeAuthProfile: (name: string, profile: AuthProfile) => profiles.set(name, profile), + deleteAuthProfile: (name: string) => { + profiles.delete(name) + deletedProfiles.push(name) + }, + } + + const storage = createFileConfigStorage(aiDataDir) + storage.writeJson('llm-config', { + configs: [ + { + id: 'existing-config', + name: 'Existing OpenAI', + provider: 'openai', + apiKey: '', + authProfile: 'shared-openai', + model: 'fixture-custom-model', + createdAt: 1, + updatedAt: 1, + }, + ], + defaultAssistant: { configId: 'existing-config', modelId: 'fixture-custom-model' }, + fastModel: null, + }) + + const firstRuntime = createLlmRuntimeStores(aiDataDir, { + authProfileDeps, + generateId: () => 'second-config', + }) + const secondRuntime = createLlmRuntimeStores(aiDataDir, { authProfileDeps }) + + assert.equal(firstRuntime.llmConfigStore.getDefaultAssistantConfig()?.apiKey, 'existing-secret') + assert.equal(secondRuntime.llmConfigStore.getDefaultAssistantConfig()?.apiKey, 'existing-secret') + assert.equal( + secondRuntime.customModelStore + .getAll() + .filter((model) => model.providerId === 'openai' && model.id === 'fixture-custom-model').length, + 1 + ) + + const customProvider = firstRuntime.customProviderStore.add({ + name: 'Shared Provider', + defaultBaseUrl: 'https://example.com/v1', + }) + assert.equal( + secondRuntime.customProviderStore.getAll().find((provider) => provider.id === customProvider.id)?.name, + 'Shared Provider' + ) + + const added = firstRuntime.llmConfigStore.addConfig({ + name: 'Second OpenAI', + provider: 'openai', + apiKey: 'new-secret', + model: 'gpt-4.1', + }) + assert.equal(added.success, true) + assert.equal(secondRuntime.llmConfigStore.getConfigById('second-config')?.apiKey, 'new-secret') + + const persisted = storage.readJson<{ configs: Array> }>('llm-config') + const persistedSecondConfig = persisted?.configs.find((config) => config.id === 'second-config') + assert.equal(persistedSecondConfig?.apiKey, '') + assert.equal(persistedSecondConfig?.authProfile, 'second-openai') + + assert.equal(secondRuntime.llmConfigStore.deleteConfig('second-config').success, true) + assert.deepEqual(deletedProfiles, ['second-openai']) + assert.equal(firstRuntime.llmConfigStore.getConfigById('second-config'), null) +}) + +test('createLlmRuntimeStores resolves distinct legacy profiles for configs from the same provider', (t) => { + const rootDir = fs.mkdtempSync(path.join(os.tmpdir(), 'chatlab-llm-runtime-legacy-profiles-')) + const aiDataDir = path.join(rootDir, 'ai') + t.after(() => fs.rmSync(rootDir, { recursive: true, force: true })) + + createFileConfigStorage(aiDataDir).writeJson('llm-config', { + configs: [ + { + id: 'primary', + name: 'Primary', + provider: 'deepseek', + apiKey: '', + model: 'deepseek-chat', + createdAt: 1, + updatedAt: 1, + }, + { + id: 'backup', + name: 'Backup', + provider: 'deepseek', + apiKey: '', + model: 'deepseek-chat', + createdAt: 1, + updatedAt: 1, + }, + ], + defaultAssistant: { configId: 'primary', modelId: 'deepseek-chat' }, + fastModel: { configId: 'backup', modelId: 'deepseek-chat' }, + }) + + const profiles: Record = { + deepseek: { type: 'api_key', provider: 'deepseek', key: 'primary-secret' }, + 'deepseek-2': { type: 'api_key', provider: 'deepseek', key: 'backup-secret' }, + } + const runtime = createLlmRuntimeStores(aiDataDir, { + authProfileDeps: { + loadAuthProfiles: () => ({ version: 1, profiles }), + resolveApiKey: (provider, profileName) => { + if (profileName) return profiles[profileName]?.key + return Object.values(profiles).find((profile) => profile.provider === provider)?.key + }, + }, + }) + + const configs = runtime.llmConfigStore.getAllConfigs() + assert.deepEqual( + configs.map((config) => ({ + id: config.id, + authProfile: (config as unknown as Record).authProfile, + apiKey: config.apiKey, + })), + [ + { id: 'primary', authProfile: 'deepseek', apiKey: 'primary-secret' }, + { id: 'backup', authProfile: 'deepseek-2', apiKey: 'backup-secret' }, + ] + ) +}) + +test('createLlmRuntimeStores restores name-based profile references written by legacy runtime stores', (t) => { + const rootDir = fs.mkdtempSync(path.join(os.tmpdir(), 'chatlab-llm-runtime-named-profiles-')) + const aiDataDir = path.join(rootDir, 'ai') + t.after(() => fs.rmSync(rootDir, { recursive: true, force: true })) + + createFileConfigStorage(aiDataDir).writeJson('llm-config', { + configs: [ + { + id: 'work', + name: 'Work OpenAI', + provider: 'openai', + apiKey: '', + createdAt: 1, + updatedAt: 1, + }, + { + id: 'personal', + name: 'Personal OpenAI', + provider: 'openai', + apiKey: '', + createdAt: 1, + updatedAt: 1, + }, + ], + defaultAssistant: { configId: 'work', modelId: '' }, + fastModel: { configId: 'personal', modelId: '' }, + }) + + const profiles: Record = { + 'work-openai': { type: 'api_key', provider: 'openai', key: 'work-secret' }, + 'personal-openai': { type: 'api_key', provider: 'openai', key: 'personal-secret' }, + } + const runtime = createLlmRuntimeStores(aiDataDir, { + authProfileDeps: { + loadAuthProfiles: () => ({ version: 1, profiles }), + resolveApiKey: (_provider, profileName) => (profileName ? profiles[profileName]?.key : undefined), + }, + }) + + assert.deepEqual( + runtime.llmConfigStore.getAllConfigs().map((config) => ({ + authProfile: (config as unknown as Record).authProfile, + apiKey: config.apiKey, + })), + [ + { authProfile: 'work-openai', apiKey: 'work-secret' }, + { authProfile: 'personal-openai', apiKey: 'personal-secret' }, + ] + ) +}) + +test('createLlmRuntimeStores serializes concurrent config writes across runtime instances', async (t) => { + const rootDir = fs.mkdtempSync(path.join(os.tmpdir(), 'chatlab-llm-runtime-concurrent-')) + const aiDataDir = path.join(rootDir, 'ai') + t.after(() => fs.rmSync(rootDir, { recursive: true, force: true })) + + const workerCount = 4 + const configsPerWorker = 20 + await Promise.all( + Array.from({ length: workerCount }, (_, workerIndex) => + runConfigWriterWorker(aiDataDir, workerIndex, configsPerWorker) + ) + ) + + const configs = createLlmRuntimeStores(aiDataDir).llmConfigStore.getAllConfigs() + assert.equal(configs.length, workerCount * configsPerWorker) + assert.equal(new Set(configs.map((config) => config.name)).size, workerCount * configsPerWorker) + assert.deepEqual( + fs + .readdirSync(aiDataDir) + .filter((name) => name.includes('.lock') || name.startsWith('.chatlab-lock-recovery-') || name.includes('.tmp-')), + [] + ) +}) diff --git a/packages/node-runtime/src/ai/llm-runtime-stores.ts b/packages/node-runtime/src/ai/llm-runtime-stores.ts new file mode 100644 index 00000000..c1769880 --- /dev/null +++ b/packages/node-runtime/src/ai/llm-runtime-stores.ts @@ -0,0 +1,58 @@ +import { getBuiltinModelById, getBuiltinProviderById } from '@openchatlab/core' +import { createAuthProfileLlmConfigStore, type AuthProfileLlmConfigStoreDeps } from './auth-profile-llm-config-store' +import { CustomModelStore, CustomProviderStore } from './custom-store' +import { createFileConfigStorage } from './file-config-storage' +import type { AIServiceConfig, LLMConfigStore, LLMConfigStoreDeps } from './llm-config-store' + +export interface LlmRuntimeStores { + llmConfigStore: LLMConfigStore + customProviderStore: CustomProviderStore + customModelStore: CustomModelStore +} + +export interface CreateLlmRuntimeStoresOptions extends Pick { + authProfileDeps?: Partial< + Pick + > +} + +function ensureLegacyCustomModels(configs: AIServiceConfig[], customModelStore: CustomModelStore): void { + for (const config of configs) { + const providerId = config.provider + + if (config.model && getBuiltinProviderById(providerId) && !getBuiltinModelById(providerId, config.model)) { + customModelStore.add({ + id: config.model, + providerId, + name: config.model, + capabilities: ['chat'], + recommendedFor: ['chat'], + status: 'stable', + }) + } + } +} + +export function createLlmRuntimeStores( + aiDataDir: string, + options: CreateLlmRuntimeStoresOptions = {} +): LlmRuntimeStores { + const storage = createFileConfigStorage(aiDataDir) + const customProviderStore = new CustomProviderStore(storage) + const customModelStore = new CustomModelStore(storage) + const llmConfigStore = createAuthProfileLlmConfigStore(storage, { + ...options.authProfileDeps, + t: options.t, + generateId: options.generateId, + onStoreLoaded: (configs) => { + ensureLegacyCustomModels(configs, customModelStore) + options.onStoreLoaded?.(configs) + }, + }) + + return { + llmConfigStore, + customProviderStore, + customModelStore, + } +} diff --git a/packages/node-runtime/src/index.ts b/packages/node-runtime/src/index.ts index a9242372..cdee47fc 100644 --- a/packages/node-runtime/src/index.ts +++ b/packages/node-runtime/src/index.ts @@ -187,8 +187,8 @@ export { } from './ai' export { AIChatManager } from './ai' export { countTokens, countMessagesTokens, initTokenizer } from './ai' -export { createFileConfigStorage, createAuthProfileLlmConfigStore } from './ai' -export type { AuthProfileLlmConfigStoreDeps } from './ai' +export { createFileConfigStorage, createAuthProfileLlmConfigStore, createLlmRuntimeStores } from './ai' +export type { AuthProfileLlmConfigStoreDeps, CreateLlmRuntimeStoresOptions, LlmRuntimeStores } from './ai' // Assistant Manager export { AssistantManager } from './ai'