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

@@ -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'