mirror of
https://github.com/hellodigua/ChatLab.git
synced 2026-10-26 02:04:37 +08:00
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:
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 }
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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'
|
||||
|
||||
Reference in new issue
Block a user