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