feat(ai): migrate AI CRUD routes to shared package and establish frontend service layer

Extract LLM config, assistant, skill, conversation and summary CRUD
routes from CLI-specific ai.ts into @openchatlab/http-routes, enabling
Electron Internal Server to serve AI endpoints via shared routes.

Frontend service layer:
- Create src/services/{llm,assistant,skill}/ with typed adapters
- Migrate all window.llmApi/assistantApi/skillApi calls to service layer
- Clean up Electron preload: remove CRUD bridges, keep only streaming IPC

Shared AI routes (packages/http-routes):
- ai-llm: config CRUD, provider registry, model catalog, custom store
- ai-assistants: CRUD, import/reimport, tool catalog
- ai-skills: CRUD, import/reimport, builtin catalog
- ai-conversations: conversation/message/debug CRUD
- ai-summaries: summary generation with LLM config injection
- Add requireAi fail-fast option to registerSharedRoutes

Backend improvements:
- Extract CustomProviderStore/CustomModelStore to node-runtime
- Fix authProfile persistence (onApiKeyCreated returns profileName)
- Reuse AI manager singletons in Electron (avoid dual instances)
- Prevent plaintext API key leakage in addConfig response
- Add authProfile regression tests

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
diguaandCursor committed 2026-06-02 15:41:21 +08:00
1 parent 09a7d7c5e2
commit 3ab162162b
42 files changed
+1599 -1305

No files matched your search

