From 3ab162162bb82027bb6968c010316c756d704bdb Mon Sep 17 00:00:00 2001 From: digua Date: Sun, 31 May 2026 23:30:13 +0800 Subject: [PATCH] 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 --- apps/cli/src/http/index.ts | 51 +- apps/cli/src/http/routes/ai.ts | 557 +----------------- apps/cli/src/http/routes/web/index.ts | 52 +- apps/desktop/main/ai/assistant/manager.ts | 2 +- apps/desktop/main/ai/skills/manager.ts | 2 +- apps/desktop/main/internal-api.ts | 70 ++- apps/desktop/preload/apis/ai.ts | 335 +---------- apps/desktop/preload/index.d.ts | 213 +------ apps/desktop/preload/index.ts | 8 +- packages/http-routes/src/context.ts | 21 +- packages/http-routes/src/index.ts | 6 + packages/http-routes/src/register.ts | 36 +- .../src/routes/web/ai-assistants.ts | 67 +++ .../src/routes/web/ai-conversations.ts | 162 +++++ packages/http-routes/src/routes/web/ai-llm.ts | 215 +++++++ .../http-routes/src/routes/web/ai-skills.ts | 56 ++ .../src/routes/web/ai-summaries.ts | 65 ++ .../src/ai/__tests__/llm-config-store.test.ts | 69 +++ packages/node-runtime/src/ai/custom-store.ts | 125 ++++ packages/node-runtime/src/ai/index.ts | 3 + .../node-runtime/src/ai/llm-config-store.ts | 17 +- packages/node-runtime/src/index.ts | 3 + src/components/AIChat/ChatExplorer.vue | 4 +- .../analysis/SQLLab/ResultTable.vue | 3 +- src/components/analysis/SQLLabTab.vue | 4 +- .../common/Settings/AI/AIModelConfigTab.vue | 3 +- .../common/Settings/AI/useAIConfigForm.ts | 24 +- src/services/assistant/fetch.ts | 53 ++ src/services/assistant/service.ts | 6 + src/services/assistant/types.ts | 43 ++ src/services/index.ts | 16 + src/services/llm/fetch.ts | 115 ++++ src/services/llm/service.ts | 6 + src/services/llm/types.ts | 141 +++++ src/services/registry.ts | 117 +--- src/services/skill/fetch.ts | 48 ++ src/services/skill/service.ts | 6 + src/services/skill/types.ts | 37 ++ src/stores/aiChat.ts | 8 +- src/stores/assistant.ts | 54 +- src/stores/llm.ts | 49 +- src/stores/skill.ts | 32 +- 42 files changed, 1599 insertions(+), 1305 deletions(-) create mode 100644 packages/http-routes/src/routes/web/ai-assistants.ts create mode 100644 packages/http-routes/src/routes/web/ai-conversations.ts create mode 100644 packages/http-routes/src/routes/web/ai-llm.ts create mode 100644 packages/http-routes/src/routes/web/ai-skills.ts create mode 100644 packages/http-routes/src/routes/web/ai-summaries.ts create mode 100644 packages/node-runtime/src/ai/custom-store.ts create mode 100644 src/services/assistant/fetch.ts create mode 100644 src/services/assistant/service.ts create mode 100644 src/services/assistant/types.ts create mode 100644 src/services/llm/fetch.ts create mode 100644 src/services/llm/service.ts create mode 100644 src/services/llm/types.ts create mode 100644 src/services/skill/fetch.ts create mode 100644 src/services/skill/service.ts create mode 100644 src/services/skill/types.ts diff --git a/apps/cli/src/http/index.ts b/apps/cli/src/http/index.ts index 809b8485..2238e495 100644 --- a/apps/cli/src/http/index.ts +++ b/apps/cli/src/http/index.ts @@ -14,22 +14,44 @@ import { NodePathProvider, DatabaseManager, AIConversationManager, + LLMConfigStore, + CustomProviderStore, + CustomModelStore, hasPendingElectronDataWarning, verifyCliDataPath, } from '@openchatlab/node-runtime' +import type { ConfigStorage } from '@openchatlab/node-runtime' import { createServer } from './server' import { setAuthToken, setRequireAuth } from '@openchatlab/http-routes' import { registerWebRoutes } from './routes/web' import { registerAiRoutes } from './routes/ai' import { registerProxyRoutes } from './routes/proxy' import { initServerAiLogger, closeServerAiLogger } from '../ai/logger' +import { getAssistantManager, getSkillManagerCore } from '../ai/manager-factory' import { initSync, cleanupSync } from '../sync' import { resolveCliPath } from '../paths' +import { resolveApiKey, writeAuthProfile } from '@openchatlab/config' let server: FastifyInstance | null = null let dbManager: DatabaseManager | null = null let convManager: AIConversationManager | null = null +function createFileConfigStorage(aiDataDir: string): ConfigStorage { + return { + readJson(key: string): T | null { + try { + return JSON.parse(fs.readFileSync(`${aiDataDir}/${key}.json`, 'utf-8')) as T + } catch { + return null + } + }, + writeJson(key: string, data: T): void { + if (!fs.existsSync(aiDataDir)) fs.mkdirSync(aiDataDir, { recursive: true }) + fs.writeFileSync(`${aiDataDir}/${key}.json`, JSON.stringify(data, null, 2), 'utf-8') + }, + } +} + export interface HttpServerOptions { port?: number host?: string @@ -115,7 +137,20 @@ export async function startHttpServer(options?: HttpServerOptions): Promise<{ await migrationRunner.run() const nativeBinding = resolveNativeBinding() dbManager = new DatabaseManager(pathProvider, { nativeBinding }) - convManager = new AIConversationManager(pathProvider.getAiDataDir(), { nativeBinding }) + + const aiDataDir = pathProvider.getAiDataDir() + convManager = new AIConversationManager(aiDataDir, { nativeBinding }) + + const assistantManager = getAssistantManager(aiDataDir) + const skillManagerCore = getSkillManagerCore(aiDataDir) + const llmConfigStore = new LLMConfigStore(createFileConfigStorage(aiDataDir), { + resolveApiKey: (provider, authProfile) => resolveApiKey(provider, authProfile) || undefined, + onApiKeyCreated: (config, apiKey) => { + const profileName = config.name?.toLowerCase().replace(/\s+/g, '-') || config.provider + writeAuthProfile(profileName, { type: 'api_key', provider: config.provider, key: apiKey }) + return profileName + }, + }) initServerAiLogger(pathProvider.getLogsDir()) @@ -130,7 +165,19 @@ export async function startHttpServer(options?: HttpServerOptions): Promise<{ }) registerAiRoutes(server, dbManager, convManager) - registerWebRoutes(server, dbManager, { pathProvider, nativeBinding }) + registerWebRoutes(server, dbManager, { + pathProvider, + nativeBinding, + aiContext: { + aiDataDir, + convManager, + assistantManager, + skillManagerCore, + llmConfigStore, + customProviderStore: new CustomProviderStore(createFileConfigStorage(aiDataDir)), + customModelStore: new CustomModelStore(createFileConfigStorage(aiDataDir)), + }, + }) initSync(server, dbManager, pathProvider, { port, host, token }) diff --git a/apps/cli/src/http/routes/ai.ts b/apps/cli/src/http/routes/ai.ts index ddae9cd9..42faafbd 100644 --- a/apps/cli/src/http/routes/ai.ts +++ b/apps/cli/src/http/routes/ai.ts @@ -1,53 +1,22 @@ /** - * AI Web API — /_web/ai/ routes + * AI Web API — CLI-specific routes * - * 提供助手、技能、LLM 配置和工具目录的只读 HTTP 接口, - * 供 CLI Web 前端使用(对齐 Electron preload 的 window.*Api)。 - * - * - 助手/技能数据来自 ~/.chatlab/ai/{assistants,skills}/*.md - * - LLM 配置来自 ~/.chatlab/ai/llm-config.json - * - 工具目录来自 @openchatlab/core 静态数据 + * Routes that remain CLI-specific (agent streaming, tool catalog, chat stream). + * CRUD routes for assistants, skills, LLM config, conversations, and summaries + * are now handled by @openchatlab/http-routes shared package. */ -import * as fs from 'fs' -import * as path from 'path' import type { FastifyInstance } from 'fastify' import type { DatabaseManager, AIConversationManager } from '@openchatlab/node-runtime' import { SkillManager, createActivateSkillTool } from '@openchatlab/node-runtime' -import type { AssistantConfig } from '@openchatlab/node-runtime' -import { - BUILTIN_TOOL_CATALOG, - BUILTIN_PROVIDERS, - BUILTIN_MODELS, - getBuiltinModelsByProvider, - getChatOverview, -} from '@openchatlab/core' +import { getChatOverview } from '@openchatlab/core' import type { DataSnapshot, OwnerInfo, MentionedMember } from '@openchatlab/node-runtime' import { runSimpleLlmStream } from '@openchatlab/node-runtime' import { AGENT_TOOL_REGISTRY } from '@openchatlab/tools' import { adaptToolsForAgent } from '../../ai/tool-adapter' import { getDefaultAssistantConfig, buildPiModel } from '../../ai/llm-config' import { loadAssistantConfig } from '../../ai/assistant-loader' -import { getAssistantManager, getSkillManagerCore } from '../../ai/manager-factory' import { runServerAgent, type AgentStreamChunk } from '../../ai/agent' -import { - addLlmConfig, - updateLlmConfig, - deleteLlmConfig, - setDefaultAssistantSlot, - setFastModelSlot, -} from '../../ai/llm-config' -import { - addCustomProvider, - updateCustomProvider, - deleteCustomProvider, - addCustomModel, - updateCustomModel, - deleteCustomModel, -} from '../../ai/custom-store' -import { validateApiKey, fetchRemoteModels } from '../../ai/remote-api' -import { resolveApiKey } from '@openchatlab/config' -import { toLlmConfigDisplay } from './ai-config-display' function getAiDir(dbManager: DatabaseManager): string { const pathProvider = (dbManager as any)['pathProvider'] @@ -62,345 +31,6 @@ export function registerAiRoutes( dbManager: DatabaseManager, convManager?: AIConversationManager ): void { - // ==================== Assistants (via shared AssistantManager) ==================== - - server.get('/_web/ai/assistants', async () => { - const mgr = getAssistantManager(getAiDir(dbManager)) - return mgr.getAllAssistants() - }) - - server.get<{ Params: { id: string } }>('/_web/ai/assistants/:id', async (request, reply) => { - const mgr = getAssistantManager(getAiDir(dbManager)) - const config = mgr.getAssistantConfig(request.params.id) - if (!config) return reply.code(404).send({ error: 'Not found' }) - return config - }) - - server.post<{ - Body: Omit - }>('/_web/ai/assistants', async (request) => { - const mgr = getAssistantManager(getAiDir(dbManager)) - return mgr.createAssistant(request.body) - }) - - server.put<{ - Params: { id: string } - Body: Partial - }>('/_web/ai/assistants/:id', async (request, reply) => { - const mgr = getAssistantManager(getAiDir(dbManager)) - 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 mgr = getAssistantManager(getAiDir(dbManager)) - 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 mgr = getAssistantManager(getAiDir(dbManager)) - 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) => { - const mgr = getAssistantManager(getAiDir(dbManager)) - return mgr.importAssistantFromMd(request.body.rawMd) - }) - - // ==================== Skills (via shared SkillManagerCore) ==================== - - server.get('/_web/ai/skills', async () => { - const mgr = getSkillManagerCore(getAiDir(dbManager)) - return mgr.getAllSkills() - }) - - server.get<{ Params: { id: string } }>('/_web/ai/skills/:id', async (request, reply) => { - const mgr = getSkillManagerCore(getAiDir(dbManager)) - 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) => { - const mgr = getSkillManagerCore(getAiDir(dbManager)) - return mgr.createSkill(request.body.rawMd) - }) - - server.put<{ - Params: { id: string } - Body: { rawMd: string } - }>('/_web/ai/skills/:id', async (request, reply) => { - const mgr = getSkillManagerCore(getAiDir(dbManager)) - 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 mgr = getSkillManagerCore(getAiDir(dbManager)) - 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) => { - const mgr = getSkillManagerCore(getAiDir(dbManager)) - return mgr.importSkillFromMd(request.body.rawMd) - }) - - server.get('/_web/ai/skills/builtin-catalog', async () => { - const mgr = getSkillManagerCore(getAiDir(dbManager)) - return mgr.getBuiltinCatalog() - }) - - // ==================== LLM Config ==================== - - server.get('/_web/ai/llm/has-config', async () => { - const configPath = path.join(getAiDir(dbManager), 'llm-config.json') - if (!fs.existsSync(configPath)) return false - - try { - const data = JSON.parse(fs.readFileSync(configPath, 'utf-8')) - if (!data.configs || !Array.isArray(data.configs) || data.configs.length === 0) return false - return data.defaultAssistant != null - } catch { - return false - } - }) - - server.get('/_web/ai/llm/configs', async () => { - const configPath = path.join(getAiDir(dbManager), 'llm-config.json') - if (!fs.existsSync(configPath)) return { configs: [], defaultAssistant: null, fastModel: null } - - try { - const data = JSON.parse(fs.readFileSync(configPath, 'utf-8')) - const configs = (data.configs || []).map((c: Record) => toLlmConfigDisplay(c, resolveApiKey)) - return { - configs, - defaultAssistant: data.defaultAssistant ?? null, - fastModel: data.fastModel ?? null, - } - } catch { - return { configs: [], defaultAssistant: null, fastModel: null } - } - }) - - // ==================== 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 customPath = path.join(getAiDir(dbManager), 'custom-providers.json') - let custom: unknown[] = [] - if (fs.existsSync(customPath)) { - try { - custom = JSON.parse(fs.readFileSync(customPath, 'utf-8')) - if (!Array.isArray(custom)) custom = [] - } catch { - custom = [] - } - } - return [...BUILTIN_PROVIDERS, ...custom] - }) - - server.get('/_web/ai/llm/model-catalog', async () => { - const customPath = path.join(getAiDir(dbManager), 'custom-models.json') - let custom: unknown[] = [] - if (fs.existsSync(customPath)) { - try { - custom = JSON.parse(fs.readFileSync(customPath, 'utf-8')) - if (!Array.isArray(custom)) custom = [] - } catch { - custom = [] - } - } - return [...BUILTIN_MODELS, ...custom] - }) - - server.get('/_web/ai/llm/default-assistant-slot', async () => { - const configPath = path.join(getAiDir(dbManager), 'llm-config.json') - if (!fs.existsSync(configPath)) return null - try { - const data = JSON.parse(fs.readFileSync(configPath, 'utf-8')) - return data.defaultAssistant ?? null - } catch { - return null - } - }) - - server.get('/_web/ai/llm/fast-model-slot', async () => { - const configPath = path.join(getAiDir(dbManager), 'llm-config.json') - if (!fs.existsSync(configPath)) return null - try { - const data = JSON.parse(fs.readFileSync(configPath, 'utf-8')) - return data.fastModel ?? null - } catch { - return null - } - }) - - // ==================== LLM Config Write Operations ==================== - - 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 aiDataDir = getAiDir(dbManager) - return addLlmConfig(aiDataDir, request.body) - }) - - 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) => { - const aiDataDir = getAiDir(dbManager) - return updateLlmConfig(aiDataDir, request.params.id, request.body) - }) - - server.delete<{ Params: { id: string } }>('/_web/ai/llm/configs/:id', async (request) => { - const aiDataDir = getAiDir(dbManager) - return deleteLlmConfig(aiDataDir, request.params.id) - }) - - server.put<{ - Body: { configId: string; modelId: string } - }>('/_web/ai/llm/default-assistant-slot', async (request) => { - const aiDataDir = getAiDir(dbManager) - return setDefaultAssistantSlot(aiDataDir, request.body.configId, request.body.modelId) - }) - - server.put<{ - Body: { configId: string; modelId: string } | null - }>('/_web/ai/llm/fast-model-slot', async (request) => { - const aiDataDir = getAiDir(dbManager) - return setFastModelSlot(aiDataDir, request.body) - }) - - // ==================== Custom Provider CRUD ==================== - - server.post<{ - Body: { - name: string - kind: string - defaultBaseUrl: string - authMode?: string - supportsCustomModels?: boolean - modelIds?: string[] - website?: string - consoleUrl?: string - } - }>('/_web/ai/llm/custom-providers', async (request) => { - const aiDataDir = getAiDir(dbManager) - return addCustomProvider(aiDataDir, { - ...request.body, - kind: (request.body.kind || 'openai-compatible') as 'official' | 'aggregator' | 'openai-compatible', - authMode: 'api-key', - supportsCustomModels: request.body.supportsCustomModels ?? true, - modelIds: request.body.modelIds ?? [], - }) - }) - - server.put<{ - Params: { id: string } - Body: Record - }>('/_web/ai/llm/custom-providers/:id', async (request) => { - const aiDataDir = getAiDir(dbManager) - return updateCustomProvider(aiDataDir, request.params.id, request.body as any) - }) - - server.delete<{ Params: { id: string } }>('/_web/ai/llm/custom-providers/:id', async (request) => { - const aiDataDir = getAiDir(dbManager) - return deleteCustomProvider(aiDataDir, request.params.id) - }) - - // ==================== 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) => { - const aiDataDir = getAiDir(dbManager) - return addCustomModel(aiDataDir, { - ...request.body, - capabilities: (request.body.capabilities ?? ['chat']) as any, - recommendedFor: (request.body.recommendedFor ?? ['chat']) as any, - status: (request.body.status ?? 'stable') as any, - }) - }) - - server.put<{ - Params: { providerId: string; modelId: string } - Body: Record - }>('/_web/ai/llm/custom-models/:providerId/:modelId', async (request) => { - const aiDataDir = getAiDir(dbManager) - return updateCustomModel(aiDataDir, request.params.providerId, request.params.modelId, request.body as any) - }) - - server.delete<{ - Params: { providerId: string; modelId: string } - }>('/_web/ai/llm/custom-models/:providerId/:modelId', async (request) => { - const aiDataDir = getAiDir(dbManager) - return deleteCustomModel(aiDataDir, request.params.providerId, request.params.modelId) - }) - - // ==================== Remote API (Validate Key & Fetch Models) ==================== - - 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) - }) - // ==================== LLM Simple Chat Stream ==================== server.post<{ @@ -446,150 +76,8 @@ export function registerAiRoutes( reply.raw.end() }) - // ==================== Tool Catalog ==================== - - server.get('/_web/ai/tools/catalog', async () => { - return BUILTIN_TOOL_CATALOG - }) - - // ==================== AI Conversations CRUD ==================== - if (!convManager) return - server.post<{ - Body: { sessionId: string; title?: string; assistantId: string } - }>('/_web/ai/conversations', async (request) => { - const { sessionId, title, assistantId } = request.body - return convManager.createConversation(sessionId, title, assistantId) - }) - - server.get<{ - Querystring: { sessionId: string } - }>('/_web/ai/conversations', async (request) => { - const { sessionId } = request.query - if (!sessionId) return [] - return convManager.getConversations(sessionId) - }) - - server.get<{ Params: { id: string } }>('/_web/ai/conversations/:id', async (request, reply) => { - const conv = convManager.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 convManager.updateConversationTitle(request.params.id, request.body.title) - }) - - server.delete<{ Params: { id: string } }>('/_web/ai/conversations/:id', async (request) => { - return convManager.deleteConversation(request.params.id) - }) - - // ==================== AI Messages 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 convManager.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 convManager.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' }) - } - convManager.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 convManager.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' }) - } - convManager.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' }) - } - convManager.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 convManager.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 convManager.getConversationTokenUsage(request.params.id) - }) - // ==================== Agent SSE Stream ==================== const activeAgentAborts = new Map() @@ -766,39 +254,4 @@ export function registerAiRoutes( } return { success: false } }) - - // ==================== Debug SQL ==================== - - server.get('/_web/ai/debug/schema', async (_request, reply) => { - if (!convManager) { - return reply.code(503).send({ error: 'AI conversation manager not available' }) - } - return convManager.getAiSchema() - }) - - server.post<{ - Body: { sql: string } - }>('/_web/ai/debug/execute-sql', async (request, reply) => { - if (!convManager) { - return reply.code(503).send({ error: 'AI conversation manager not available' }) - } - const { sql } = request.body - if (!sql || typeof sql !== 'string') { - return reply.code(400).send({ error: 'sql is required' }) - } - try { - return convManager.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 (_request, reply) => { - if (!convManager) { - return reply.code(503).send({ error: 'AI conversation manager not available' }) - } - const cleared = convManager.clearAllDebugContext() - return { success: true, cleared } - }) } diff --git a/apps/cli/src/http/routes/web/index.ts b/apps/cli/src/http/routes/web/index.ts index d5578767..02e025ef 100644 --- a/apps/cli/src/http/routes/web/index.ts +++ b/apps/cli/src/http/routes/web/index.ts @@ -21,16 +21,33 @@ import * as os from 'os' import * as path from 'path' import type { FastifyInstance } from 'fastify' import type { PathProvider } from '@openchatlab/core' -import type { DatabaseManager } from '@openchatlab/node-runtime' +import type { + DatabaseManager, + AIConversationManager, + AssistantManager, + SkillManagerCore, + LLMConfigStore, + CustomProviderStore, + CustomModelStore, +} from '@openchatlab/node-runtime' import { createDatabaseManagerAdapter } from '@openchatlab/node-runtime' import { registerSharedRoutes } from '@openchatlab/http-routes' import { MergeSessionCache } from '../../../merger/merge-cache' -import { registerSummaryRoutes } from './summaries' import { registerImportRoutes } from './import' import { registerMergeRoutes } from './merge' import { registerCacheRoutes } from './cache' import { getVersion } from '../../../version' +export interface AiContextOptions { + aiDataDir: string + convManager: AIConversationManager + assistantManager: AssistantManager + skillManagerCore: SkillManagerCore + llmConfigStore: LLMConfigStore + customProviderStore: CustomProviderStore + customModelStore: CustomModelStore +} + /** * Semver comparison: returns true if `latest` is strictly newer than `current`. * Handles pre-release tags (e.g. 0.22.0-beta.1 < 0.22.0). @@ -55,7 +72,7 @@ function isNewerVersion(latest: string, current: string): boolean { export function registerWebRoutes( server: FastifyInstance, dbManager: DatabaseManager, - options?: { pathProvider?: PathProvider; nativeBinding?: string } + options?: { pathProvider?: PathProvider; nativeBinding?: string; aiContext?: AiContextOptions } ): void { const adapter = createDatabaseManagerAdapter(dbManager) @@ -77,16 +94,29 @@ export function registerWebRoutes( } const resolvedPathProvider = options?.pathProvider ?? fallbackPathProvider - registerSharedRoutes(server, { - dbManager, - sessionAdapter: adapter, - pathProvider: resolvedPathProvider, - getVersion, - nativeBinding: options?.nativeBinding, - }) + const ai = options?.aiContext + registerSharedRoutes( + server, + { + dbManager, + sessionAdapter: adapter, + pathProvider: resolvedPathProvider, + getVersion, + nativeBinding: options?.nativeBinding, + ...(ai && { + aiDataDir: ai.aiDataDir, + conversationManager: ai.convManager, + assistantManager: ai.assistantManager, + skillManagerCore: ai.skillManagerCore, + llmConfigStore: ai.llmConfigStore, + customProviderStore: ai.customProviderStore, + customModelStore: ai.customModelStore, + }), + }, + ai ? { requireAi: true } : undefined + ) // CLI-specific routes not yet migrated to @openchatlab/http-routes - registerSummaryRoutes(server, dbManager, adapter) registerImportRoutes(server, dbManager) if (mergeCache) { registerMergeRoutes(server, dbManager, mergeCache) diff --git a/apps/desktop/main/ai/assistant/manager.ts b/apps/desktop/main/ai/assistant/manager.ts index 63b233d5..e8f2f00e 100644 --- a/apps/desktop/main/ai/assistant/manager.ts +++ b/apps/desktop/main/ai/assistant/manager.ts @@ -50,7 +50,7 @@ const nodeFs: AssistantManagerFs = { let _manager: SharedAssistantManager | null = null -function getManager(): SharedAssistantManager { +export function getManager(): SharedAssistantManager { if (!_manager) { _manager = new SharedAssistantManager({ fs: nodeFs, diff --git a/apps/desktop/main/ai/skills/manager.ts b/apps/desktop/main/ai/skills/manager.ts index 41b0daf7..6c83c208 100644 --- a/apps/desktop/main/ai/skills/manager.ts +++ b/apps/desktop/main/ai/skills/manager.ts @@ -40,7 +40,7 @@ const nodeFs: SkillManagerFs = { let _manager: SkillManagerCore | null = null -function getManager(): SkillManagerCore { +export function getManager(): SkillManagerCore { if (!_manager) { _manager = new SkillManagerCore({ fs: nodeFs, diff --git a/apps/desktop/main/internal-api.ts b/apps/desktop/main/internal-api.ts index cae282a4..69f38122 100644 --- a/apps/desktop/main/internal-api.ts +++ b/apps/desktop/main/internal-api.ts @@ -8,13 +8,26 @@ * (apps/desktop/main/api/). Different port, different token, different lifecycle. */ +import * as fs from 'fs' +import * as path from 'path' import { randomBytes, createHmac, timingSafeEqual } from 'crypto' import { ipcMain } from 'electron' import Fastify, { type FastifyInstance, type FastifyError, type FastifyRequest, type FastifyReply } from 'fastify' import type { PathProvider } from '@openchatlab/core' -import { DatabaseManager, createDatabaseManagerAdapter } from '@openchatlab/node-runtime' +import { + DatabaseManager, + createDatabaseManagerAdapter, + LLMConfigStore, + CustomProviderStore, + CustomModelStore, +} from '@openchatlab/node-runtime' +import type { ConfigStorage } from '@openchatlab/node-runtime' import { registerSharedRoutes, ApiError, ApiErrorCode, errorResponse, serverError } from '@openchatlab/http-routes' import type { HttpRouteContext } from '@openchatlab/http-routes' +import { resolveApiKey, writeAuthProfile } from '@openchatlab/config' +import { getManager as getConversationManager } from './ai/conversations' +import { getManager as getAssistantManager } from './ai/assistant/manager' +import { getManager as getSkillManager } from './ai/skills/manager' export interface InternalEndpoint { baseUrl: string @@ -27,6 +40,22 @@ let dbManager: DatabaseManager | null = null const JSON_BODY_LIMIT = 50 * 1024 * 1024 // 50 MB +function createFileConfigStorage(aiDataDir: string): ConfigStorage { + return { + readJson(key: string): T | null { + try { + return JSON.parse(fs.readFileSync(path.join(aiDataDir, `${key}.json`), 'utf-8')) as T + } catch { + return null + } + }, + writeJson(key: string, data: T): void { + if (!fs.existsSync(aiDataDir)) fs.mkdirSync(aiDataDir, { recursive: true }) + fs.writeFileSync(path.join(aiDataDir, `${key}.json`), JSON.stringify(data, null, 2), 'utf-8') + }, + } +} + /** * Start the Internal API Server. * Must be called before createWindow() so the Renderer can retrieve the endpoint. @@ -43,13 +72,31 @@ export async function startInternalServer(pathProvider: PathProvider): Promise resolveApiKey(provider, authProfile) || undefined, + onApiKeyCreated: (config, apiKey) => { + const profileName = config.name?.toLowerCase().replace(/\s+/g, '-') || config.provider + writeAuthProfile(profileName, { type: 'api_key', provider: config.provider, key: apiKey }) + return profileName + }, + }) + const { app } = await import('electron') + const configStorage = createFileConfigStorage(aiDataDir) const ctx: HttpRouteContext = { dbManager: newDbManager, sessionAdapter, pathProvider, getVersion: () => app.getVersion(), + aiDataDir, + conversationManager: getConversationManager(), + assistantManager: getAssistantManager(), + skillManagerCore: getSkillManager(), + llmConfigStore, + customProviderStore: new CustomProviderStore(configStorage), + customModelStore: new CustomModelStore(configStorage), } newServer = Fastify({ logger: false, bodyLimit: JSON_BODY_LIMIT }) @@ -103,7 +150,7 @@ export async function startInternalServer(pathProvider: PathProvider): Promise { } catch (err) { console.error('[InternalAPI] Error closing server:', err) } finally { - try { dbManager?.closeAll() } catch { /* best-effort */ } + try { + dbManager?.closeAll() + } catch { + /* best-effort */ + } server = null endpoint = null dbManager = null diff --git a/apps/desktop/preload/apis/ai.ts b/apps/desktop/preload/apis/ai.ts index 45286894..b01089ce 100644 --- a/apps/desktop/preload/apis/ai.ts +++ b/apps/desktop/preload/apis/ai.ts @@ -54,15 +54,6 @@ export interface AIMessage { tokenUsage?: TokenUsageData } -// LLM API 类型 -export interface LLMProvider { - id: string - name: string - description: string - defaultBaseUrl: string - models: Array<{ id: string; name: string; description?: string }> -} - export interface ChatMessage { role: 'system' | 'user' | 'assistant' content: string @@ -175,22 +166,6 @@ export interface ToolContext { preprocessConfig?: PreprocessConfig } -// AI 服务配置类型(前端用) -export interface AIServiceConfigDisplay { - id: string - name: string - provider: string - apiKey: string // 脱敏后的 API Key - apiKeySet: boolean - model?: string - baseUrl?: string - maxTokens?: number - apiFormat?: string - customModels?: Array<{ id: string; name: string }> - createdAt: number - updatedAt: number -} - // ==================== AI API ==================== export const aiApi = { @@ -530,162 +505,12 @@ export const aiApi = { // ==================== LLM API ==================== export const llmApi = { - // ==================== Provider Registry / Model Catalog ==================== - - getProviderRegistry: () => { - return ipcRenderer.invoke('llm:getProviderRegistry') - }, - - getModelCatalog: () => { - return ipcRenderer.invoke('llm:getModelCatalog') - }, - - addCustomProvider: (input: Record) => { - return ipcRenderer.invoke('llm:addCustomProvider', input) - }, - - updateCustomProvider: (id: string, updates: Record) => { - return ipcRenderer.invoke('llm:updateCustomProvider', id, updates) - }, - - deleteCustomProvider: (id: string) => { - return ipcRenderer.invoke('llm:deleteCustomProvider', id) - }, - - addCustomModel: (input: Record) => { - return ipcRenderer.invoke('llm:addCustomModel', input) - }, - - updateCustomModel: (providerId: string, modelId: string, updates: Record) => { - return ipcRenderer.invoke('llm:updateCustomModel', providerId, modelId, updates) - }, - - deleteCustomModel: (providerId: string, modelId: string) => { - return ipcRenderer.invoke('llm:deleteCustomModel', providerId, modelId) - }, - - /** @deprecated 使用 getProviderRegistry 代替 */ - getProviders: (): Promise => { - return ipcRenderer.invoke('llm:getProviders') - }, - - // ==================== 多配置管理 API ==================== - /** - * 获取所有配置列表 + * LLM CRUD (config, provider, model, validate, etc.) has been migrated to + * HTTP service layer via FetchLLMAdapter. Only streaming / non-streaming + * chat APIs remain on IPC because they require real-time event forwarding. */ - getAllConfigs: (): Promise => { - return ipcRenderer.invoke('llm:getAllConfigs') - }, - /** - * 获取默认助手 slot(configId + modelId) - */ - getDefaultAssistantSlot: (): Promise<{ configId: string; modelId: string } | null> => { - return ipcRenderer.invoke('llm:getDefaultAssistantSlot') - }, - - /** - * 获取快速模型 slot - */ - getFastModelSlot: (): Promise<{ configId: string; modelId: string } | null> => { - return ipcRenderer.invoke('llm:getFastModelSlot') - }, - - /** - * 添加新配置 - */ - addConfig: (config: { - name: string - provider: string - apiKey: string - model?: string - baseUrl?: string - maxTokens?: number - apiFormat?: string - customModels?: Array<{ id: string; name: string }> - }): Promise<{ success: boolean; config?: AIServiceConfigDisplay; error?: string }> => { - return ipcRenderer.invoke('llm:addConfig', config) - }, - - /** - * 更新配置 - */ - updateConfig: ( - id: string, - updates: { - name?: string - provider?: string - apiKey?: string - model?: string - baseUrl?: string - maxTokens?: number - apiFormat?: string - customModels?: Array<{ id: string; name: string }> - } - ): Promise<{ success: boolean; error?: string }> => { - return ipcRenderer.invoke('llm:updateConfig', id, updates) - }, - - /** - * 删除配置 - */ - deleteConfig: (id?: string): Promise<{ success: boolean; error?: string }> => { - return ipcRenderer.invoke('llm:deleteConfig', id) - }, - - /** - * 设置默认助手模型(configId + modelId) - */ - setDefaultAssistantModel: (configId: string, modelId: string): Promise<{ success: boolean; error?: string }> => { - return ipcRenderer.invoke('llm:setDefaultAssistantModel', configId, modelId) - }, - - /** - * 设置快速模型 - */ - setFastModel: (slot: { configId: string; modelId: string } | null): Promise<{ success: boolean; error?: string }> => { - return ipcRenderer.invoke('llm:setFastModel', slot) - }, - - /** - * 验证 API Key(支持自定义 baseUrl 和 model) - */ - validateApiKey: ( - provider: string, - apiKey: string, - baseUrl?: string, - model?: string - ): Promise<{ success: boolean; error?: string }> => { - return ipcRenderer.invoke('llm:validateApiKey', provider, apiKey, baseUrl, model) - }, - - /** - * 获取远程模型列表 - */ - fetchRemoteModels: ( - provider: string, - apiKey: string, - baseUrl?: string, - apiFormat?: string - ): Promise<{ - success: boolean - models?: Array<{ id: string; name: string; ownedBy?: string; contextWindow?: number }> - error?: string - }> => { - return ipcRenderer.invoke('llm:fetchRemoteModels', provider, apiKey, baseUrl, apiFormat) - }, - - /** - * 检查是否已配置 LLM(是否有激活的配置) - */ - hasConfig: (): Promise => { - return ipcRenderer.invoke('llm:hasConfig') - }, - - /** - * 发送 LLM 聊天请求(非流式) - */ chat: ( messages: ChatMessage[], options?: ChatOptions @@ -693,10 +518,6 @@ export const llmApi = { return ipcRenderer.invoke('llm:chat', messages, options) }, - /** - * 发送 LLM 聊天请求(流式) - * 返回一个 Promise,该 Promise 在流完成后才 resolve - */ chatStream: ( messages: ChatMessage[], options?: ChatOptions, @@ -755,149 +576,15 @@ export const llmApi = { }, } -// ==================== Assistant API ==================== +/** + * Assistant CRUD has been migrated to HTTP service layer via FetchAssistantAdapter. + * The preload bridge is no longer needed. + */ -export interface AssistantSummary { - id: string - name: string - systemPrompt: string - presetQuestions: string[] - builtinId?: string - applicableChatTypes?: ('group' | 'private')[] - supportedLocales?: string[] -} - -export interface AssistantConfigFull { - id: string - name: string - systemPrompt: string - presetQuestions: string[] - allowedBuiltinTools?: string[] - builtinId?: string - applicableChatTypes?: ('group' | 'private')[] - supportedLocales?: string[] -} - -export interface BuiltinAssistantInfo { - id: string - name: string - systemPrompt: string - applicableChatTypes?: ('group' | 'private')[] - supportedLocales?: string[] - imported: boolean -} - -export const assistantApi = { - getAll: (): Promise => { - return ipcRenderer.invoke('assistant:getAll') - }, - - getConfig: (id: string): Promise => { - return ipcRenderer.invoke('assistant:getConfig', id) - }, - - update: (id: string, updates: Partial): Promise<{ success: boolean; error?: string }> => { - return ipcRenderer.invoke('assistant:update', id, updates) - }, - - create: (config: Omit): Promise<{ success: boolean; id?: string; error?: string }> => { - return ipcRenderer.invoke('assistant:create', config) - }, - - delete: (id: string): Promise<{ success: boolean; error?: string }> => { - return ipcRenderer.invoke('assistant:delete', id) - }, - - reset: (id: string): Promise<{ success: boolean; error?: string }> => { - return ipcRenderer.invoke('assistant:reset', id) - }, - - getBuiltinCatalog: (): Promise => { - return ipcRenderer.invoke('assistant:getBuiltinCatalog') - }, - - getBuiltinToolCatalog: (): Promise> => { - return ipcRenderer.invoke('assistant:getBuiltinToolCatalog') - }, - - importAssistant: (builtinId: string): Promise<{ success: boolean; error?: string }> => { - return ipcRenderer.invoke('assistant:import', builtinId) - }, - - reimportAssistant: (id: string): Promise<{ success: boolean; error?: string }> => { - return ipcRenderer.invoke('assistant:reimport', id) - }, - - importFromMd: (rawMd: string): Promise<{ success: boolean; id?: string; error?: string }> => { - return ipcRenderer.invoke('assistant:importFromMd', rawMd) - }, -} - -// ==================== Skill API ==================== - -export interface SkillSummary { - id: string - name: string - description: string - tags: string[] - chatScope: 'all' | 'group' | 'private' - tools: string[] - builtinId?: string -} - -export interface SkillConfigFull { - id: string - name: string - description: string - tags: string[] - chatScope: 'all' | 'group' | 'private' - prompt: string - tools: string[] - builtinId?: string -} - -export interface BuiltinSkillInfo extends SkillSummary { - imported: boolean - hasUpdate: boolean -} - -export const skillApi = { - getAll: (): Promise => { - return ipcRenderer.invoke('skill:getAll') - }, - - getConfig: (id: string): Promise => { - return ipcRenderer.invoke('skill:getConfig', id) - }, - - update: (id: string, rawMd: string): Promise<{ success: boolean; error?: string }> => { - return ipcRenderer.invoke('skill:update', id, rawMd) - }, - - create: (rawMd: string): Promise<{ success: boolean; id?: string; error?: string }> => { - return ipcRenderer.invoke('skill:create', rawMd) - }, - - delete: (id: string): Promise<{ success: boolean; error?: string }> => { - return ipcRenderer.invoke('skill:delete', id) - }, - - getBuiltinCatalog: (): Promise => { - return ipcRenderer.invoke('skill:getBuiltinCatalog') - }, - - importSkill: (builtinId: string): Promise<{ success: boolean; id?: string; error?: string }> => { - return ipcRenderer.invoke('skill:import', builtinId) - }, - - reimportSkill: (id: string): Promise<{ success: boolean; error?: string }> => { - return ipcRenderer.invoke('skill:reimport', id) - }, - - importFromMd: (rawMd: string): Promise<{ success: boolean; id?: string; error?: string }> => { - return ipcRenderer.invoke('skill:importFromMd', rawMd) - }, -} +/** + * Skill CRUD has been migrated to HTTP service layer via FetchSkillAdapter. + * The preload bridge is no longer needed. + */ // ==================== Agent API ==================== diff --git a/apps/desktop/preload/index.d.ts b/apps/desktop/preload/index.d.ts index c2a7d4bd..bbb9bf6e 100644 --- a/apps/desktop/preload/index.d.ts +++ b/apps/desktop/preload/index.d.ts @@ -64,12 +64,20 @@ interface ChatApi { analyzeIncrementalImport: ( sessionId: string, filePath: string - ) => Promise<{ newMessageCount: number; duplicateCount: number; totalInFile: number; error?: string; diagnosis?: { suggestion?: string } }> + ) => Promise<{ + newMessageCount: number + duplicateCount: number + totalInFile: number + error?: string + diagnosis?: { suggestion?: string } + }> incrementalImport: ( sessionId: string, filePath: string ) => Promise<{ success: boolean; newMessageCount: number; error?: string }> - exportSessionsToTempFiles: (sessionIds: string[]) => Promise<{ success: boolean; tempFiles: string[]; error?: string }> + exportSessionsToTempFiles: ( + sessionIds: string[] + ) => Promise<{ success: boolean; tempFiles: string[]; error?: string }> cleanupTempExportFiles: (filePaths: string[]) => Promise<{ success: boolean; error?: string }> importDemo: (locale: string) => Promise<{ success: boolean @@ -353,29 +361,6 @@ interface ModelDefinition { editable: boolean } -// LLM 相关类型(旧,兼容) -interface LLMProviderInfo { - id: string - name: string - defaultBaseUrl: string - models: Array<{ id: string; name: string; description?: string }> -} - -// 单个 AI 服务配置(前端显示用,API Key 已脱敏) -interface AIServiceConfigDisplay { - id: string - name: string - provider: string - apiKey: string // 脱敏后的 API Key - apiKeySet: boolean - model?: string - baseUrl?: string - maxTokens?: number - apiFormat?: string - createdAt: number - updatedAt: number -} - interface LLMChatMessage { role: 'system' | 'user' | 'assistant' content: string @@ -395,81 +380,12 @@ interface LLMChatStreamChunk { thinkingDone?: boolean } +/** + * LLM CRUD (config, provider, model, validate, etc.) has been migrated to + * HTTP service layer via FetchLLMAdapter. Only streaming / non-streaming + * chat APIs remain on IPC. + */ interface LlmApi { - // Provider Registry / Model Catalog - getProviderRegistry: () => Promise - getModelCatalog: () => Promise - addCustomProvider: ( - input: Omit - ) => Promise<{ success: boolean; provider?: ProviderDefinition; error?: string }> - updateCustomProvider: ( - id: string, - updates: Partial> - ) => Promise<{ success: boolean; error?: string }> - deleteCustomProvider: (id: string) => Promise<{ success: boolean; error?: string }> - addCustomModel: ( - input: Omit - ) => Promise<{ success: boolean; model?: ModelDefinition; error?: string }> - updateCustomModel: ( - providerId: string, - modelId: string, - updates: Partial> - ) => Promise<{ success: boolean; error?: string }> - deleteCustomModel: (providerId: string, modelId: string) => Promise<{ success: boolean; error?: string }> - - getProviders: () => Promise - - // 多配置管理 API - getAllConfigs: () => Promise - getDefaultAssistantSlot: () => Promise<{ configId: string; modelId: string } | null> - getFastModelSlot: () => Promise<{ configId: string; modelId: string } | null> - addConfig: (config: { - name: string - provider: string - apiKey: string - model?: string - baseUrl?: string - maxTokens?: number - apiFormat?: string - customModels?: Array<{ id: string; name: string }> - }) => Promise<{ success: boolean; config?: AIServiceConfigDisplay; error?: string }> - updateConfig: ( - id: string, - updates: { - name?: string - provider?: string - apiKey?: string - model?: string - baseUrl?: string - maxTokens?: number - apiFormat?: string - customModels?: Array<{ id: string; name: string }> - } - ) => Promise<{ success: boolean; error?: string }> - deleteConfig: (id?: string) => Promise<{ success: boolean; error?: string }> - setDefaultAssistantModel: (configId: string, modelId: string) => Promise<{ success: boolean; error?: string }> - setFastModel: (slot: { configId: string; modelId: string } | null) => Promise<{ success: boolean; error?: string }> - - // 验证和检查 - validateApiKey: ( - provider: string, - apiKey: string, - baseUrl?: string, - model?: string - ) => Promise<{ success: boolean; error?: string }> - fetchRemoteModels: ( - provider: string, - apiKey: string, - baseUrl?: string, - apiFormat?: string - ) => Promise<{ - success: boolean - models?: Array<{ id: string; name: string; ownedBy?: string; contextWindow?: number }> - error?: string - }> - hasConfig: () => Promise - - // 聊天功能 chat: ( messages: LLMChatMessage[], options?: LLMChatOptions @@ -607,91 +523,8 @@ interface AgentApi { abort: (requestId: string) => Promise<{ success: boolean; error?: string }> } -// ==================== 助手管理 ==================== - -interface AssistantSummary { - id: string - name: string - systemPrompt: string - presetQuestions: string[] - builtinId?: string - applicableChatTypes?: ('group' | 'private')[] - supportedLocales?: string[] -} - -interface AssistantConfigFull { - id: string - name: string - systemPrompt: string - presetQuestions: string[] - allowedBuiltinTools?: string[] - builtinId?: string - applicableChatTypes?: ('group' | 'private')[] - supportedLocales?: string[] -} - -interface BuiltinAssistantInfo { - id: string - name: string - systemPrompt: string - applicableChatTypes?: ('group' | 'private')[] - supportedLocales?: string[] - imported: boolean -} - -interface AssistantApi { - getAll: () => Promise - getConfig: (id: string) => Promise - update: (id: string, updates: Partial) => Promise<{ success: boolean; error?: string }> - create: (config: Omit) => Promise<{ success: boolean; id?: string; error?: string }> - delete: (id: string) => Promise<{ success: boolean; error?: string }> - reset: (id: string) => Promise<{ success: boolean; error?: string }> - getBuiltinCatalog: () => Promise - getBuiltinToolCatalog: () => Promise> - importAssistant: (builtinId: string) => Promise<{ success: boolean; error?: string }> - reimportAssistant: (id: string) => Promise<{ success: boolean; error?: string }> - importFromMd: (rawMd: string) => Promise<{ success: boolean; id?: string; error?: string }> -} - -// ==================== 技能管理 ==================== - -interface SkillSummary { - id: string - name: string - description: string - tags: string[] - chatScope: 'all' | 'group' | 'private' - tools: string[] - builtinId?: string -} - -interface SkillConfigFull { - id: string - name: string - description: string - tags: string[] - chatScope: 'all' | 'group' | 'private' - prompt: string - tools: string[] - builtinId?: string -} - -interface BuiltinSkillInfo extends SkillSummary { - imported: boolean - hasUpdate: boolean -} - -interface SkillApi { - getAll: () => Promise - getConfig: (id: string) => Promise - update: (id: string, rawMd: string) => Promise<{ success: boolean; error?: string }> - create: (rawMd: string) => Promise<{ success: boolean; id?: string; error?: string }> - delete: (id: string) => Promise<{ success: boolean; error?: string }> - getBuiltinCatalog: () => Promise - importSkill: (builtinId: string) => Promise<{ success: boolean; id?: string; error?: string }> - reimportSkill: (id: string) => Promise<{ success: boolean; error?: string }> - importFromMd: (rawMd: string) => Promise<{ success: boolean; id?: string; error?: string }> -} +// Assistant CRUD migrated to HTTP service layer (FetchAssistantAdapter) +// Skill CRUD migrated to HTTP service layer (FetchSkillAdapter) // Cache API 类型 interface CacheDirectoryInfo { @@ -929,8 +762,6 @@ declare global { aiApi: AiApi llmApi: LlmApi agentApi: AgentApi - assistantApi: AssistantApi - skillApi: SkillApi cacheApi: CacheApi networkApi: NetworkApi sessionApi: SessionApi @@ -961,21 +792,11 @@ export { ModelStatus, ModelRecommendedFor, AgentApi, - AssistantApi, - AssistantSummary, - AssistantConfigFull, - BuiltinAssistantInfo, - SkillApi, - SkillSummary, - SkillConfigFull, - BuiltinSkillInfo, CacheApi, NetworkApi, ProxyConfig, AIConversation, AIMessage, - LLMProviderInfo, - AIServiceConfigDisplay, LLMChatMessage, LLMChatOptions, LLMChatStreamChunk, diff --git a/apps/desktop/preload/index.ts b/apps/desktop/preload/index.ts index 1a6f5396..2def2d07 100644 --- a/apps/desktop/preload/index.ts +++ b/apps/desktop/preload/index.ts @@ -8,7 +8,7 @@ import { electronAPI } from '@electron-toolkit/preload' // 从拆分的模块导入 API import { extendedApi } from './apis/core' import { chatApi, mergeApi } from './apis/chat' -import { aiApi, llmApi, agentApi, assistantApi, skillApi } from './apis/ai' +import { aiApi, llmApi, agentApi } from './apis/ai' import { networkApi, cacheApi, sessionApi } from './apis/utils' import { apiServerApi } from './apis/api-server' import { internalApi } from './apis/internal-api' @@ -40,8 +40,6 @@ if (process.contextIsolated) { contextBridge.exposeInMainWorld('aiApi', aiApi) contextBridge.exposeInMainWorld('llmApi', llmApi) contextBridge.exposeInMainWorld('agentApi', agentApi) - contextBridge.exposeInMainWorld('assistantApi', assistantApi) - contextBridge.exposeInMainWorld('skillApi', skillApi) contextBridge.exposeInMainWorld('cacheApi', cacheApi) contextBridge.exposeInMainWorld('networkApi', networkApi) contextBridge.exposeInMainWorld('sessionApi', sessionApi) @@ -66,10 +64,6 @@ if (process.contextIsolated) { // @ts-ignore (define in dts) window.agentApi = agentApi // @ts-ignore (define in dts) - window.assistantApi = assistantApi - // @ts-ignore (define in dts) - window.skillApi = skillApi - // @ts-ignore (define in dts) window.cacheApi = cacheApi // @ts-ignore (define in dts) window.networkApi = networkApi diff --git a/packages/http-routes/src/context.ts b/packages/http-routes/src/context.ts index 39feeb70..f49dd33f 100644 --- a/packages/http-routes/src/context.ts +++ b/packages/http-routes/src/context.ts @@ -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 } diff --git a/packages/http-routes/src/index.ts b/packages/http-routes/src/index.ts index 1f91a60d..6d5e1877 100644 --- a/packages/http-routes/src/index.ts +++ b/packages/http-routes/src/index.ts @@ -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' diff --git a/packages/http-routes/src/register.ts b/packages/http-routes/src/register.ts index a17d9749..c7b8c3f1 100644 --- a/packages/http-routes/src/register.ts +++ b/packages/http-routes/src/register.ts @@ -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) } diff --git a/packages/http-routes/src/routes/web/ai-assistants.ts b/packages/http-routes/src/routes/web/ai-assistants.ts new file mode 100644 index 00000000..b10098aa --- /dev/null +++ b/packages/http-routes/src/routes/web/ai-assistants.ts @@ -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 + }>('/_web/ai/assistants', async (request) => { + return mgr.createAssistant(request.body) + }) + + server.put<{ + Params: { id: string } + Body: Partial + }>('/_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 + }) +} diff --git a/packages/http-routes/src/routes/web/ai-conversations.ts b/packages/http-routes/src/routes/web/ai-conversations.ts new file mode 100644 index 00000000..62792dd8 --- /dev/null +++ b/packages/http-routes/src/routes/web/ai-conversations.ts @@ -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 } + }) +} diff --git a/packages/http-routes/src/routes/web/ai-llm.ts b/packages/http-routes/src/routes/web/ai-llm.ts new file mode 100644 index 00000000..7dc9d414 --- /dev/null +++ b/packages/http-routes/src/routes/web/ai-llm.ts @@ -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): Record { + 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)), + 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) } + } + 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 + }>('/_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 + }>('/_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) + }) +} diff --git a/packages/http-routes/src/routes/web/ai-skills.ts b/packages/http-routes/src/routes/web/ai-skills.ts new file mode 100644 index 00000000..4118d369 --- /dev/null +++ b/packages/http-routes/src/routes/web/ai-skills.ts @@ -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 + }) +} diff --git a/packages/http-routes/src/routes/web/ai-summaries.ts b/packages/http-routes/src/routes/web/ai-summaries.ts new file mode 100644 index 00000000..8d42088a --- /dev/null +++ b/packages/http-routes/src/routes/web/ai-summaries.ts @@ -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) + }) +} diff --git a/packages/node-runtime/src/ai/__tests__/llm-config-store.test.ts b/packages/node-runtime/src/ai/__tests__/llm-config-store.test.ts index 29c05ab9..4db2e054 100644 --- a/packages/node-runtime/src/ai/__tests__/llm-config-store.test.ts +++ b/packages/node-runtime/src/ai/__tests__/llm-config-store.test.ts @@ -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 + 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 + 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() + 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') + }) }) diff --git a/packages/node-runtime/src/ai/custom-store.ts b/packages/node-runtime/src/ai/custom-store.ts new file mode 100644 index 00000000..8d14d04b --- /dev/null +++ b/packages/node-runtime/src/ai/custom-store.ts @@ -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('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): { 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('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): { 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 } + } +} diff --git a/packages/node-runtime/src/ai/index.ts b/packages/node-runtime/src/ai/index.ts index 2e199522..3bc108fc 100644 --- a/packages/node-runtime/src/ai/index.ts +++ b/packages/node-runtime/src/ai/index.ts @@ -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' diff --git a/packages/node-runtime/src/ai/llm-config-store.ts b/packages/node-runtime/src/ai/llm-config-store.ts index d48d722e..6acf9f29 100644 --- a/packages/node-runtime/src/ai/llm-config-store.ts +++ b/packages/node-runtime/src/ai/llm-config-store.ts @@ -42,7 +42,8 @@ export interface ConfigStorage { export interface LLMConfigStoreDeps { t?: (key: string, options?: Record) => 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 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).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).authProfile = profileName + } } this.saveStore(store) diff --git a/packages/node-runtime/src/index.ts b/packages/node-runtime/src/index.ts index 5fc79f90..822d5fd1 100644 --- a/packages/node-runtime/src/index.ts +++ b/packages/node-runtime/src/index.ts @@ -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' diff --git a/src/components/AIChat/ChatExplorer.vue b/src/components/AIChat/ChatExplorer.vue index 39515a53..d3f90292 100644 --- a/src/components/AIChat/ChatExplorer.vue +++ b/src/components/AIChat/ChatExplorer.vue @@ -9,7 +9,7 @@ import AIChatInput from './input/AIChatInput.vue' import AIThinkingIndicator from './chat/AIThinkingIndicator.vue' import ChatStatusBar from './chat/ChatStatusBar.vue' import { useAIChat } from '@/composables/useAIChat' -import { useAIService } from '@/services' +import { useAIService, useLLMService } from '@/services' import CaptureButton from '@/components/common/CaptureButton.vue' import AssistantInlineBar from './assistant/AssistantInlineBar.vue' import AssistantConfigModal from './assistant/AssistantConfigModal.vue' @@ -144,7 +144,7 @@ const conversationContentRef = ref(null) async function checkLLMConfig() { isCheckingConfig.value = true try { - hasLLMConfig.value = await window.llmApi.hasConfig() + hasLLMConfig.value = await useLLMService().hasConfig() } catch (error) { console.error('检查 LLM 配置失败:', error) hasLLMConfig.value = false diff --git a/src/components/analysis/SQLLab/ResultTable.vue b/src/components/analysis/SQLLab/ResultTable.vue index 11297aaa..f3364ba1 100644 --- a/src/components/analysis/SQLLab/ResultTable.vue +++ b/src/components/analysis/SQLLab/ResultTable.vue @@ -10,6 +10,7 @@ import { COLUMN_LABELS } from './types' import { usePromptStore } from '@/stores/prompt' import { useLayoutStore } from '@/stores/layout' import { exportSQLResult, type SQLExportFormat } from '@/utils/sqlExport' +import { useLLMService } from '@/services' const { t, locale } = useI18n() const toast = useToast() @@ -305,7 +306,7 @@ async function openSummaryModal() { // AI 总结 async function generateSummary() { - const hasConfig = await window.llmApi.hasConfig() + const hasConfig = await useLLMService().hasConfig() if (!hasConfig) { summaryError.value = t('common.errorNoAIConfig') return diff --git a/src/components/analysis/SQLLabTab.vue b/src/components/analysis/SQLLabTab.vue index 0778c5e4..44f11198 100644 --- a/src/components/analysis/SQLLabTab.vue +++ b/src/components/analysis/SQLLabTab.vue @@ -3,7 +3,7 @@ import { ref, nextTick, onMounted, watch } from 'vue' import { useI18n } from 'vue-i18n' import { SchemaPanel, AIHistoryModal, ResultTable, getTableLabel, getColumnLabel } from './SQLLab' import type { AIHistory, SQLResult, TableSchema } from './SQLLab' -import { useDataService } from '@/services' +import { useDataService, useLLMService } from '@/services' const { t, locale } = useI18n() @@ -213,7 +213,7 @@ async function generateAndRunSQL() { return } - const hasConfig = await window.llmApi.hasConfig() + const hasConfig = await useLLMService().hasConfig() if (!hasConfig) { error.value = t('common.errorNoAIConfig') return diff --git a/src/components/common/Settings/AI/AIModelConfigTab.vue b/src/components/common/Settings/AI/AIModelConfigTab.vue index 9e10c875..9e6a8156 100644 --- a/src/components/common/Settings/AI/AIModelConfigTab.vue +++ b/src/components/common/Settings/AI/AIModelConfigTab.vue @@ -3,6 +3,7 @@ import { ref, computed, onMounted } from 'vue' import { storeToRefs } from 'pinia' import { useI18n } from 'vue-i18n' import { useLLMStore, type AIServiceConfigDisplay } from '@/stores/llm' +import { useLLMService } from '@/services' import type { ProviderDefinition } from '@electron/preload/index' import AIModelEditModal from './AIModelEditModal.vue' import AlertTips from './AlertTips.vue' @@ -52,7 +53,7 @@ async function handleModalSaved() { async function deleteConfig(id: string) { try { - const result = await window.llmApi.deleteConfig(id) + const result = await useLLMService().deleteConfig(id) if (result.success) { await llmStore.refreshConfigs() emit('config-changed') diff --git a/src/components/common/Settings/AI/useAIConfigForm.ts b/src/components/common/Settings/AI/useAIConfigForm.ts index 246b4780..86564c76 100644 --- a/src/components/common/Settings/AI/useAIConfigForm.ts +++ b/src/components/common/Settings/AI/useAIConfigForm.ts @@ -2,6 +2,7 @@ import { ref, computed, watch, type Ref } from 'vue' import { useI18n } from 'vue-i18n' import { useSettingsStore } from '@/stores/settings' import { useLLMStore } from '@/stores/llm' +import { useLLMService } from '@/services' import { canReuseExistingApiKey, getConnectionModeForConfig, type ConnectionMode } from './apiKeyReuse' // ==================== 类型 ==================== @@ -368,7 +369,7 @@ export function useAIConfigForm(props: { showRemoteModelBrowser.value = true try { - const result = await window.llmApi.fetchRemoteModels( + const result = await useLLMService().fetchRemoteModels( formData.value.provider || 'openai-compatible', apiKey, baseUrl, @@ -399,7 +400,7 @@ export function useAIConfigForm(props: { const providerId = formData.value.provider || 'openai-compatible' if (model.contextWindow && !llmStore.getModelById(providerId, model.id)?.contextWindow) { try { - await window.llmApi.addCustomModel({ + await useLLMService().addCustomModel({ id: model.id, providerId, name: model.name, @@ -418,7 +419,7 @@ export function useAIConfigForm(props: { const providerId = formData.value.provider || 'openai-compatible' if (!catalogModels.value.some((m) => m.id === model.id)) { try { - await window.llmApi.addCustomModel({ + await useLLMService().addCustomModel({ id: model.id, providerId, name: model.name, @@ -471,7 +472,7 @@ export function useAIConfigForm(props: { const providerId = formData.value.provider || 'openai-compatible' try { - await window.llmApi.addCustomModel({ + await useLLMService().addCustomModel({ id: modelId, providerId, name: modelName, @@ -514,9 +515,10 @@ export function useAIConfigForm(props: { if (editModelName.value.trim()) { updates.name = editModelName.value.trim() } - const result = await window.llmApi.updateCustomModel(providerId, modelId, updates) + const svc = useLLMService() + const result = await svc.updateCustomModel(providerId, modelId, updates) if (!result.success) { - await window.llmApi.addCustomModel({ + await svc.addCustomModel({ id: modelId, providerId, name: editModelName.value.trim() || modelId, @@ -546,7 +548,7 @@ export function useAIConfigForm(props: { const providerId = formData.value.provider || 'openai-compatible' try { - await window.llmApi.deleteCustomModel(providerId, modelId) + await useLLMService().deleteCustomModel(providerId, modelId) await llmStore.refreshConfigs() if (formData.value.model === modelId) { const models = catalogModels.value @@ -578,7 +580,7 @@ export function useAIConfigForm(props: { try { const testApiKey = apiKey || 'sk-no-key-required' - const result = await window.llmApi.validateApiKey( + const result = await useLLMService().validateApiKey( provider || 'openai-compatible', testApiKey, baseUrl || undefined, @@ -635,7 +637,7 @@ export function useAIConfigForm(props: { : undefined if (props.mode.value === 'add') { const savedApiFormat = isCompatMode.value ? formData.value.apiFormat || undefined : undefined - const result = await window.llmApi.addConfig({ + const result = await useLLMService().addConfig({ name: finalName, provider: finalProvider, apiKey: finalApiKey, @@ -666,7 +668,7 @@ export function useAIConfigForm(props: { updates.apiKey = finalApiKey } - const result = await window.llmApi.updateConfig(props.config.value!.id, updates) + const result = await useLLMService().updateConfig(props.config.value!.id, updates) if (result.success) { props.onClose() @@ -708,7 +710,7 @@ export function useAIConfigForm(props: { isValidating.value = true try { const testApiKey = formData.value.apiKey.trim() || 'sk-no-key-required' - const result = await window.llmApi.validateApiKey( + const result = await useLLMService().validateApiKey( formData.value.provider || 'openai-compatible', testApiKey, formData.value.baseUrl.trim() || undefined, diff --git a/src/services/assistant/fetch.ts b/src/services/assistant/fetch.ts new file mode 100644 index 00000000..5131d1a4 --- /dev/null +++ b/src/services/assistant/fetch.ts @@ -0,0 +1,53 @@ +import type { AssistantServiceAdapter, AssistantSummary, AssistantConfig, BuiltinAssistantInfo } from './types' +import { get, post, put, del } from '../utils/http' + +export class FetchAssistantAdapter implements AssistantServiceAdapter { + async getAll(): Promise { + return get('/ai/assistants') + } + + async getConfig(id: string): Promise { + try { + return await get(`/ai/assistants/${id}`) + } catch { + return null + } + } + + async create(config: Omit) { + return post<{ success: boolean; id?: string; error?: string }>('/ai/assistants', config) + } + + async update(id: string, updates: Partial) { + return put<{ success: boolean; error?: string }>(`/ai/assistants/${id}`, updates) + } + + async delete(id: string) { + return del<{ success: boolean; error?: string }>(`/ai/assistants/${id}`) + } + + async reset(id: string) { + return post<{ success: boolean; error?: string }>(`/ai/assistants/${id}/reset`, {}) + } + + async importFromMd(rawMd: string) { + return post<{ success: boolean; error?: string }>('/ai/assistants/import', { rawMd }) + } + + /** @deprecated Local builtin catalog is empty; kept for backward compatibility */ + async getBuiltinCatalog(): Promise { + return [] + } + + async importBuiltin(builtinId: string) { + return post<{ success: boolean; error?: string }>('/ai/assistants/import-builtin', { builtinId }) + } + + async reimport(id: string) { + return post<{ success: boolean; error?: string }>(`/ai/assistants/${id}/reimport`, {}) + } + + async getBuiltinToolCatalog(): Promise> { + return get>('/ai/tools/catalog') + } +} diff --git a/src/services/assistant/service.ts b/src/services/assistant/service.ts new file mode 100644 index 00000000..d704db4a --- /dev/null +++ b/src/services/assistant/service.ts @@ -0,0 +1,6 @@ +import { getRegisteredAdapter } from '../registry' +import type { AssistantServiceAdapter } from './types' + +export function useAssistantService(): AssistantServiceAdapter { + return getRegisteredAdapter('assistant-crud') +} diff --git a/src/services/assistant/types.ts b/src/services/assistant/types.ts new file mode 100644 index 00000000..ca47ecb6 --- /dev/null +++ b/src/services/assistant/types.ts @@ -0,0 +1,43 @@ +export interface AssistantSummary { + id: string + name: string + systemPrompt: string + presetQuestions: string[] + builtinId?: string + applicableChatTypes?: ('group' | 'private')[] + supportedLocales?: string[] +} + +export interface AssistantConfig { + id: string + name: string + systemPrompt: string + presetQuestions: string[] + allowedBuiltinTools?: string[] + builtinId?: string + applicableChatTypes?: ('group' | 'private')[] + supportedLocales?: string[] +} + +export interface BuiltinAssistantInfo { + id: string + name: string + systemPrompt: string + applicableChatTypes?: ('group' | 'private')[] + supportedLocales?: string[] + imported: boolean +} + +export interface AssistantServiceAdapter { + getAll(): Promise + getConfig(id: string): Promise + create(config: Omit): Promise<{ success: boolean; id?: string; error?: string }> + update(id: string, updates: Partial): Promise<{ success: boolean; error?: string }> + delete(id: string): Promise<{ success: boolean; error?: string }> + reset(id: string): Promise<{ success: boolean; error?: string }> + importFromMd(rawMd: string): Promise<{ success: boolean; error?: string }> + importBuiltin(builtinId: string): Promise<{ success: boolean; error?: string }> + reimport(id: string): Promise<{ success: boolean; error?: string }> + getBuiltinCatalog(): Promise + getBuiltinToolCatalog(): Promise> +} diff --git a/src/services/index.ts b/src/services/index.ts index 80e11352..bd85e6e7 100644 --- a/src/services/index.ts +++ b/src/services/index.ts @@ -46,6 +46,22 @@ export { usePlatformService } from './platform/service' export type { PlatformAdapter, OpenDialogOptions, OpenDialogResult, RemoteConfigResult } from './platform/types' export { useAIService } from './ai/service' export { usePreferencesService } from './preferences/service' +export { useLLMService } from './llm/service' +export type { + LLMServiceAdapter, + AIServiceConfigDisplay, + AIServiceConfigInput, + ModelSlot, + LLMProvider, + ProviderRegistryItem, + ModelCatalogItem, + CustomProviderInput, + CustomModelInput, +} from './llm/types' +export { useAssistantService } from './assistant/service' +export type { AssistantServiceAdapter } from './assistant/types' +export { useSkillService } from './skill/service' +export type { SkillServiceAdapter } from './skill/types' export type { PreferencesAdapter, Preferences, diff --git a/src/services/llm/fetch.ts b/src/services/llm/fetch.ts new file mode 100644 index 00000000..65c31d5f --- /dev/null +++ b/src/services/llm/fetch.ts @@ -0,0 +1,115 @@ +import type { + LLMServiceAdapter, + AIServiceConfigDisplay, + AIServiceConfigInput, + ModelSlot, + LLMConfigStore, + LLMProvider, + ProviderRegistryItem, + ModelCatalogItem, + CustomProviderInput, + CustomModelInput, +} from './types' +import { get, post, put, del } from '../utils/http' + +export class FetchLLMAdapter implements LLMServiceAdapter { + async hasConfig(): Promise { + return get('/ai/llm/has-config') + } + + async getConfigStore(): Promise { + return get('/ai/llm/configs') + } + + async getAllConfigs(): Promise { + const store = await this.getConfigStore() + return store.configs + } + + async getDefaultAssistantSlot(): Promise { + return get('/ai/llm/default-assistant-slot') + } + + async getFastModelSlot(): Promise { + return get('/ai/llm/fast-model-slot') + } + + async setDefaultAssistantModel(configId: string, modelId: string) { + return put<{ success: boolean; error?: string }>('/ai/llm/default-assistant-slot', { configId, modelId }) + } + + async setFastModel(slot: ModelSlot | null) { + return put<{ success: boolean; error?: string }>('/ai/llm/fast-model-slot', slot) + } + + async getProviders(): Promise { + return get('/ai/llm/providers') + } + + async getProviderRegistry(): Promise { + return get('/ai/llm/provider-registry') + } + + async getModelCatalog(): Promise { + return get('/ai/llm/model-catalog') + } + + async addConfig(config: AIServiceConfigInput) { + return post<{ success: boolean; config?: AIServiceConfigDisplay; error?: string }>('/ai/llm/configs', config) + } + + async updateConfig(id: string, updates: Partial) { + return put<{ success: boolean; error?: string }>(`/ai/llm/configs/${id}`, updates) + } + + async deleteConfig(id: string) { + return del<{ success: boolean; error?: string }>(`/ai/llm/configs/${id}`) + } + + async validateApiKey(provider: string, apiKey: string, baseUrl?: string, model?: string, apiFormat?: string) { + return post<{ success: boolean; error?: string }>('/ai/llm/validate-key', { + provider, + apiKey, + baseUrl, + model, + apiFormat, + }) + } + + async fetchRemoteModels(provider: string, apiKey: string, baseUrl?: string, apiFormat?: string) { + return post<{ + success: boolean + models?: Array<{ id: string; name: string; ownedBy?: string; contextWindow?: number }> + error?: string + }>('/ai/llm/remote-models', { provider, apiKey, baseUrl, apiFormat }) + } + + async addCustomProvider(input: CustomProviderInput) { + return post('/ai/llm/custom-providers', input) + } + + async updateCustomProvider(id: string, updates: Partial) { + return put<{ success: boolean; error?: string }>(`/ai/llm/custom-providers/${id}`, updates) + } + + async deleteCustomProvider(id: string) { + return del<{ success: boolean; error?: string }>(`/ai/llm/custom-providers/${id}`) + } + + async addCustomModel(input: CustomModelInput) { + return post<{ success: boolean; model?: ModelCatalogItem; error?: string }>('/ai/llm/custom-models', input) + } + + async updateCustomModel(providerId: string, modelId: string, updates: Partial) { + return put<{ success: boolean; error?: string }>( + `/ai/llm/custom-models/${providerId}/${encodeURIComponent(modelId)}`, + updates + ) + } + + async deleteCustomModel(providerId: string, modelId: string) { + return del<{ success: boolean; error?: string }>( + `/ai/llm/custom-models/${providerId}/${encodeURIComponent(modelId)}` + ) + } +} diff --git a/src/services/llm/service.ts b/src/services/llm/service.ts new file mode 100644 index 00000000..65ef888a --- /dev/null +++ b/src/services/llm/service.ts @@ -0,0 +1,6 @@ +import { getRegisteredAdapter } from '../registry' +import type { LLMServiceAdapter } from './types' + +export function useLLMService(): LLMServiceAdapter { + return getRegisteredAdapter('llm') +} diff --git a/src/services/llm/types.ts b/src/services/llm/types.ts new file mode 100644 index 00000000..b95c1efe --- /dev/null +++ b/src/services/llm/types.ts @@ -0,0 +1,141 @@ +export interface AIServiceConfigDisplay { + id: string + name: string + provider: string + apiKeySet: boolean + model?: string + baseUrl?: string + maxTokens?: number + apiFormat?: string + customModels?: Array<{ id: string; name: string }> + createdAt: number + updatedAt: number +} + +export interface ModelSlot { + configId: string + modelId: string +} + +export interface LLMConfigStore { + configs: AIServiceConfigDisplay[] + defaultAssistant: ModelSlot | null + fastModel: ModelSlot | null +} + +export interface LLMProvider { + id: string + name: string + defaultBaseUrl: string + models: Array<{ id: string; name: string; description?: string }> +} + +export type ProviderKind = 'official' | 'aggregator' | 'openai-compatible' + +export interface ProviderRegistryItem { + id: string + name: string + kind: ProviderKind + website?: string + consoleUrl?: string + defaultBaseUrl: string + authMode: 'api-key' + supportsCustomModels: boolean + builtin: boolean + enabledByDefault: boolean + modelIds: string[] +} + +export type ModelCapability = 'chat' | 'reasoning' | 'vision' | 'function_calling' | 'embedding' | 'ranking' +export type ModelStatus = 'stable' | 'preview' | 'deprecated' +export type ModelRecommendedFor = 'chat' | 'embedding' | 'rerank' + +export interface ModelCatalogItem { + id: string + providerId: string + name: string + description?: string + contextWindow?: number + capabilities: ModelCapability[] + recommendedFor: ModelRecommendedFor[] + status: ModelStatus + builtin: boolean + editable: boolean +} + +export interface CustomProviderInput { + name: string + kind?: string + defaultBaseUrl: string + supportsCustomModels?: boolean + modelIds?: string[] + website?: string + consoleUrl?: string +} + +export interface CustomModelInput { + id: string + providerId: string + name: string + description?: string + contextWindow?: number + capabilities?: string[] + recommendedFor?: string[] + status?: string +} + +export interface AIServiceConfigInput { + name: string + provider: string + apiKey: string + model?: string + baseUrl?: string + maxTokens?: number + apiFormat?: string + customModels?: Array<{ id: string; name: string }> +} + +export interface LLMServiceAdapter { + hasConfig(): Promise + getAllConfigs(): Promise + getConfigStore(): Promise + getDefaultAssistantSlot(): Promise + getFastModelSlot(): Promise + setDefaultAssistantModel(configId: string, modelId: string): Promise<{ success: boolean; error?: string }> + setFastModel(slot: ModelSlot | null): Promise<{ success: boolean; error?: string }> + getProviders(): Promise + getProviderRegistry(): Promise + getModelCatalog(): Promise + addConfig( + config: AIServiceConfigInput + ): Promise<{ success: boolean; config?: AIServiceConfigDisplay; error?: string }> + updateConfig(id: string, updates: Partial): Promise<{ success: boolean; error?: string }> + deleteConfig(id: string): Promise<{ success: boolean; error?: string }> + validateApiKey( + provider: string, + apiKey: string, + baseUrl?: string, + model?: string, + apiFormat?: string + ): Promise<{ success: boolean; error?: string }> + fetchRemoteModels( + provider: string, + apiKey: string, + baseUrl?: string, + apiFormat?: string + ): Promise<{ + success: boolean + models?: Array<{ id: string; name: string; ownedBy?: string; contextWindow?: number }> + error?: string + }> + addCustomProvider(input: CustomProviderInput): Promise + updateCustomProvider(id: string, updates: Partial): Promise<{ success: boolean; error?: string }> + deleteCustomProvider(id: string): Promise<{ success: boolean; error?: string }> + addCustomModel(input: CustomModelInput): Promise<{ success: boolean; model?: ModelCatalogItem; error?: string }> + updateCustomModel( + providerId: string, + modelId: string, + updates: Partial + ): Promise<{ success: boolean; error?: string }> + deleteCustomModel(providerId: string, modelId: string): Promise<{ success: boolean; error?: string }> +} diff --git a/src/services/registry.ts b/src/services/registry.ts index bf3c94d3..ca0f0b01 100644 --- a/src/services/registry.ts +++ b/src/services/registry.ts @@ -71,9 +71,8 @@ async function initElectronAdapters(): Promise { const { ElectronImportAdapter } = await import('./import/electron') registerAdapter('import', new ElectronImportAdapter()) - // Session-index stays on IPC: summaries depend on LLM config - const { ElectronSessionIndexAdapter } = await import('./session-index/electron') - registerAdapter('session-index', new ElectronSessionIndexAdapter()) + const { FetchSessionIndexAdapter } = await import('./session-index/fetch') + registerAdapter('session-index', new FetchSessionIndexAdapter()) const { FetchMessageAdapter } = await import('./message/fetch') registerAdapter('message', new FetchMessageAdapter()) @@ -86,6 +85,15 @@ async function initElectronAdapters(): Promise { const { FetchPreferencesAdapter } = await import('./preferences/fetch') registerAdapter('preferences', new FetchPreferencesAdapter()) + + const { FetchLLMAdapter } = await import('./llm/fetch') + registerAdapter('llm', new FetchLLMAdapter()) + + const { FetchAssistantAdapter } = await import('./assistant/fetch') + registerAdapter('assistant-crud', new FetchAssistantAdapter()) + + const { FetchSkillAdapter } = await import('./skill/fetch') + registerAdapter('skill-crud', new FetchSkillAdapter()) } async function initWebServeAdapters(): Promise { @@ -110,89 +118,24 @@ async function initWebServeAdapters(): Promise { const { FetchPreferencesAdapter } = await import('./preferences/fetch') registerAdapter('preferences', new FetchPreferencesAdapter()) + const { FetchLLMAdapter } = await import('./llm/fetch') + registerAdapter('llm', new FetchLLMAdapter()) + + const { FetchAssistantAdapter } = await import('./assistant/fetch') + registerAdapter('assistant-crud', new FetchAssistantAdapter()) + + const { FetchSkillAdapter } = await import('./skill/fetch') + registerAdapter('skill-crud', new FetchSkillAdapter()) + await installAiApiShims() } -const WEB_STUB = { success: false as const, error: 'Not available in web mode' } - +/** + * Install remaining window shims for SSE streaming APIs that the + * service-layer adapters do not (yet) cover. + */ async function installAiApiShims(): Promise { - const { get } = await import('./utils/http') - - ;(window as any).assistantApi = { - getAll: () => get('/ai/assistants'), - getConfig: (id: string) => get(`/ai/assistants/${id}`), - getBuiltinToolCatalog: () => get('/ai/tools/catalog'), - getBuiltinCatalog: () => Promise.resolve([]), - importFromMd: () => Promise.resolve(WEB_STUB), - update: () => Promise.resolve(WEB_STUB), - reset: () => Promise.resolve(WEB_STUB), - importAssistant: () => Promise.resolve(WEB_STUB), - reimportAssistant: () => Promise.resolve(WEB_STUB), - create: () => Promise.resolve(WEB_STUB), - delete: () => Promise.resolve(WEB_STUB), - } - ;(window as any).skillApi = { - getAll: () => get('/ai/skills'), - getConfig: (id: string) => get(`/ai/skills/${id}`), - getBuiltinCatalog: () => Promise.resolve([]), - importFromMd: () => Promise.resolve(WEB_STUB), - update: () => Promise.resolve(WEB_STUB), - create: () => Promise.resolve(WEB_STUB), - delete: () => Promise.resolve(WEB_STUB), - importSkill: () => Promise.resolve(WEB_STUB), - reimportSkill: () => Promise.resolve(WEB_STUB), - } - - let _cachedLlmData: { configs: any[]; defaultAssistant: any; fastModel: any } | null = null - async function loadLlmData() { - if (!_cachedLlmData) { - _cachedLlmData = await get('/ai/llm/configs') - } - return _cachedLlmData! - } - ;(window as any).llmApi = { - hasConfig: () => get('/ai/llm/has-config'), - getAllConfigs: async () => (await loadLlmData()).configs, - getDefaultAssistantSlot: () => get('/ai/llm/default-assistant-slot'), - getFastModelSlot: () => get('/ai/llm/fast-model-slot'), - getProviders: () => get('/ai/llm/providers'), - getProviderRegistry: () => get('/ai/llm/provider-registry'), - getModelCatalog: () => get('/ai/llm/model-catalog'), - setDefaultAssistantModel: async (configId: string, modelId: string) => { - const { put: httpPut } = await import('./utils/http') - _cachedLlmData = null - return httpPut('/ai/llm/default-assistant-slot', { configId, modelId }) - }, - setFastModel: async (slot: { configId: string; modelId: string } | null) => { - const { put: httpPut } = await import('./utils/http') - _cachedLlmData = null - return httpPut('/ai/llm/fast-model-slot', slot) - }, - deleteConfig: async (id?: string) => { - if (!id) return WEB_STUB - const { del } = await import('./utils/http') - _cachedLlmData = null - return del(`/ai/llm/configs/${id}`) - }, - addConfig: async (config: Record) => { - const { post: httpPost } = await import('./utils/http') - _cachedLlmData = null - return httpPost('/ai/llm/configs', config) - }, - updateConfig: async (id: string, updates: Record) => { - const { put: httpPut } = await import('./utils/http') - _cachedLlmData = null - return httpPut(`/ai/llm/configs/${id}`, updates) - }, - validateApiKey: async (provider: string, apiKey: string, baseUrl?: string, model?: string) => { - const { post: httpPost } = await import('./utils/http') - return httpPost('/ai/llm/validate-key', { provider, apiKey, baseUrl, model }) - }, - fetchRemoteModels: async (provider: string, apiKey: string, baseUrl?: string, apiFormat?: string) => { - const { post: httpPost } = await import('./utils/http') - return httpPost('/ai/llm/remote-models', { provider, apiKey, baseUrl, apiFormat }) - }, chatStream: async ( messages: Array<{ role: string; content: string }>, options?: { temperature?: number; maxTokens?: number }, @@ -227,18 +170,6 @@ async function installAiApiShims(): Promise { return { success: false, error: error instanceof Error ? error.message : String(error) } } }, - addCustomModel: async (input: Record) => { - const { post: httpPost } = await import('./utils/http') - return httpPost('/ai/llm/custom-models', input) - }, - updateCustomModel: async (providerId: string, modelId: string, updates: Record) => { - const { put: httpPut } = await import('./utils/http') - return httpPut(`/ai/llm/custom-models/${providerId}/${encodeURIComponent(modelId)}`, updates) - }, - deleteCustomModel: async (providerId: string, modelId: string) => { - const { del } = await import('./utils/http') - return del(`/ai/llm/custom-models/${providerId}/${encodeURIComponent(modelId)}`) - }, } const { fetchSSE } = await import('./utils/sse') const { post: httpPost } = await import('./utils/http') diff --git a/src/services/skill/fetch.ts b/src/services/skill/fetch.ts new file mode 100644 index 00000000..bde11508 --- /dev/null +++ b/src/services/skill/fetch.ts @@ -0,0 +1,48 @@ +import type { SkillServiceAdapter, SkillSummary, SkillConfig, BuiltinSkillInfo } from './types' +import { get, post, put, del } from '../utils/http' + +export class FetchSkillAdapter implements SkillServiceAdapter { + async getAll(): Promise { + return get('/ai/skills') + } + + async getConfig(id: string): Promise { + try { + return await get(`/ai/skills/${id}`) + } catch { + return null + } + } + + async create(rawMd: string) { + return post<{ success: boolean; id?: string; error?: string }>('/ai/skills', { rawMd }) + } + + async update(id: string, rawMd: string) { + return put<{ success: boolean; error?: string }>(`/ai/skills/${id}`, { rawMd }) + } + + async delete(id: string) { + return del<{ success: boolean; error?: string }>(`/ai/skills/${id}`) + } + + async importFromMd(rawMd: string) { + return post<{ success: boolean; error?: string }>('/ai/skills/import', { rawMd }) + } + + async importBuiltin(builtinId: string) { + return post<{ success: boolean; id?: string; error?: string }>('/ai/skills/import-builtin', { builtinId }) + } + + async reimport(id: string) { + return post<{ success: boolean; error?: string }>(`/ai/skills/${id}/reimport`, {}) + } + + async getBuiltinCatalog(): Promise { + try { + return await get('/ai/skills/builtin-catalog') + } catch { + return [] + } + } +} diff --git a/src/services/skill/service.ts b/src/services/skill/service.ts new file mode 100644 index 00000000..914d0c1f --- /dev/null +++ b/src/services/skill/service.ts @@ -0,0 +1,6 @@ +import { getRegisteredAdapter } from '../registry' +import type { SkillServiceAdapter } from './types' + +export function useSkillService(): SkillServiceAdapter { + return getRegisteredAdapter('skill-crud') +} diff --git a/src/services/skill/types.ts b/src/services/skill/types.ts new file mode 100644 index 00000000..d49506b1 --- /dev/null +++ b/src/services/skill/types.ts @@ -0,0 +1,37 @@ +export interface SkillSummary { + id: string + name: string + description: string + tags: string[] + chatScope: 'all' | 'group' | 'private' + tools: string[] + builtinId?: string +} + +export interface SkillConfig { + id: string + name: string + description: string + tags: string[] + chatScope: 'all' | 'group' | 'private' + prompt: string + tools: string[] + builtinId?: string +} + +export interface BuiltinSkillInfo extends SkillSummary { + imported: boolean + hasUpdate: boolean +} + +export interface SkillServiceAdapter { + getAll(): Promise + getConfig(id: string): Promise + create(rawMd: string): Promise<{ success: boolean; id?: string; error?: string }> + update(id: string, rawMd: string): Promise<{ success: boolean; error?: string }> + delete(id: string): Promise<{ success: boolean; error?: string }> + importFromMd(rawMd: string): Promise<{ success: boolean; error?: string }> + importBuiltin(builtinId: string): Promise<{ success: boolean; id?: string; error?: string }> + reimport(id: string): Promise<{ success: boolean; error?: string }> + getBuiltinCatalog(): Promise +} diff --git a/src/stores/aiChat.ts b/src/stores/aiChat.ts index 6a8a93f0..9abd5b07 100644 --- a/src/stores/aiChat.ts +++ b/src/stores/aiChat.ts @@ -10,7 +10,7 @@ import { storeToRefs } from 'pinia' import { usePromptStore } from '@/stores/prompt' import { useSessionStore } from '@/stores/session' import { useSettingsStore } from '@/stores/settings' -import { useDataService, useAIService } from '@/services' +import { useDataService, useAIService, useLLMService } from '@/services' import type { AIMessage as PersistedAIMessage } from '@/services/ai/types' import { useAssistantStore } from '@/stores/assistant' import { useSkillStore } from '@/stores/skill' @@ -702,7 +702,7 @@ export const useAIChatStore = defineStore('aiChatRuntime', () => { state.currentAgentRequestId = '' try { - const hasConfig = await window.llmApi.hasConfig() + const hasConfig = await useLLMService().hasConfig() if (state.isAborted) { clearActiveTask(chatKey, thisRequestId) return { success: false, reason: 'aborted' } @@ -1212,7 +1212,7 @@ export const useAIChatStore = defineStore('aiChatRuntime', () => { content: string ): Promise { try { - const hasConfig = await window.llmApi.hasConfig() + const hasConfig = await useLLMService().hasConfig() if (!hasConfig) { return { success: false, reason: 'no_config' } } @@ -1298,7 +1298,7 @@ export const useAIChatStore = defineStore('aiChatRuntime', () => { createStreamBlockHelpers(targetBuffer, () => aiMessageIndex) try { - const hasConfig = await window.llmApi.hasConfig() + const hasConfig = await useLLMService().hasConfig() if (!hasConfig) { restoreOriginal() clearActiveTask(chatKey, thisRequestId) diff --git a/src/stores/assistant.ts b/src/stores/assistant.ts index fff7b69f..b497a850 100644 --- a/src/stores/assistant.ts +++ b/src/stores/assistant.ts @@ -6,7 +6,7 @@ import { defineStore } from 'pinia' import { ref, computed } from 'vue' import { i18n } from '@/i18n' -import { usePlatformService } from '@/services' +import { usePlatformService, useAssistantService } from '@/services' import { CHATLAB_SITE_BASE } from '@/utils/chatlabSiteLocale' const CLOUD_MARKET_BASE_URL = CHATLAB_SITE_BASE @@ -108,7 +108,8 @@ export const useAssistantStore = defineStore('assistant', () => { async function loadAssistants(): Promise { try { - assistants.value = await window.assistantApi.getAll() + const svc = useAssistantService() + assistants.value = await svc.getAll() isLoaded.value = true } catch (error) { console.error('[AssistantStore] Failed to load assistants:', error) @@ -118,7 +119,7 @@ export const useAssistantStore = defineStore('assistant', () => { /** @deprecated 本地内置目录已清空,保留兼容 */ async function loadBuiltinCatalog(): Promise { try { - builtinCatalog.value = await window.assistantApi.getBuiltinCatalog() + builtinCatalog.value = await useAssistantService().getBuiltinCatalog() } catch (error) { console.error('[AssistantStore] Failed to load builtin catalog:', error) } @@ -126,7 +127,7 @@ export const useAssistantStore = defineStore('assistant', () => { async function loadBuiltinToolCatalog(): Promise { try { - builtinToolCatalog.value = await window.assistantApi.getBuiltinToolCatalog() + builtinToolCatalog.value = await useAssistantService().getBuiltinToolCatalog() } catch (error) { console.error('[AssistantStore] Failed to load builtin tool catalog:', error) } @@ -175,7 +176,7 @@ export const useAssistantStore = defineStore('assistant', () => { return { success: false, error: mdResult.error || 'Failed to fetch assistant content' } } - const result = await window.assistantApi.importFromMd(mdResult.data) + const result = await useAssistantService().importFromMd(mdResult.data) if (result.success) { await loadAssistants() } @@ -201,7 +202,7 @@ export const useAssistantStore = defineStore('assistant', () => { async function getAssistantConfig(id: string): Promise { try { - return await window.assistantApi.getConfig(id) + return await useAssistantService().getConfig(id) } catch (error) { console.error('[AssistantStore] Failed to get config:', error) return null @@ -213,10 +214,8 @@ export const useAssistantStore = defineStore('assistant', () => { updates: Partial ): Promise<{ success: boolean; error?: string }> { try { - const result = await window.assistantApi.update(id, updates) - if (result.success) { - await loadAssistants() - } + const result = await useAssistantService().update(id, updates) + if (result.success) await loadAssistants() return result } catch (error) { return { success: false, error: String(error) } @@ -225,10 +224,8 @@ export const useAssistantStore = defineStore('assistant', () => { async function resetAssistant(id: string): Promise<{ success: boolean; error?: string }> { try { - const result = await window.assistantApi.reset(id) - if (result.success) { - await loadAssistants() - } + const result = await useAssistantService().reset(id) + if (result.success) await loadAssistants() return result } catch (error) { return { success: false, error: String(error) } @@ -237,7 +234,7 @@ export const useAssistantStore = defineStore('assistant', () => { async function importAssistant(builtinId: string): Promise<{ success: boolean; error?: string }> { try { - const result = await window.assistantApi.importAssistant(builtinId) + const result = await useAssistantService().importBuiltin(builtinId) if (result.success) { await loadAssistants() await loadBuiltinCatalog() @@ -250,7 +247,7 @@ export const useAssistantStore = defineStore('assistant', () => { async function reimportAssistant(id: string): Promise<{ success: boolean; error?: string }> { try { - const result = await window.assistantApi.reimportAssistant(id) + const result = await useAssistantService().reimport(id) if (result.success) { await loadAssistants() await loadBuiltinCatalog() @@ -265,10 +262,8 @@ export const useAssistantStore = defineStore('assistant', () => { config: Omit ): Promise<{ success: boolean; id?: string; error?: string }> { try { - const result = await window.assistantApi.create(config) - if (result.success) { - await loadAssistants() - } + const result = await useAssistantService().create(config) + if (result.success) await loadAssistants() return result } catch (error) { return { success: false, error: String(error) } @@ -277,18 +272,15 @@ export const useAssistantStore = defineStore('assistant', () => { async function duplicateAssistant(id: string): Promise<{ success: boolean; error?: string }> { try { - const config = await window.assistantApi.getConfig(id) - if (!config) { - return { success: false, error: 'Assistant not found' } - } + const svc = useAssistantService() + const config = await svc.getConfig(id) + if (!config) return { success: false, error: 'Assistant not found' } const { id: _id, builtinId: _bid, ...rest } = config - const result = await window.assistantApi.create({ + const result = await svc.create({ ...rest, name: `${config.name}${i18n.global.t('ai.assistant.duplicateSuffix')}`, }) - if (result.success) { - await loadAssistants() - } + if (result.success) await loadAssistants() return result } catch (error) { return { success: false, error: String(error) } @@ -297,10 +289,8 @@ export const useAssistantStore = defineStore('assistant', () => { async function deleteAssistant(id: string): Promise<{ success: boolean; error?: string }> { try { - const result = await window.assistantApi.delete(id) - if (result.success) { - await loadAssistants() - } + const result = await useAssistantService().delete(id) + if (result.success) await loadAssistants() return result } catch (error) { return { success: false, error: String(error) } diff --git a/src/stores/llm.ts b/src/stores/llm.ts index e4d45109..79a60c70 100644 --- a/src/stores/llm.ts +++ b/src/stores/llm.ts @@ -1,28 +1,10 @@ import { defineStore } from 'pinia' import { ref, computed } from 'vue' import type { ProviderDefinition, ModelDefinition } from '@electron/preload/index' +import { useLLMService } from '@/services' +import type { AIServiceConfigDisplay, LLMProvider } from '@/services' -/** - * LLM 服务配置(展示用,不含敏感信息) - */ -export interface AIServiceConfigDisplay { - id: string - name: string - provider: string - apiKeySet: boolean - model?: string - baseUrl?: string - customModels?: Array<{ id: string; name: string }> - createdAt: number - updatedAt: number -} - -export interface LLMProvider { - id: string - name: string - defaultBaseUrl: string - models: Array<{ id: string; name: string; description?: string }> -} +export type { AIServiceConfigDisplay, LLMProvider } export const useLLMStore = defineStore('llm', () => { // ============ 状态 ============ @@ -74,18 +56,19 @@ export const useLLMStore = defineStore('llm', () => { async function loadConfigs() { isLoading.value = true try { - const [providersData, registryData, catalogData, configsData, assistantSlot, fastSlot] = await Promise.all([ - window.llmApi.getProviders(), - window.llmApi.getProviderRegistry(), - window.llmApi.getModelCatalog(), - window.llmApi.getAllConfigs(), - window.llmApi.getDefaultAssistantSlot(), - window.llmApi.getFastModelSlot(), + const svc = useLLMService() + const [providersData, registryData, catalogData, configStore, assistantSlot, fastSlot] = await Promise.all([ + svc.getProviders(), + svc.getProviderRegistry(), + svc.getModelCatalog(), + svc.getConfigStore(), + svc.getDefaultAssistantSlot(), + svc.getFastModelSlot(), ]) providers.value = providersData - providerRegistry.value = registryData - modelCatalog.value = catalogData - configs.value = configsData + providerRegistry.value = registryData as ProviderDefinition[] + modelCatalog.value = catalogData as ModelDefinition[] + configs.value = configStore.configs defaultAssistant.value = assistantSlot fastModel.value = fastSlot } catch (error) { @@ -97,7 +80,7 @@ export const useLLMStore = defineStore('llm', () => { async function setDefaultAssistantModel(configId: string, modelId: string): Promise { try { - const result = await window.llmApi.setDefaultAssistantModel(configId, modelId) + const result = await useLLMService().setDefaultAssistantModel(configId, modelId) if (result.success) { defaultAssistant.value = { configId, modelId } return true @@ -112,7 +95,7 @@ export const useLLMStore = defineStore('llm', () => { async function setFastModel(slot: { configId: string; modelId: string } | null): Promise { try { - const result = await window.llmApi.setFastModel(slot) + const result = await useLLMService().setFastModel(slot) if (result.success) { fastModel.value = slot return true diff --git a/src/stores/skill.ts b/src/stores/skill.ts index ce4d1d01..85489e2a 100644 --- a/src/stores/skill.ts +++ b/src/stores/skill.ts @@ -6,7 +6,7 @@ import { defineStore } from 'pinia' import { ref, computed } from 'vue' import { useAssistantStore } from './assistant' -import { usePlatformService } from '@/services' +import { usePlatformService, useSkillService } from '@/services' import { CHATLAB_SITE_BASE } from '@/utils/chatlabSiteLocale' const CLOUD_MARKET_BASE_URL = CHATLAB_SITE_BASE @@ -109,7 +109,7 @@ export const useSkillStore = defineStore('skill', () => { async function loadSkills(): Promise { try { - skills.value = await window.skillApi.getAll() + skills.value = await useSkillService().getAll() isLoaded.value = true } catch (error) { console.error('[SkillStore] Failed to load skills:', error) @@ -119,7 +119,7 @@ export const useSkillStore = defineStore('skill', () => { /** @deprecated 本地内置目录已清空,保留兼容 */ async function loadBuiltinCatalog(): Promise { try { - builtinCatalog.value = await window.skillApi.getBuiltinCatalog() + builtinCatalog.value = await useSkillService().getBuiltinCatalog() } catch (error) { console.error('[SkillStore] Failed to load builtin catalog:', error) } @@ -167,7 +167,7 @@ export const useSkillStore = defineStore('skill', () => { return { success: false, error: mdResult.error || 'Failed to fetch skill content' } } - const result = await window.skillApi.importFromMd(mdResult.data) + const result = await useSkillService().importFromMd(mdResult.data) if (result.success) { await loadSkills() } @@ -189,7 +189,7 @@ export const useSkillStore = defineStore('skill', () => { async function getSkillConfig(id: string): Promise { try { - return await window.skillApi.getConfig(id) + return await useSkillService().getConfig(id) } catch (error) { console.error('[SkillStore] Failed to get skill config:', error) return null @@ -198,10 +198,8 @@ export const useSkillStore = defineStore('skill', () => { async function updateSkill(id: string, rawMd: string): Promise<{ success: boolean; error?: string }> { try { - const result = await window.skillApi.update(id, rawMd) - if (result.success) { - await loadSkills() - } + const result = await useSkillService().update(id, rawMd) + if (result.success) await loadSkills() return result } catch (error) { return { success: false, error: String(error) } @@ -210,10 +208,8 @@ export const useSkillStore = defineStore('skill', () => { async function createSkill(rawMd: string): Promise<{ success: boolean; id?: string; error?: string }> { try { - const result = await window.skillApi.create(rawMd) - if (result.success) { - await loadSkills() - } + const result = await useSkillService().create(rawMd) + if (result.success) await loadSkills() return result } catch (error) { return { success: false, error: String(error) } @@ -222,11 +218,9 @@ export const useSkillStore = defineStore('skill', () => { async function deleteSkill(id: string): Promise<{ success: boolean; error?: string }> { try { - const result = await window.skillApi.delete(id) + const result = await useSkillService().delete(id) if (result.success) { - if (activeSkillId.value === id) { - activeSkillId.value = null - } + if (activeSkillId.value === id) activeSkillId.value = null await loadSkills() } return result @@ -237,7 +231,7 @@ export const useSkillStore = defineStore('skill', () => { async function importSkill(builtinId: string): Promise<{ success: boolean; id?: string; error?: string }> { try { - const result = await window.skillApi.importSkill(builtinId) + const result = await useSkillService().importBuiltin(builtinId) if (result.success) { await loadSkills() await loadBuiltinCatalog() @@ -250,7 +244,7 @@ export const useSkillStore = defineStore('skill', () => { async function reimportSkill(id: string): Promise<{ success: boolean; error?: string }> { try { - const result = await window.skillApi.reimportSkill(id) + const result = await useSkillService().reimport(id) if (result.success) { await loadSkills() await loadBuiltinCatalog()