refactor(ai): unify LLM configuration storage

This commit is contained in:
digua committed 2026-07-23 19:46:22 +08:00
1 parent c11ca38472
commit 7bf7768c03
33 files changed
+1245 -1440

No files matched your search

+19 -7
View File
@@ -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<void> {
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,
+4 -4
View File
@@ -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<void> {
skillDef,
compressionConfig,
tools = [],
aiDataDir,
llmConfig,
aiChatManager,
onEvent,
abortSignal,
@@ -89,7 +90,6 @@ export async function runServerAgent(options: RunAgentOptions): Promise<void> {
// 确保 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 })
-142
View File
@@ -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, 'id' | 'builtin' | 'enabledByDefault'>
): 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<Omit<ProviderDefinition, 'id' | 'builtin'>>
): { 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<ModelDefinition, 'builtin' | 'editable'>
): { 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<Omit<ModelDefinition, 'id' | 'providerId' | 'builtin'>>
): { 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 }
}
-256
View File
@@ -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<string, unknown>) => {
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<AIServiceConfig, 'id'>
): { 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<string, unknown>).authProfile =
profileName
}
saveLlmConfig(aiDataDir, storeForSave)
return { success: true, config: { ...newConfig, apiKey: '' } }
}
export function updateLlmConfig(
aiDataDir: string,
id: string,
updates: Partial<Omit<AIServiceConfig, 'id'>>
): { 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<string, unknown>).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<PiApi> {
return buildPiModelCore(config as PiModelConfig)
}
+10 -9
View File
@@ -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,
}),
},
})
-64
View File
@@ -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<typeof getDefaultAssistantConfig> & 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)
})
}