+20 -1
View File
@@ -7,7 +7,17 @@
*/
import type { PathProvider } from '@openchatlab/core'
import type { DatabaseManager, SessionRuntimeAdapter, PreferencesManager } from '@openchatlab/node-runtime'
import type {
DatabaseManager,
SessionRuntimeAdapter,
PreferencesManager,
AIConversationManager,
AssistantManager,
SkillManagerCore,
LLMConfigStore,
CustomProviderStore,
CustomModelStore,
} from '@openchatlab/node-runtime'
export interface HttpRouteContext {
dbManager: DatabaseManager
@@ -20,4 +30,13 @@ export interface HttpRouteContext {
nativeBinding?: string
preferencesManager?: PreferencesManager
/** AI subsystem — optional, routes gracefully skip when absent */
aiDataDir?: string
conversationManager?: AIConversationManager
assistantManager?: AssistantManager
skillManagerCore?: SkillManagerCore
llmConfigStore?: LLMConfigStore
customProviderStore?: CustomProviderStore
customModelStore?: CustomModelStore
}
+6
View File
@@ -1,5 +1,6 @@
export type { HttpRouteContext } from './context'
export { registerSharedRoutes } from './register'
export type { SharedRouteOptions } from './register'
export { setAuthToken, setRequireAuth, authHook } from './auth'
export {
ApiError,
@@ -27,3 +28,8 @@ export { registerSqlRoutes } from './routes/web/sql'
export { registerSessionIndexRoutes } from './routes/web/session-index'
export { registerExportRoutes } from './routes/web/export'
export { registerNlpRoutes } from './routes/web/nlp'
export { registerAiAssistantRoutes } from './routes/web/ai-assistants'
export { registerAiSkillRoutes } from './routes/web/ai-skills'
export { registerAiLlmRoutes } from './routes/web/ai-llm'
export { registerAiConversationRoutes } from './routes/web/ai-conversations'
export { registerAiSummaryRoutes } from './routes/web/ai-summaries'
+35 -1
View File
@@ -17,8 +17,22 @@ import { registerSqlRoutes } from './routes/web/sql'
import { registerSessionIndexRoutes } from './routes/web/session-index'
import { registerExportRoutes } from './routes/web/export'
import { registerNlpRoutes } from './routes/web/nlp'
import { registerAiAssistantRoutes } from './routes/web/ai-assistants'
import { registerAiSkillRoutes } from './routes/web/ai-skills'
import { registerAiLlmRoutes } from './routes/web/ai-llm'
import { registerAiConversationRoutes } from './routes/web/ai-conversations'
import { registerAiSummaryRoutes } from './routes/web/ai-summaries'
export function registerSharedRoutes(server: FastifyInstance, ctx: HttpRouteContext): void {
export interface SharedRouteOptions {
/** When true, AI routes will throw on missing dependencies instead of silently skipping */
requireAi?: boolean
}
export function registerSharedRoutes(
server: FastifyInstance,
ctx: HttpRouteContext,
options?: SharedRouteOptions
): void {
// REST API (/api/v1/*)
registerSystemRoutes(server, ctx)
registerRestSessionRoutes(server, ctx)
@@ -32,4 +46,24 @@ export function registerSharedRoutes(server: FastifyInstance, ctx: HttpRouteCont
registerSessionIndexRoutes(server, ctx)
registerExportRoutes(server, ctx)
registerNlpRoutes(server, ctx)
if (options?.requireAi) {
const missing: string[] = []
if (!ctx.aiDataDir) missing.push('aiDataDir')
if (!ctx.conversationManager) missing.push('conversationManager')
if (!ctx.assistantManager) missing.push('assistantManager')
if (!ctx.skillManagerCore) missing.push('skillManagerCore')
if (!ctx.llmConfigStore) missing.push('llmConfigStore')
if (!ctx.customProviderStore) missing.push('customProviderStore')
if (!ctx.customModelStore) missing.push('customModelStore')
if (missing.length > 0) {
throw new Error(`[http-routes] requireAi is set but missing AI dependencies: ${missing.join(', ')}`)
}
}
registerAiAssistantRoutes(server, ctx)
registerAiSkillRoutes(server, ctx)
registerAiLlmRoutes(server, ctx)
registerAiConversationRoutes(server, ctx)
registerAiSummaryRoutes(server, ctx)
}
@@ -0,0 +1,67 @@
import type { FastifyInstance } from 'fastify'
import type { HttpRouteContext } from '../../context'
import type { AssistantConfig } from '@openchatlab/node-runtime'
import { BUILTIN_TOOL_CATALOG } from '@openchatlab/core'
export function registerAiAssistantRoutes(server: FastifyInstance, ctx: HttpRouteContext): void {
// Tool catalog is static data — always available regardless of AI context
server.get('/_web/ai/tools/catalog', async () => {
return BUILTIN_TOOL_CATALOG
})
const mgr = ctx.assistantManager
if (!mgr) return
server.get('/_web/ai/assistants', async () => {
return mgr.getAllAssistants()
})
server.get<{ Params: { id: string } }>('/_web/ai/assistants/:id', async (request, reply) => {
const config = mgr.getAssistantConfig(request.params.id)
if (!config) return reply.code(404).send({ error: 'Not found' })
return config
})
server.post<{
Body: Omit<AssistantConfig, 'id'>
}>('/_web/ai/assistants', async (request) => {
return mgr.createAssistant(request.body)
})
server.put<{
Params: { id: string }
Body: Partial<AssistantConfig>
}>('/_web/ai/assistants/:id', async (request, reply) => {
const result = mgr.updateAssistant(request.params.id, request.body)
if (!result.success) return reply.code(404).send(result)
return result
})
server.delete<{ Params: { id: string } }>('/_web/ai/assistants/:id', async (request, reply) => {
const result = mgr.deleteAssistant(request.params.id)
if (!result.success) return reply.code(400).send(result)
return result
})
server.post<{ Params: { id: string } }>('/_web/ai/assistants/:id/reset', async (request, reply) => {
const result = mgr.resetAssistant(request.params.id)
if (!result.success) return reply.code(400).send(result)
return result
})
server.post<{ Body: { rawMd: string } }>('/_web/ai/assistants/import', async (request) => {
return mgr.importAssistantFromMd(request.body.rawMd)
})
server.post<{ Body: { builtinId: string } }>('/_web/ai/assistants/import-builtin', async (request, reply) => {
const result = mgr.importAssistant(request.body.builtinId)
if (!result.success) return reply.code(400).send(result)
return result
})
server.post<{ Params: { id: string } }>('/_web/ai/assistants/:id/reimport', async (request, reply) => {
const result = mgr.reimportAssistant(request.params.id)
if (!result.success) return reply.code(400).send(result)
return result
})
}
@@ -0,0 +1,162 @@
import type { FastifyInstance } from 'fastify'
import type { HttpRouteContext } from '../../context'
export function registerAiConversationRoutes(server: FastifyInstance, ctx: HttpRouteContext): void {
const cm = ctx.conversationManager
if (!cm) return
// ==================== Conversation CRUD ====================
server.post<{
Body: { sessionId: string; title?: string; assistantId: string }
}>('/_web/ai/conversations', async (request) => {
const { sessionId, title, assistantId } = request.body
return cm.createConversation(sessionId, title, assistantId)
})
server.get<{
Querystring: { sessionId: string }
}>('/_web/ai/conversations', async (request) => {
const { sessionId } = request.query
if (!sessionId) return []
return cm.getConversations(sessionId)
})
server.get<{ Params: { id: string } }>('/_web/ai/conversations/:id', async (request, reply) => {
const conv = cm.getConversation(request.params.id)
if (!conv) return reply.code(404).send({ error: 'Conversation not found' })
return conv
})
server.put<{
Params: { id: string }
Body: { title: string }
}>('/_web/ai/conversations/:id/title', async (request) => {
return cm.updateConversationTitle(request.params.id, request.body.title)
})
server.delete<{ Params: { id: string } }>('/_web/ai/conversations/:id', async (request) => {
return cm.deleteConversation(request.params.id)
})
// ==================== Message CRUD ====================
server.post<{
Params: { id: string }
Body: {
role: 'user' | 'assistant' | 'summary'
content: string
dataKeywords?: string[]
dataMessageCount?: number
contentBlocks?: unknown[]
tokenUsage?: { promptTokens: number; completionTokens: number; totalTokens: number }
}
}>('/_web/ai/conversations/:id/messages', async (request) => {
const { role, content, dataKeywords, dataMessageCount, contentBlocks, tokenUsage } = request.body
return cm.addMessage(
request.params.id,
role,
content,
dataKeywords,
dataMessageCount,
contentBlocks as any,
tokenUsage
)
})
server.get<{ Params: { id: string } }>('/_web/ai/conversations/:id/messages', async (request) => {
return cm.getMessages(request.params.id)
})
server.post<{
Params: { id: string; messageId: string }
}>('/_web/ai/conversations/:id/messages/:messageId/delete-from', async (request, reply) => {
const { id, messageId } = request.params
if (!id || typeof id !== 'string' || !messageId || typeof messageId !== 'string') {
return reply.code(400).send({ error: 'conversation id and messageId are required' })
}
cm.deleteMessagesFrom(id, messageId)
return { success: true }
})
server.post<{
Params: { id: string }
Body: { upToMessageId: string; title?: string }
}>('/_web/ai/conversations/:id/fork', async (request, reply) => {
const { upToMessageId, title } = request.body
if (!upToMessageId || typeof upToMessageId !== 'string') {
return reply.code(400).send({ error: 'upToMessageId is required' })
}
return cm.forkConversation(request.params.id, upToMessageId, title)
})
server.put<{
Params: { messageId: string }
Body: { content: string }
}>('/_web/ai/messages/:messageId/content', async (request, reply) => {
const { content } = request.body
if (!content || typeof content !== 'string') {
return reply.code(400).send({ error: 'content is required' })
}
cm.updateMessageContent(request.params.messageId, content)
return { success: true }
})
server.post<{
Params: { id: string; messageId: string }
}>('/_web/ai/conversations/:id/messages/:messageId/delete-relink', async (request, reply) => {
const { id, messageId } = request.params
if (!id || !messageId) {
return reply.code(400).send({ error: 'conversation id and messageId are required' })
}
cm.deleteAndRelinkMessage(id, messageId)
return { success: true }
})
server.post<{
Params: { id: string }
Body: {
afterMessageId: string
role: 'user' | 'assistant' | 'summary'
content: string
contentBlocks?: unknown[]
tokenUsage?: { promptTokens: number; completionTokens: number; totalTokens: number }
}
}>('/_web/ai/conversations/:id/messages/insert-after', async (request, reply) => {
const { afterMessageId, role, content, contentBlocks, tokenUsage } = request.body
if (!afterMessageId || typeof afterMessageId !== 'string') {
return reply.code(400).send({ error: 'afterMessageId is required' })
}
return cm.insertMessageAfter(request.params.id, afterMessageId, role, content, contentBlocks as any, tokenUsage)
})
server.get<{ Params: { id: string } }>('/_web/ai/conversations/:id/token-usage', async (request) => {
return cm.getConversationTokenUsage(request.params.id)
})
// ==================== Debug ====================
server.get('/_web/ai/debug/schema', async () => {
return cm.getAiSchema()
})
server.post<{
Body: { sql: string }
}>('/_web/ai/debug/execute-sql', async (request, reply) => {
const { sql } = request.body
if (!sql || typeof sql !== 'string') {
return reply.code(400).send({ error: 'sql is required' })
}
try {
return cm.executeAiSQL(sql)
} catch (e) {
const msg = e instanceof Error ? e.message : String(e)
return reply.code(400).send({ error: msg })
}
})
server.post('/_web/ai/debug/clear-debug-context', async () => {
const cleared = cm.clearAllDebugContext()
return { success: true, cleared }
})
}
@@ -0,0 +1,215 @@
import type { FastifyInstance } from 'fastify'
import type { HttpRouteContext } from '../../context'
import { BUILTIN_PROVIDERS, BUILTIN_MODELS, getBuiltinModelsByProvider } from '@openchatlab/core'
import { validateApiKey, fetchRemoteModels } from '@openchatlab/node-runtime'
// ==================== API key display masking ====================
function hasRealApiKey(apiKey: string): boolean {
return !!apiKey && apiKey !== 'sk-no-key-required'
}
function toConfigDisplay(config: Record<string, unknown>): Record<string, unknown> {
const { apiKey: _raw, ...rest } = config
return { ...rest, apiKey: '', apiKeySet: hasRealApiKey(String(_raw || '')) }
}
// ==================== Route registration ====================
export function registerAiLlmRoutes(server: FastifyInstance, ctx: HttpRouteContext): void {
const store = ctx.llmConfigStore
const aiDataDir = ctx.aiDataDir
if (!store || !aiDataDir) return
// ---------- Config CRUD ----------
server.get('/_web/ai/llm/has-config', async () => {
return store.hasActiveConfig()
})
server.get('/_web/ai/llm/configs', async () => {
const data = store.loadStore()
return {
configs: data.configs.map((c) => toConfigDisplay(c as unknown as Record<string, unknown>)),
defaultAssistant: data.defaultAssistant,
fastModel: data.fastModel,
}
})
server.post<{
Body: {
name: string
provider: string
apiKey: string
model?: string
baseUrl?: string
maxTokens?: number
apiFormat?: string
customModels?: Array<{ id: string; name: string }>
}
}>('/_web/ai/llm/configs', async (request) => {
const result = store.addConfig(request.body)
if (result.success && result.config) {
return { ...result, config: toConfigDisplay(result.config as unknown as Record<string, unknown>) }
}
return result
})
server.put<{
Params: { id: string }
Body: {
name?: string
provider?: string
apiKey?: string
model?: string
baseUrl?: string
maxTokens?: number
apiFormat?: string
customModels?: Array<{ id: string; name: string }>
}
}>('/_web/ai/llm/configs/:id', async (request) => {
return store.updateConfig(request.params.id, request.body)
})
server.delete<{ Params: { id: string } }>('/_web/ai/llm/configs/:id', async (request) => {
return store.deleteConfig(request.params.id)
})
server.get('/_web/ai/llm/default-assistant-slot', async () => {
return store.getDefaultAssistantSlot()
})
server.put<{
Body: { configId: string; modelId: string }
}>('/_web/ai/llm/default-assistant-slot', async (request) => {
return store.setDefaultAssistantModel(request.body.configId, request.body.modelId)
})
server.get('/_web/ai/llm/fast-model-slot', async () => {
return store.getFastModelSlot()
})
server.put<{
Body: { configId: string; modelId: string } | null
}>('/_web/ai/llm/fast-model-slot', async (request) => {
return store.setFastModel(request.body)
})
// ---------- Provider Registry & Model Catalog ----------
server.get('/_web/ai/llm/providers', async () => {
return BUILTIN_PROVIDERS.map((p) => {
const models = getBuiltinModelsByProvider(p.id)
return {
id: p.id,
name: p.name,
defaultBaseUrl: p.defaultBaseUrl,
models: models
.filter((m) => !m.capabilities.includes('embedding') && !m.capabilities.includes('ranking'))
.map((m) => ({ id: m.id, name: m.name, description: m.description })),
}
})
})
server.get('/_web/ai/llm/provider-registry', async () => {
const custom = ctx.customProviderStore?.getAll() ?? []
return [...BUILTIN_PROVIDERS, ...custom]
})
server.get('/_web/ai/llm/model-catalog', async () => {
const custom = ctx.customModelStore?.getAll() ?? []
return [...BUILTIN_MODELS, ...custom]
})
// ---------- Custom Provider CRUD ----------
server.post<{
Body: {
name: string
kind: string
defaultBaseUrl: string
supportsCustomModels?: boolean
modelIds?: string[]
website?: string
consoleUrl?: string
}
}>('/_web/ai/llm/custom-providers', async (request, reply) => {
if (!ctx.customProviderStore)
return reply.code(501).send({ success: false, error: 'Custom providers not available' })
return ctx.customProviderStore.add(request.body)
})
server.put<{
Params: { id: string }
Body: Record<string, unknown>
}>('/_web/ai/llm/custom-providers/:id', async (request, reply) => {
if (!ctx.customProviderStore)
return reply.code(501).send({ success: false, error: 'Custom providers not available' })
const result = ctx.customProviderStore.update(request.params.id, request.body as any)
if (!result.success) return reply.code(404).send(result)
return result
})
server.delete<{ Params: { id: string } }>('/_web/ai/llm/custom-providers/:id', async (request, reply) => {
if (!ctx.customProviderStore)
return reply.code(501).send({ success: false, error: 'Custom providers not available' })
const result = ctx.customProviderStore.delete(request.params.id)
if (!result.success) return reply.code(404).send(result)
return result
})
// ---------- Custom Model CRUD ----------
server.post<{
Body: {
id: string
providerId: string
name: string
description?: string
contextWindow?: number
capabilities?: string[]
recommendedFor?: string[]
status?: string
}
}>('/_web/ai/llm/custom-models', async (request, reply) => {
if (!ctx.customModelStore) return reply.code(501).send({ success: false, error: 'Custom models not available' })
const result = ctx.customModelStore.add(request.body)
if (!result.success) return reply.code(409).send(result)
return result
})
server.put<{
Params: { providerId: string; modelId: string }
Body: Record<string, unknown>
}>('/_web/ai/llm/custom-models/:providerId/:modelId', async (request, reply) => {
if (!ctx.customModelStore) return reply.code(501).send({ success: false, error: 'Custom models not available' })
const result = ctx.customModelStore.update(request.params.providerId, request.params.modelId, request.body as any)
if (!result.success) return reply.code(404).send(result)
return result
})
server.delete<{
Params: { providerId: string; modelId: string }
}>('/_web/ai/llm/custom-models/:providerId/:modelId', async (request, reply) => {
if (!ctx.customModelStore) return reply.code(501).send({ success: false, error: 'Custom models not available' })
const result = ctx.customModelStore.delete(request.params.providerId, request.params.modelId)
if (!result.success) return reply.code(404).send(result)
return result
})
// ---------- Remote API ----------
server.post<{
Body: { provider: string; apiKey: string; baseUrl?: string; model?: string; apiFormat?: string }
}>('/_web/ai/llm/validate-key', async (request) => {
const { provider, apiKey, baseUrl, model, apiFormat } = request.body
return validateApiKey(provider, apiKey, baseUrl, model, apiFormat)
})
server.post<{
Body: { provider: string; apiKey: string; baseUrl?: string; apiFormat?: string }
}>('/_web/ai/llm/remote-models', async (request) => {
const { provider, apiKey, baseUrl, apiFormat } = request.body
return fetchRemoteModels(provider, apiKey, baseUrl, apiFormat)
})
}
@@ -0,0 +1,56 @@
import type { FastifyInstance } from 'fastify'
import type { HttpRouteContext } from '../../context'
export function registerAiSkillRoutes(server: FastifyInstance, ctx: HttpRouteContext): void {
const mgr = ctx.skillManagerCore
if (!mgr) return
server.get('/_web/ai/skills', async () => {
return mgr.getAllSkills()
})
server.get<{ Params: { id: string } }>('/_web/ai/skills/:id', async (request, reply) => {
const config = mgr.getSkillConfig(request.params.id)
if (!config) return reply.code(404).send({ error: 'Not found' })
return config
})
server.post<{ Body: { rawMd: string } }>('/_web/ai/skills', async (request) => {
return mgr.createSkill(request.body.rawMd)
})
server.put<{
Params: { id: string }
Body: { rawMd: string }
}>('/_web/ai/skills/:id', async (request, reply) => {
const result = mgr.updateSkill(request.params.id, request.body.rawMd)
if (!result.success) return reply.code(404).send(result)
return result
})
server.delete<{ Params: { id: string } }>('/_web/ai/skills/:id', async (request, reply) => {
const result = mgr.deleteSkill(request.params.id)
if (!result.success) return reply.code(400).send(result)
return result
})
server.post<{ Body: { rawMd: string } }>('/_web/ai/skills/import', async (request) => {
return mgr.importSkillFromMd(request.body.rawMd)
})
server.get('/_web/ai/skills/builtin-catalog', async () => {
return mgr.getBuiltinCatalog()
})
server.post<{ Body: { builtinId: string } }>('/_web/ai/skills/import-builtin', async (request, reply) => {
const result = mgr.importSkill(request.body.builtinId)
if (!result.success) return reply.code(400).send(result)
return result
})
server.post<{ Params: { id: string } }>('/_web/ai/skills/:id/reimport', async (request, reply) => {
const result = mgr.reimportSkill(request.params.id)
if (!result.success) return reply.code(400).send(result)
return result
})
}
@@ -0,0 +1,65 @@
import type { FastifyInstance } from 'fastify'
import type { HttpRouteContext } from '../../context'
import { summaryService, buildPiModel } from '@openchatlab/node-runtime'
import type { SummaryServiceDeps, LlmConfig, PiModelConfig } from '@openchatlab/node-runtime'
function createSummaryDeps(ctx: HttpRouteContext): SummaryServiceDeps | null {
const store = ctx.llmConfigStore
if (!store) return null
return {
getLlmConfig(): LlmConfig | null {
const config = store.getDefaultAssistantConfig()
if (!config) return null
return config
},
buildPiModel(config: LlmConfig) {
return buildPiModel(config as unknown as PiModelConfig)
},
}
}
export function registerAiSummaryRoutes(server: FastifyInstance, ctx: HttpRouteContext): void {
const deps = createSummaryDeps(ctx)
if (!deps) return
const { sessionAdapter: adapter } = ctx
server.post<{
Params: { id: string }
Body: { chatSessionId: number; locale?: string; forceRegenerate?: boolean; strategy?: 'brief' | 'standard' }
}>('/_web/sessions/:id/summaries/generate', async (request, reply) => {
const { chatSessionId, locale, forceRegenerate, strategy } = request.body
const result = await summaryService.generateSummary(adapter, request.params.id, chatSessionId, 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: { chatSessionIds: number[] }
}>('/_web/sessions/:id/summaries/check-can-generate', async (request) => {
const { chatSessionIds } = request.body
return summaryService.checkCanGenerate(adapter, request.params.id, chatSessionIds)
})
}
@@ -129,4 +129,73 @@ describe('LLMConfigStore', () => {
assert.equal(captured[0].name, 'Hooked')
assert.equal(captured[0].key, 'my-key')
})
it('persists authProfile from onApiKeyCreated return value on addConfig', () => {
const storeWithAuth = new LLMConfigStore(storage, {
generateId: () => `id-${++idCounter}`,
onApiKeyCreated: (config) => {
return config.name.toLowerCase().replace(/\s+/g, '-')
},
resolveApiKey: (_provider, authProfile) => {
if (authProfile === 'my-openai') return 'resolved-key'
return undefined
},
})
storeWithAuth.addConfig({ name: 'My OpenAI', provider: 'openai', apiKey: 'sk-secret' })
const raw = storage.data.get('llm-config') as AIConfigStore
const savedConfig = raw.configs[0] as unknown as Record<string, unknown>
assert.equal(savedConfig.authProfile, 'my-openai')
assert.equal(savedConfig.apiKey, '', 'apiKey should be cleared on disk')
const loaded = storeWithAuth.getAllConfigs()
assert.equal(loaded[0].apiKey, 'resolved-key', 'resolveApiKey should use authProfile')
})
it('persists authProfile on updateConfig when key changes', () => {
const storeWithAuth = new LLMConfigStore(storage, {
generateId: () => `id-${++idCounter}`,
onApiKeyCreated: (config) => {
return config.name.toLowerCase().replace(/\s+/g, '-')
},
})
storeWithAuth.addConfig({ name: 'Test Config', provider: 'openai', apiKey: 'sk-old' })
storeWithAuth.updateConfig('id-1', { apiKey: 'sk-new' })
const raw = storage.data.get('llm-config') as AIConfigStore
const savedConfig = raw.configs[0] as unknown as Record<string, unknown>
assert.equal(savedConfig.authProfile, 'test-config')
})
it('addConfig returns config with apiKey cleared', () => {
const result = store.addConfig({ name: 'A', provider: 'openai', apiKey: 'sk-secret' })
assert.ok(result.success)
assert.equal(result.config!.apiKey, '', 'returned config should not leak apiKey')
})
it('resolves correct key for same-provider configs with different authProfiles', () => {
const profiles = new Map<string, string>()
const storeWithAuth = new LLMConfigStore(storage, {
generateId: () => `id-${++idCounter}`,
onApiKeyCreated: (config, apiKey) => {
const profileName = config.name.toLowerCase().replace(/\s+/g, '-')
profiles.set(profileName, apiKey)
return profileName
},
resolveApiKey: (_provider, authProfile) => {
if (authProfile) return profiles.get(authProfile)
return undefined
},
})
storeWithAuth.addConfig({ name: 'Work OpenAI', provider: 'openai', apiKey: 'sk-work' })
storeWithAuth.addConfig({ name: 'Personal OpenAI', provider: 'openai', apiKey: 'sk-personal' })
const configs = storeWithAuth.getAllConfigs()
assert.equal(configs.length, 2)
assert.equal(configs[0].apiKey, 'sk-work')
assert.equal(configs[1].apiKey, 'sk-personal')
})
})
@@ -0,0 +1,125 @@
/**
* Custom provider/model persistence — platform-agnostic.
* Uses ConfigStorage abstraction so both CLI and Electron share the same logic.
*/
import type { ConfigStorage } from './llm-config-store'
import type { ProviderDefinition, ModelDefinition } from '@openchatlab/core'
function generateCustomId(): string {
return `custom:${Date.now()}-${Math.random().toString(36).slice(2, 10)}`
}
export class CustomProviderStore {
constructor(private storage: ConfigStorage) {}
getAll(): ProviderDefinition[] {
return this.storage.readJson<ProviderDefinition[]>('custom-providers') ?? []
}
add(input: {
name: string
kind?: string
defaultBaseUrl: string
supportsCustomModels?: boolean
modelIds?: string[]
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
}
update(id: string, updates: Partial<ProviderDefinition>): { 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 }
}
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 }
}
}
export class CustomModelStore {
constructor(private storage: ConfigStorage) {}
getAll(): ModelDefinition[] {
return this.storage.readJson<ModelDefinition[]>('custom-models') ?? []
}
add(input: {
id: string
providerId: string
name: string
description?: string
contextWindow?: number
capabilities?: string[]
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}"`,
}
}
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<ModelDefinition>): { 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 }
}
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 }
}
}
+3
View File
@@ -126,6 +126,9 @@ export type { SummaryDeps, SummaryMessage, SummaryOptions, SummaryResult, Summar
export { LLMConfigStore, MAX_CONFIG_COUNT } from './llm-config-store'
export type { AIServiceConfig, AIConfigStore, ConfigStorage, LLMConfigStoreDeps } from './llm-config-store'
// Custom Provider/Model Store
export { CustomProviderStore, CustomModelStore } from './custom-store'
// LLM Model Builder
export { buildPiModel, normalizeAnthropicBaseUrl, normalizeOpenAICompatibleBaseUrl } from './llm-builder'
export type { PiModelConfig, BuildPiModelOptions } from './llm-builder'
@@ -42,7 +42,8 @@ export interface ConfigStorage {
export interface LLMConfigStoreDeps {
t?: (key: string, options?: Record<string, unknown>) => string
generateId?: () => string
onApiKeyCreated?: (config: AIServiceConfig, apiKey: string) => void
/** Returns the auth profile name so LLMConfigStore can persist it on the config */
onApiKeyCreated?: (config: AIServiceConfig, apiKey: string) => string | void
resolveApiKey?: (provider: string, authProfile?: string) => string | undefined
onStoreLoaded?: (configs: AIServiceConfig[]) => void
}
@@ -69,7 +70,7 @@ export class LLMConfigStore {
private storage: ConfigStorage
private t: (key: string, options?: Record<string, unknown>) => string
private generateId: () => string
private onApiKeyCreated?: (config: AIServiceConfig, apiKey: string) => void
private onApiKeyCreated?: (config: AIServiceConfig, apiKey: string) => string | void
private resolveApiKey?: (provider: string, authProfile?: string) => string | undefined
private onStoreLoaded?: (configs: AIServiceConfig[]) => void
@@ -185,11 +186,14 @@ export class LLMConfigStore {
}
if (newConfig.apiKey && this.onApiKeyCreated) {
this.onApiKeyCreated(newConfig, newConfig.apiKey)
const profileName = this.onApiKeyCreated(newConfig, newConfig.apiKey)
if (profileName) {
;(newConfig as unknown as Record<string, unknown>).authProfile = profileName
}
}
this.saveStore(store)
return { success: true, config: newConfig }
return { success: true, config: { ...newConfig, apiKey: '' } }
}
updateConfig(
@@ -211,7 +215,10 @@ export class LLMConfigStore {
store.configs[index] = updated
if (updates.apiKey && this.onApiKeyCreated) {
this.onApiKeyCreated(updated, updates.apiKey)
const profileName = this.onApiKeyCreated(updated, updates.apiKey)
if (profileName) {
;(store.configs[index] as unknown as Record<string, unknown>).authProfile = profileName
}
}
this.saveStore(store)
+3
View File
@@ -167,6 +167,9 @@ export type { SummaryDeps, SummaryMessage, SummaryOptions, SummaryResult, Summar
export { LLMConfigStore, MAX_CONFIG_COUNT } from './ai'
export type { AIServiceConfig, AIConfigStore, ConfigStorage, LLMConfigStoreDeps } from './ai'
// Custom Provider/Model Store
export { CustomProviderStore, CustomModelStore } from './ai'
// Agent Event Handler
export { AgentEventHandler, estimateTokensFromText } from './ai'
export type { TokenUsage, AgentRuntimeStatus, AgentStreamChunk, EventHandlerConfig, EventHandlerContext } from './ai'