diff --git a/.github/workflows/verify.yml b/.github/workflows/verify.yml index 6682ad6..f8ade4f 100644 --- a/.github/workflows/verify.yml +++ b/.github/workflows/verify.yml @@ -12,8 +12,9 @@ name: Verify # # better_sqlite3.node, vec0.so/.dylib/.dll, skia.*.node # -# `lint` is deliberately not part of this gate: the repository currently has 6 -# pre-existing renderer react-hooks errors, unrelated to build correctness. +# `lint` is deliberately not part of this gate: it reports no errors but still +# carries a large pre-existing `@typescript-eslint/no-explicit-any` baseline, +# unrelated to build correctness. on: pull_request: @@ -51,6 +52,9 @@ jobs: - name: Typecheck run: npm run typecheck + - name: Unit tests + run: npm test + # Runs `npm run build` first, then electron-builder --dir, so the bundle and # the package are both covered by this one step. - name: Build and package (unpacked) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index a73557a..220e156 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -42,7 +42,8 @@ npm run dev | Command | What it does | | ------------------------ | ------------------------------------------------------------- | | `npm run dev` | Start the app in development with HMR. | -| `npm run typecheck` | Typecheck the main/preload and renderer projects. | +| `npm run typecheck` | Typecheck the main/preload, renderer and test projects. | +| `npm test` | Run the Node test suite (`test/**/*.test.ts`). | | `npm run lint` | ESLint (see the note on the current baseline below). | | `npm run format` | Prettier over the whole repository. | | `npm run build` | Typecheck, then bundle with electron-vite. | @@ -55,6 +56,7 @@ npm run dev ```bash npm run typecheck # required — this is a CI gate +npm test # required — this is a CI gate npm run build ``` @@ -75,21 +77,22 @@ against the real `app.asar`, so it is the step that actually catches these. Two caveats: -- **`lint` is not a gate.** `main` currently reports 6 pre-existing - `react-hooks` errors and 156 warnings. Don't fix them in an unrelated PR, and - don't add new ones. +- **`lint` is not a gate.** `npm run lint` has no errors, but `main` still + reports ~118 pre-existing `@typescript-eslint/no-explicit-any` warnings. + Don't add new ones, and don't mass-refactor them in an unrelated PR. - **Don't run `npm run format` on the whole repository.** It rewrites every - file, and one pre-existing file (`src/main/services/FileParserService.ts`) is - not prettier-clean, so a repo-wide format pulls unrelated changes into your - diff. Format only what you touched: + file, and a repo-wide format pulls unrelated changes into your diff. Format + only what you touched: ```bash npx prettier --write ``` -There is **no automated test suite yet**. `test/fixtures/` contains sample -documents for manual parser checks. Describe your manual verification in the PR -instead — which platform, which workflow, what you observed. +Tests live in `test/**/*.test.ts` and run on Node's built-in test runner via +`npm test` (no test framework dependency). `test/fixtures/` additionally +contains sample documents for manual parser checks. For anything not covered by +a test, describe your manual verification in the PR instead — which platform, +which workflow, what you observed. ## Conventions @@ -109,7 +112,7 @@ welcome when it is meaningful: `fix(mac): ...`. src/main/ Electron main process db/ Drizzle schema, migrations, queries services/ Document parsing, RAG, item and note storage - providers/ LLM provider abstraction and registry + models/ Model Connection resolution and API protocol adapters src/preload/ IPC bridge src/renderer/ React UI src/shared/ Types and utilities used by more than one process diff --git a/README.md b/README.md index 44cb877..ef0b64c 100644 --- a/README.md +++ b/README.md @@ -46,7 +46,7 @@ It’s still early, but the core idea works — and I’m excited to share it an - 📚 Build a local knowledge base from documents and notes - 💬 Chat, summarize, and reason with your content using LLMs -- 🔌 Provider-based LLM design (OpenAI, DeepSeek, Ollama, and more) +- 🔌 Bring your own API: any OpenAI-, Anthropic- or Google-compatible endpoint, or a local server - 🔍 RAG-powered retrieval with precise source traceability - 🖥️ Desktop app built with Electron — no Docker, no server setup @@ -83,7 +83,7 @@ KnowNote is for you if: ### 🤖 AI-Powered Q&A - Retrieval-Augmented Generation (RAG) -- Multiple LLM providers +- Any OpenAI / Anthropic / Google-compatible endpoint, or local models - Answers with precise source references ### 🔒 Local-First by Design @@ -123,7 +123,7 @@ Feedback and suggestions are very welcome. ### ✅ Completed -- AI LLM conversation with multiple providers +- AI LLM conversation with any compatible endpoint, local or remote - Structured note generation - One-click mind map generation - RAG-based document retrieval @@ -177,7 +177,7 @@ KnowNote/ │ ├── main/ # Electron main process │ │ ├── db/ # Database configuration and schema │ │ ├── services/ # Core logic (document parsing, RAG, etc.) -│ │ └── providers/ # LLM provider abstraction +│ │ └── models/ # Model connection resolution and API protocol adapters │ ├── renderer/ # React renderer process │ ├── preload/ # Electron preload scripts │ └── shared/ # Shared types and utilities @@ -196,7 +196,7 @@ If you have ideas about: - learning workflows - knowledge visualization -- model/provider abstraction +- model connection / API protocol abstraction I’d love to hear them. diff --git a/README_CN.md b/README_CN.md index 8f0fff9..c454ec6 100644 --- a/README_CN.md +++ b/README_CN.md @@ -46,7 +46,7 @@ - 📚 从文档和笔记构建本地知识库 - 💬 使用 LLM 对内容进行聊天、总结和推理 -- 🔌 基于提供商的 LLM 设计(OpenAI、DeepSeek、Ollama 等) +- 🔌 自带 API:任何兼容 OpenAI、Anthropic 或 Google 协议的端点,或本地部署 - 🔍 基于 RAG 的检索,具有精确的来源追溯能力 - 🖥️ 使用 Electron 构建的桌面应用——无需 Docker,无需服务器设置 @@ -83,7 +83,7 @@ ### 🤖 AI 驱动的问答 - 检索增强生成(RAG) -- 多种 LLM 提供商 +- 任何兼容 OpenAI / Anthropic / Google 协议的端点,或本地模型 - 带有精确来源引用的答案 ### 🔒 本地优先设计 @@ -123,7 +123,7 @@ KnowNote 是一个早期阶段的项目。 ### ✅ 已完成 -- 支持多个提供商的 AI LLM 对话 +- 支持任意兼容端点的 AI LLM 对话(本地或远程) - 结构化笔记生成 - 一键生成思维导图 - 基于 RAG 的文档检索 @@ -177,7 +177,7 @@ KnowNote/ │ ├── main/ # Electron 主进程 │ │ ├── db/ # 数据库配置和架构 │ │ ├── services/ # 核心逻辑(文档解析、RAG 等) -│ │ └── providers/ # LLM 提供商抽象 +│ │ └── models/ # 模型连接解析与 API 协议适配器 │ ├── renderer/ # React 渲染进程 │ ├── preload/ # Electron 预加载脚本 │ └── shared/ # 共享类型和工具 @@ -196,7 +196,7 @@ KnowNote/ - 学习工作流程 - 知识可视化 -- 模型/提供商抽象 +- 模型连接 / API 协议抽象 我很乐意听取。 diff --git a/package-lock.json b/package-lock.json index f9f3353..b4532f0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18,8 +18,8 @@ "sqlite-vec": "0.1.7-alpha.2" }, "devDependencies": { - "@ai-sdk/alibaba": "^0.0.31", - "@ai-sdk/deepseek": "^1.0.33", + "@ai-sdk/anthropic": "^2.0.105", + "@ai-sdk/google": "^2.0.99", "@ai-sdk/openai": "^2.0.89", "@ai-sdk/openai-compatible": "^1.0.31", "@dagrejs/dagre": "^1.1.8", @@ -89,7 +89,6 @@ "nanoid": "^3.3.11", "next-themes": "^0.4.6", "officeparser": "^5.2.2", - "ollama-ai-provider-v2": "^1.5.5", "postcss": "^8.5.6", "prettier": "^3.8.1", "react": "^19.2.4", @@ -122,14 +121,13 @@ "integrity": "sha512-ZnR3GSaH+/vJ0YlHau21FjfLYjMpYVIzTD8M8vIEQvIGxeOXyXdzCI140rrCY862p/C/BbzWsjc1dgnM9mkoTA==", "license": "MIT" }, - "node_modules/@ai-sdk/alibaba": { - "version": "0.0.31", - "resolved": "https://registry.npmmirror.com/@ai-sdk/alibaba/-/alibaba-0.0.31.tgz", - "integrity": "sha512-t3OYdMn+G71GcnedQOROwFn3by4yL9/WuMlyHI9FtgXTYn5oByVouhrN8jYTd/hvSU0jPb2EMVvj0NKOsQ0NZQ==", + "node_modules/@ai-sdk/anthropic": { + "version": "2.0.105", + "resolved": "https://registry.npmmirror.com/@ai-sdk/anthropic/-/anthropic-2.0.105.tgz", + "integrity": "sha512-yz+RATWLmsvxKuswRxuvasUlrhxgwCY9RrafM2q2ZBax7XFDuiMR7fv/syMTNRtLo4Y3Oclz8x89V28TiAvARA==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@ai-sdk/openai-compatible": "1.0.56", "@ai-sdk/provider": "2.0.5", "@ai-sdk/provider-utils": "3.0.39" }, @@ -140,15 +138,16 @@ "zod": "^3.25.76 || ^4.1.8" } }, - "node_modules/@ai-sdk/deepseek": { - "version": "1.0.59", - "resolved": "https://registry.npmmirror.com/@ai-sdk/deepseek/-/deepseek-1.0.59.tgz", - "integrity": "sha512-mzXMvEW+uSjf6JldiwluaOab6UB1Zu8ETl/uvifd0G+BKo0qPss2IYqJTfU8/H6lrVntFhniBgu+bG7XFwtNtA==", + "node_modules/@ai-sdk/gateway": { + "version": "2.0.157", + "resolved": "https://registry.npmmirror.com/@ai-sdk/gateway/-/gateway-2.0.157.tgz", + "integrity": "sha512-fEhZ1W1Dn3Lyr4pm8bxSoH79CRFCxBR4JW/lrN7A3TpoNXN7O4uHBcVNaEqESJVjBOc6mESG9jaFALRixHgodw==", "dev": true, "license": "Apache-2.0", "dependencies": { "@ai-sdk/provider": "2.0.5", - "@ai-sdk/provider-utils": "3.0.39" + "@ai-sdk/provider-utils": "3.0.39", + "@vercel/oidc": "3.1.0" }, "engines": { "node": ">=18" @@ -157,16 +156,15 @@ "zod": "^3.25.76 || ^4.1.8" } }, - "node_modules/@ai-sdk/gateway": { - "version": "2.0.157", - "resolved": "https://registry.npmmirror.com/@ai-sdk/gateway/-/gateway-2.0.157.tgz", - "integrity": "sha512-fEhZ1W1Dn3Lyr4pm8bxSoH79CRFCxBR4JW/lrN7A3TpoNXN7O4uHBcVNaEqESJVjBOc6mESG9jaFALRixHgodw==", + "node_modules/@ai-sdk/google": { + "version": "2.0.99", + "resolved": "https://registry.npmmirror.com/@ai-sdk/google/-/google-2.0.99.tgz", + "integrity": "sha512-TibnKH6ZZ5aPgS7G09On+VdZmy08BIck5JsNL9ddbPyV8ofbPTIAwPhPWRZvBKOOLNbk/Zc5PP0YpGsBin/rJA==", "dev": true, "license": "Apache-2.0", "dependencies": { "@ai-sdk/provider": "2.0.5", - "@ai-sdk/provider-utils": "3.0.39", - "@vercel/oidc": "3.1.0" + "@ai-sdk/provider-utils": "3.0.39" }, "engines": { "node": ">=18" @@ -13576,23 +13574,6 @@ "node": ">=12" } }, - "node_modules/ollama-ai-provider-v2": { - "version": "1.5.5", - "resolved": "https://registry.npmmirror.com/ollama-ai-provider-v2/-/ollama-ai-provider-v2-1.5.5.tgz", - "integrity": "sha512-1YwTFdPjhPNHny/DrOHO+s8oVGGIE5Jib61/KnnjPRNWQhVVimrJJdaAX3e6nNRRDXrY5zbb9cfm2+yVvgsrqw==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@ai-sdk/provider": "^2.0.0", - "@ai-sdk/provider-utils": "^3.0.17" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "zod": "^4.0.16" - } - }, "node_modules/once": { "version": "1.4.0", "resolved": "https://registry.npmmirror.com/once/-/once-1.4.0.tgz", diff --git a/package.json b/package.json index 0512bc0..99c3168 100644 --- a/package.json +++ b/package.json @@ -18,7 +18,9 @@ "lint": "eslint --cache .", "typecheck:node": "tsc --noEmit -p tsconfig.node.json --composite false", "typecheck:web": "tsc --noEmit -p tsconfig.web.json --composite false", - "typecheck": "npm run typecheck:node && npm run typecheck:web", + "typecheck:test": "tsc -p tsconfig.test.json", + "typecheck": "npm run typecheck:node && npm run typecheck:web && npm run typecheck:test", + "test": "node --disable-warning=MODULE_TYPELESS_PACKAGE_JSON --test \"test/**/*.test.ts\"", "start": "electron-vite preview", "dev": "electron-vite dev", "build": "npm run typecheck && electron-vite build", @@ -90,8 +92,8 @@ "rather than in app.asar." ], "devDependencies": { - "@ai-sdk/alibaba": "^0.0.31", - "@ai-sdk/deepseek": "^1.0.33", + "@ai-sdk/anthropic": "^2.0.105", + "@ai-sdk/google": "^2.0.99", "@ai-sdk/openai": "^2.0.89", "@ai-sdk/openai-compatible": "^1.0.31", "@dagrejs/dagre": "^1.1.8", @@ -161,7 +163,6 @@ "nanoid": "^3.3.11", "next-themes": "^0.4.6", "officeparser": "^5.2.2", - "ollama-ai-provider-v2": "^1.5.5", "postcss": "^8.5.6", "prettier": "^3.8.1", "react": "^19.2.4", diff --git a/src/main/config/ConnectionConfigManager.ts b/src/main/config/ConnectionConfigManager.ts new file mode 100644 index 0000000..aa0afe6 --- /dev/null +++ b/src/main/config/ConnectionConfigManager.ts @@ -0,0 +1,90 @@ +/** + * ConnectionConfigManager + * 持久化 Model Connection 配置(apiKey 使用 safeStorage 加密)。 + */ + +import type Store from 'electron-store' +import type { ConnectionMap, ModelCapability, ModelConnection } from '../../shared/types' +import { MODEL_CAPABILITIES } from '../../shared/types' +import type { StoreSchema } from './types' +import { encryptObjectFields, decryptObjectFields, isEncryptionAvailable } from './secureStorage' +import Logger from '../../shared/utils/logger' + +const SENSITIVE_FIELDS = ['apiKey'] + +export class ConnectionConfigManager { + private getStore: () => Promise> + + constructor(getStore: () => Promise>) { + this.getStore = getStore + } + + /** + * 读取全部连接(自动解密 apiKey) + */ + async getConnections(): Promise { + const store = await this.getStore() + const stored = store.get('connections', {}) as ConnectionMap + const result: ConnectionMap = {} + + for (const capability of MODEL_CAPABILITIES) { + const connection = stored?.[capability] + if (connection) { + result[capability] = decryptObjectFields({ ...connection }, SENSITIVE_FIELDS) + } + } + + return result + } + + /** + * 读取单个连接 + */ + async getConnection(capability: ModelCapability): Promise { + const connections = await this.getConnections() + return connections[capability] ?? null + } + + /** + * 保存单个连接(覆盖同一 capability 的已有连接) + */ + async saveConnection(capability: ModelCapability, connection: ModelConnection): Promise { + const store = await this.getStore() + const connections = { ...((store.get('connections', {}) as ConnectionMap) || {}) } + + let stored: ModelConnection = { ...connection } + if (isEncryptionAvailable()) { + stored = encryptObjectFields(stored, SENSITIVE_FIELDS) + } else { + Logger.warn( + 'ConnectionConfigManager', + `Encryption not available, storing ${capability} apiKey in plain text` + ) + } + + connections[capability] = stored + store.set('connections', connections) + Logger.info('ConnectionConfigManager', `Saved ${capability} connection`) + } + + /** + * 删除单个连接 + */ + async deleteConnection(capability: ModelCapability): Promise { + const store = await this.getStore() + const connections = { ...((store.get('connections', {}) as ConnectionMap) || {}) } + delete connections[capability] + store.set('connections', connections) + Logger.info('ConnectionConfigManager', `Deleted ${capability} connection`) + } + + /** + * 监听连接配置变化 + */ + async onConnectionsChange(callback: (connections: ConnectionMap) => void): Promise<() => void> { + const store = await this.getStore() + return store.onDidChange('connections', () => { + void this.getConnections().then(callback) + }) + } +} diff --git a/src/main/config/ProviderConfigManager.ts b/src/main/config/ProviderConfigManager.ts deleted file mode 100644 index 07919da..0000000 --- a/src/main/config/ProviderConfigManager.ts +++ /dev/null @@ -1,154 +0,0 @@ -import type Store from 'electron-store' -import type { ProviderConfig } from './types' -import { enrichModelsWithType } from '../../shared/utils/modelClassifier' -import { - encryptProviderConfig, - decryptProviderConfig, - isEncryptionAvailable -} from './secureStorage' -import Logger from '../../shared/utils/logger' - -/** - * 提供商配置管理器 - */ -export class ProviderConfigManager { - private getStore: () => Promise> - - constructor(getStore: () => Promise>) { - this.getStore = getStore - } - - /** - * 保存提供商配置(自动加密敏感字段) - */ - async saveProviderConfig( - providerName: string, - config: Record, - enabled: boolean - ): Promise { - const store = await this.getStore() - const providers = store.get('providers', {}) - - // 加密敏感字段(如 apiKey) - let encryptedConfig = config - if (isEncryptionAvailable()) { - encryptedConfig = encryptProviderConfig(config) - Logger.info('ProviderConfigManager', `Config encrypted for ${providerName}`) - } else { - Logger.warn( - 'ProviderConfigManager', - `Encryption not available, storing config in plain text for ${providerName}` - ) - } - - providers[providerName] = { - providerName, - config: encryptedConfig, - enabled, - updatedAt: Date.now() - } - - store.set('providers', providers) - } - - /** - * 获取提供商配置(自动解密敏感字段) - */ - async getProviderConfig(providerName: string): Promise { - const store = await this.getStore() - const providers = store.get('providers', {}) - const providerConfig = providers[providerName] - - if (!providerConfig) { - return null - } - - // 解密敏感字段 - const decryptedConfig = decryptProviderConfig(providerConfig.config) - - return { - ...providerConfig, - config: decryptedConfig - } - } - - /** - * 获取所有提供商配置(自动解密敏感字段) - */ - async getAllProviderConfigs(): Promise { - const store = await this.getStore() - const providers = store.get('providers', {}) - - // 解密每个配置 - return Object.values(providers).map((providerConfig: any) => ({ - ...providerConfig, - config: decryptProviderConfig(providerConfig.config) - })) - } - - /** - * 删除提供商配置 - */ - async deleteProviderConfig(providerName: string): Promise { - const store = await this.getStore() - const providers = store.get('providers', {}) - delete providers[providerName] - store.set('providers', providers) - } - - /** - * 监听提供商配置变化 - */ - async onProvidersChange( - callback: ( - newProviders: Record, - oldProviders: Record - ) => void - ): Promise<() => void> { - const store = await this.getStore() - return store.onDidChange('providers', (newValue, oldValue) => { - if (newValue && oldValue) { - callback(newValue, oldValue) - } - }) - } - - /** - * 保存提供商模型列表 - */ - async saveProviderModels(providerName: string, models: any[]): Promise { - const store = await this.getStore() - const modelsData = store.get('models', {}) - - modelsData[providerName] = { - models - } - - store.set('models', modelsData) - } - - /** - * 获取提供商模型列表(带向后兼容) - */ - async getProviderModels(providerName: string): Promise { - const store = await this.getStore() - const modelsData = store.get('models', {}) - const providerModels = modelsData[providerName] - - let models = providerModels?.models || [] - - // 向后兼容:如果模型没有 type 字段,自动添加 - const hasTypeField = models.length > 0 && models.some((m) => m.type) - - if (!hasTypeField && models.length > 0) { - console.log( - `[ProviderConfigManager] Migrating models for ${providerName} - adding type field` - ) - models = enrichModelsWithType(models) - // 更新存储 - await this.saveProviderModels(providerName, models) - } - - return models - } -} diff --git a/src/main/config/connectionMigration.ts b/src/main/config/connectionMigration.ts new file mode 100644 index 0000000..d4c24ca --- /dev/null +++ b/src/main/config/connectionMigration.ts @@ -0,0 +1,74 @@ +/** + * 一次性迁移:旧版 Provider 配置 -> Model Connection + * + * 纯映射逻辑在 legacyConnectionMapping.ts,这里只负责读写 store、 + * 解密 apiKey,以及记录迁移状态。 + */ + +import type Store from 'electron-store' +import type { ConnectionMap } from '../../shared/types' +import type { + ConnectionMigrationState, + LegacyModelEntry, + LegacyProviderConfig, + LegacyProviderConfigData, + LegacySettingsShape, + StoreSchema +} from './types' +import { deriveConnections } from './legacyConnectionMapping' +import { decryptObjectFields } from './secureStorage' +import Logger from '../../shared/utils/logger' + +const LEGACY_SENSITIVE_FIELDS = ['apiKey', 'apiSecret', 'accessToken'] + +function decryptLegacyConfig(config: LegacyProviderConfigData): LegacyProviderConfigData { + return decryptObjectFields({ ...config }, LEGACY_SENSITIVE_FIELDS) as LegacyProviderConfigData +} + +/** + * 执行一次性迁移。幂等:已迁移过(存在 connectionMigration 标记)则直接返回。 + */ +export async function migrateProvidersToConnections(store: Store): Promise { + const existingMigration = store.get('connectionMigration') as ConnectionMigrationState | undefined + if (existingMigration) { + return + } + + const providers = (store.get('providers', {}) as Record) || {} + const cachedModels = + (store.get('models', {}) as Record) || {} + + if (Object.keys(providers).length === 0) { + // 全新安装,没有需要迁移的内容 + store.set('connectionMigration', { migratedAt: Date.now(), warnings: [] }) + return + } + + const settings = store.get('settings') as LegacySettingsShape | undefined + const existingConnections = (store.get('connections', {}) as ConnectionMap) || {} + + // 已存在的 connection 优先,只补齐缺失的能力 + const { connections: derived, warnings } = deriveConnections({ + providers, + settings: { + defaultChatModel: settings?.defaultChatModel, + defaultEmbeddingModel: settings?.defaultEmbeddingModel + }, + cachedModels, + decrypt: decryptLegacyConfig + }) + + const connections: ConnectionMap = { ...existingConnections, ...derived } + + store.set('connections', connections) + store.set('connectionMigration', { migratedAt: Date.now(), warnings }) + + if (connections.chat && connections.embedding) { + Logger.info('ConnectionMigration', 'Migrated legacy provider configuration to connections') + } else { + Logger.error( + 'ConnectionMigration', + `Migration incomplete; user must reconfigure in Settings. ${warnings.join('; ')}` + ) + } +} diff --git a/src/main/config/defaults.ts b/src/main/config/defaults.ts index f008831..ec3b332 100644 --- a/src/main/config/defaults.ts +++ b/src/main/config/defaults.ts @@ -10,8 +10,6 @@ export const defaultSettings: AppSettings = { language: 'en-US', autoLaunch: false, hasCompletedOnboarding: false, - defaultChatModel: undefined, - defaultEmbeddingModel: undefined, prompts: { mindMap: { 'zh-CN': `你是知识结构分析专家,负责从笔记本内容中提炼核心知识结构。 diff --git a/src/main/config/index.ts b/src/main/config/index.ts index e651e35..1e83012 100644 --- a/src/main/config/index.ts +++ b/src/main/config/index.ts @@ -4,17 +4,14 @@ export { getStore } from './store' export { SettingsManager } from './settingsManager' -export { ProviderConfigManager } from './ProviderConfigManager' -// 向后兼容导出(已废弃) -export { ProviderConfigManager as ProvidersManager } from './ProviderConfigManager' -export type { AppSettings, ProviderConfig, StoreSchema } from './types' +export { ConnectionConfigManager } from './ConnectionConfigManager' +export type { AppSettings, StoreSchema } from './types' export { defaultSettings } from './defaults' // 导出单例实例 import { getStore } from './store' import { SettingsManager } from './settingsManager' -import { ProviderConfigManager } from './ProviderConfigManager' +import { ConnectionConfigManager } from './ConnectionConfigManager' export const settingsManager = new SettingsManager(getStore) -export const providersManager = new ProviderConfigManager(getStore) -export const providerConfigManager = providersManager // 新名称的别名 +export const connectionConfigManager = new ConnectionConfigManager(getStore) diff --git a/src/main/config/legacyConnectionMapping.ts b/src/main/config/legacyConnectionMapping.ts new file mode 100644 index 0000000..b648c73 --- /dev/null +++ b/src/main/config/legacyConnectionMapping.ts @@ -0,0 +1,254 @@ +/** + * 旧版 Provider 配置 -> Model Connection 的纯映射逻辑。 + * + * 这个模块不依赖 Electron,便于单独测试。解密由调用方通过 `decrypt` 注入。 + * + * 旧模型:providers[providerName] = { baseUrl, apiKey, models[], modelDetails[] } + * 新模型:connections.chat / connections.embedding = { protocol, baseUrl, apiKey, modelId } + * + * 旧的内置 provider 全部是 OpenAI 兼容端点,因此统一映射到 + * `openai-completions` 协议。用户声明的模型 ID 保持不变。 + */ + +import type { APIProtocol, ConnectionMap, ModelConnection } from '../../shared/types' + +export interface LegacyModelEntry { + id: string + type?: string + [key: string]: unknown +} + +export interface LegacyProviderConfigData { + baseUrl?: string + apiKey?: string + displayName?: string + /** 用户在旧 UI 中勾选的模型 ID */ + models?: string[] + /** 旧 UI 缓存下来的模型元数据(含 type 猜测结果) */ + modelDetails?: LegacyModelEntry[] +} + +export interface LegacyProviderConfig { + providerName: string + config: LegacyProviderConfigData + enabled: boolean + updatedAt: number +} + +export interface LegacySettingsShape { + defaultChatModel?: string + defaultEmbeddingModel?: string +} + +interface LegacyProviderDefaults { + baseUrl: string + protocol: APIProtocol + apiKey?: string +} + +/** + * 旧内置 provider 的默认 baseUrl。仅迁移需要,新代码不再使用。 + */ +export const LEGACY_PROVIDER_DEFAULTS: Record = { + openai: { baseUrl: 'https://api.openai.com/v1', protocol: 'openai-completions' }, + deepseek: { baseUrl: 'https://api.deepseek.com', protocol: 'openai-completions' }, + qwen: { + baseUrl: 'https://dashscope.aliyuncs.com/compatible-mode/v1', + protocol: 'openai-completions' + }, + kimi: { baseUrl: 'https://api.moonshot.cn/v1', protocol: 'openai-completions' }, + siliconflow: { baseUrl: 'https://api.siliconflow.cn/v1', protocol: 'openai-completions' }, + ollama: { + baseUrl: 'http://localhost:11434/v1', + protocol: 'openai-completions', + apiKey: 'ollama' + }, + zhipu: { baseUrl: 'https://open.bigmodel.cn/api/paas/v4', protocol: 'openai-completions' } +} + +export interface DeriveConnectionsInput { + providers: Record + settings: LegacySettingsShape + cachedModels?: Record + /** 解密单个 provider 的 config,默认按原样返回 */ + decrypt?: (config: LegacyProviderConfigData) => LegacyProviderConfigData +} + +export interface DeriveConnectionsResult { + connections: ConnectionMap + warnings: string[] +} + +interface ResolvedLegacyProvider { + name: string + enabled: boolean + config: LegacyProviderConfigData +} + +/** + * Ollama 的旧 baseUrl 是原生 API(/api),新协议需要 OpenAI 兼容的 /v1。 + */ +function normalizeLegacyBaseUrl(providerName: string, baseUrl: string): string { + if (providerName !== 'ollama') { + return baseUrl + } + const cleaned = baseUrl.replace(/\/api\/?$/, '').replace(/\/+$/, '') + return cleaned.endsWith('/v1') ? cleaned : `${cleaned}/v1` +} + +function resolveProvider( + name: string, + providers: Record, + decrypt: (config: LegacyProviderConfigData) => LegacyProviderConfigData +): ResolvedLegacyProvider | null { + const provider = providers[name] + if (!provider) { + return null + } + return { name, enabled: provider.enabled, config: decrypt({ ...provider.config }) } +} + +function getModelEntries( + provider: ResolvedLegacyProvider, + cachedModels: Record +): LegacyModelEntry[] { + const fromConfig = provider.config.modelDetails + if (Array.isArray(fromConfig) && fromConfig.length > 0) { + return fromConfig + } + const fromCache = cachedModels[provider.name]?.models + return Array.isArray(fromCache) ? fromCache : [] +} + +function getSelectedModelIds(provider: ResolvedLegacyProvider): string[] { + const selected = provider.config.models + return Array.isArray(selected) ? selected.filter((m): m is string => typeof m === 'string') : [] +} + +function findModelId( + provider: ResolvedLegacyProvider, + modelEntries: LegacyModelEntry[], + capability: 'chat' | 'embedding' +): string | null { + const selected = getSelectedModelIds(provider) + const candidates = selected.length > 0 ? selected : modelEntries.map((m) => m.id) + + for (const modelId of candidates) { + const entry = modelEntries.find((m) => m.id === modelId) + const type = entry?.type + if (capability === 'embedding') { + if (type === 'embedding') { + return modelId + } + } else if (type !== 'embedding' && type !== 'reranker') { + return modelId + } + } + + return null +} + +function buildConnection( + provider: ResolvedLegacyProvider, + modelId: string +): ModelConnection | null { + const defaults = LEGACY_PROVIDER_DEFAULTS[provider.name] + const rawBaseUrl = provider.config.baseUrl || defaults?.baseUrl || '' + if (!rawBaseUrl) { + return null + } + + return { + protocol: defaults?.protocol ?? 'openai-completions', + baseUrl: normalizeLegacyBaseUrl(provider.name, rawBaseUrl), + apiKey: provider.config.apiKey || defaults?.apiKey || '', + modelId + } +} + +function deriveConnection( + capability: 'chat' | 'embedding', + defaultModelKey: string | undefined, + providers: Record, + cachedModels: Record, + decrypt: (config: LegacyProviderConfigData) => LegacyProviderConfigData, + warnings: string[] +): ModelConnection | null { + // 1. 优先使用旧设置中显式选择的默认模型:: + if (defaultModelKey && defaultModelKey.includes(':')) { + const [providerName, ...modelIdParts] = defaultModelKey.split(':') + const modelId = modelIdParts.join(':') + const provider = resolveProvider(providerName, providers, decrypt) + + if (!provider) { + warnings.push( + `Default ${capability} model "${defaultModelKey}" references unknown provider "${providerName}"` + ) + } else { + const connection = buildConnection(provider, modelId) + if (connection) { + return connection + } + warnings.push( + `Provider "${providerName}" has no base URL; cannot migrate ${capability} model` + ) + } + } + + // 2. 回退:找一个启用的 provider,其中勾选的模型类型匹配该 capability + for (const name of Object.keys(providers)) { + const provider = resolveProvider(name, providers, decrypt) + if (!provider || !provider.enabled) { + continue + } + const modelEntries = getModelEntries(provider, cachedModels) + const modelId = findModelId(provider, modelEntries, capability) + if (!modelId) { + continue + } + const connection = buildConnection(provider, modelId) + if (connection) { + return connection + } + } + + warnings.push(`No legacy configuration could be mapped to a ${capability} connection`) + return null +} + +/** + * 从旧 Provider 配置推导 chat / embedding connection。 + * 无法映射时返回带 warning 的空结果,而不是伪造一个可用配置。 + */ +export function deriveConnections(input: DeriveConnectionsInput): DeriveConnectionsResult { + const cachedModels = input.cachedModels ?? {} + const decrypt = input.decrypt ?? ((config) => config) + const warnings: string[] = [] + + const chat = deriveConnection( + 'chat', + input.settings.defaultChatModel, + input.providers, + cachedModels, + decrypt, + warnings + ) + const embedding = deriveConnection( + 'embedding', + input.settings.defaultEmbeddingModel, + input.providers, + cachedModels, + decrypt, + warnings + ) + + const connections: ConnectionMap = {} + if (chat) { + connections.chat = chat + } + if (embedding) { + connections.embedding = embedding + } + + return { connections, warnings } +} diff --git a/src/main/config/settingsManager.ts b/src/main/config/settingsManager.ts index 80a0db5..c68d106 100644 --- a/src/main/config/settingsManager.ts +++ b/src/main/config/settingsManager.ts @@ -12,8 +12,6 @@ function mergeSettings(stored: Partial): AppSettings { language: stored.language ?? defaultSettings.language, autoLaunch: stored.autoLaunch ?? defaultSettings.autoLaunch, hasCompletedOnboarding: stored.hasCompletedOnboarding ?? defaultSettings.hasCompletedOnboarding, - defaultChatModel: stored.defaultChatModel ?? defaultSettings.defaultChatModel, - defaultEmbeddingModel: stored.defaultEmbeddingModel ?? defaultSettings.defaultEmbeddingModel, prompts: { mindMap: { 'zh-CN': stored.prompts?.mindMap?.['zh-CN'] ?? defaultSettings.prompts!.mindMap!['zh-CN'], diff --git a/src/main/config/store.ts b/src/main/config/store.ts index c527a37..d43c412 100644 --- a/src/main/config/store.ts +++ b/src/main/config/store.ts @@ -13,7 +13,7 @@ export async function getStore(): Promise> { store = new Store({ defaults: { settings: defaultSettings, - providers: {}, + connections: {}, shortcuts: defaultShortcuts }, name: 'knownote-config', diff --git a/src/main/config/types.ts b/src/main/config/types.ts index 7f915ab..dc6c1fe 100644 --- a/src/main/config/types.ts +++ b/src/main/config/types.ts @@ -1,13 +1,16 @@ -import type { AppSettings, ShortcutConfig } from '../../shared/types' +import type { AppSettings, ConnectionMap, ShortcutConfig } from '../../shared/types' +import type { + LegacyModelEntry, + LegacyProviderConfig, + LegacyProviderConfigData, + LegacySettingsShape +} from './legacyConnectionMapping' -/** - * 提供商配置接口 - */ -export interface ProviderConfig { - providerName: string - config: Record - enabled: boolean - updatedAt: number +export type { + LegacyModelEntry, + LegacyProviderConfig, + LegacyProviderConfigData, + LegacySettingsShape } /** @@ -15,8 +18,26 @@ export interface ProviderConfig { */ export interface StoreSchema { settings: AppSettings - providers: Record + connections: ConnectionMap shortcuts: ShortcutConfig[] + /** + * 旧版 Provider 配置,仅用于一次性迁移到 connections。 + * 迁移完成后不再写入。 + */ + providers?: Record + /** 旧版模型缓存,仅用于一次性迁移。 */ + models?: Record + /** 迁移状态标记。 */ + connectionMigration?: ConnectionMigrationState +} + +/** + * 一次性迁移状态 + */ +export interface ConnectionMigrationState { + migratedAt: number + /** 迁移中遇到的无法转换的配置;非空表示需要用户手动重新配置。 */ + warnings: string[] } // 重新导出 AppSettings 以保持向后兼容 diff --git a/src/main/index.ts b/src/main/index.ts index bdfb5c3..cd2d4ee 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -7,7 +7,7 @@ import { closeDatabase, executeCheckpoint } from './db' -import { ProviderManager } from './providers/ProviderManager' +import { ConnectionManager } from './models/ConnectionManager' import { SessionAutoSwitchService } from './services/SessionAutoSwitchService' import { KnowledgeService } from './services/KnowledgeService' import { UpdateService } from './services/UpdateService' @@ -15,10 +15,11 @@ import { ShortcutManager } from './services/ShortcutManager' import { createMainWindow } from './windows' import { registerAllHandlers } from './ipc' import { getStore } from './config/store' +import { migrateProvidersToConnections } from './config/connectionMigration' import { isSmokeTestRequested, runSmokeTest } from './smokeTest' import Logger from '../shared/utils/logger' -let providerManager: ProviderManager | null = null +let connectionManager: ConnectionManager | null = null let sessionAutoSwitchService: SessionAutoSwitchService | null = null let knowledgeService: KnowledgeService | null = null let updateService: UpdateService | null = null @@ -56,20 +57,27 @@ app.whenReady().then(async () => { initVectorStore() Logger.info('Main', 'Database initialized') - // Initialize Provider Manager - Logger.info('Main', 'Initializing Provider Manager...') - providerManager = new ProviderManager() - await providerManager.initialize() - Logger.info('Main', 'Provider Manager initialized') + // Initialize electron-store + Logger.info('Main', 'Initializing electron-store...') + const store = await getStore() + Logger.info('Main', 'electron-store initialized') + + // One-time migration: legacy Provider config -> Model Connections + await migrateProvidersToConnections(store) + + // Initialize Connection Manager + Logger.info('Main', 'Initializing Connection Manager...') + connectionManager = new ConnectionManager() + Logger.info('Main', 'Connection Manager initialized') // Initialize Session Auto Switch Service Logger.info('Main', 'Initializing Session Auto Switch Service...') - sessionAutoSwitchService = new SessionAutoSwitchService(providerManager) + sessionAutoSwitchService = new SessionAutoSwitchService(connectionManager) Logger.info('Main', 'Session Auto Switch Service initialized') // Initialize Knowledge Service Logger.info('Main', 'Initializing Knowledge Service...') - knowledgeService = new KnowledgeService(providerManager) + knowledgeService = new KnowledgeService(connectionManager) Logger.info('Main', 'Knowledge Service initialized') // Initialize Update Service @@ -77,11 +85,6 @@ app.whenReady().then(async () => { updateService = new UpdateService() Logger.info('Main', 'Update Service initialized') - // Initialize electron-store - Logger.info('Main', 'Initializing electron-store...') - const store = await getStore() - Logger.info('Main', 'electron-store initialized') - // Initialize Shortcut Manager Logger.info('Main', 'Initializing Shortcut Manager...') shortcutManager = new ShortcutManager(store) @@ -89,7 +92,7 @@ app.whenReady().then(async () => { // Register all IPC Handlers registerAllHandlers( - providerManager, + connectionManager, sessionAutoSwitchService, knowledgeService, updateService, diff --git a/src/main/ipc/chatHandlers.ts b/src/main/ipc/chatHandlers.ts index d33c7d2..97578bc 100644 --- a/src/main/ipc/chatHandlers.ts +++ b/src/main/ipc/chatHandlers.ts @@ -1,10 +1,9 @@ import { ipcMain, IpcMainInvokeEvent } from 'electron' import * as queries from '../db/queries' -import { ProviderManager } from '../providers/ProviderManager' +import { ConnectionManager } from '../models/ConnectionManager' import { SessionAutoSwitchService } from '../services/SessionAutoSwitchService' import { KnowledgeService } from '../services/KnowledgeService' import { validateAndCleanMessages } from '../utils/messageValidator' -import { settingsManager } from '../config' import Logger from '../../shared/utils/logger' import { ChatSchemas, validate } from './validation' @@ -38,7 +37,7 @@ ${contextParts.join('\n\n---\n\n')} * Register chat-related IPC Handlers */ export function registerChatHandlers( - providerManager: ProviderManager, + connectionManager: ConnectionManager, sessionAutoSwitchService: SessionAutoSwitchService, knowledgeService: KnowledgeService ) { @@ -126,13 +125,11 @@ export function registerChatHandlers( } // 3.2 RAG 增强:检索相关知识并注入上下文 - // 只有在设置了默认嵌入模型时才启用 RAG + // 只有在配置了 embedding connection 时才启用 RAG try { - const settings = await settingsManager.getAllSettings() - const hasEmbeddingModel = - settings.defaultEmbeddingModel && settings.defaultEmbeddingModel.includes(':') + const embeddingClient = await connectionManager.getEmbeddingClient() - if (hasEmbeddingModel) { + if (embeddingClient) { const session = queries.getSessionById(sessionId) if (session?.notebookId) { const searchResults = await knowledgeService.search(session.notebookId, content, { @@ -162,12 +159,12 @@ export function registerChatHandlers( Logger.warn('ChatHandlers', 'RAG search failed:', error) } - // 4. 调用 AI Provider 流式生成 - const provider = await providerManager.getActiveChatProvider() - if (!provider) { + // 4. 调用 Model Connection 流式生成 + const client = await connectionManager.getChatClient() + if (!client) { event.sender.send('message-error', { messageId: assistantMessage.id, - error: 'AI Provider not configured, please configure in settings' + error: 'Chat model not configured, please configure in settings' }) return assistantMessage.id } @@ -176,8 +173,8 @@ export function registerChatHandlers( let fullReasoningContent = '' let usageMetadata: any = null - // 调用 Provider 流式生成,获取 AbortController(基于 AI SDK fullStream) - const abortController = await provider.sendMessageStream( + // 调用 ModelClient 流式生成,获取 AbortController(基于 AI SDK fullStream) + const abortController = await client.sendMessageStream( messages, // onChunk - 处理 AI SDK fullStream 的各种 part 类型 (chunk) => { diff --git a/src/main/ipc/connectionHandlers.ts b/src/main/ipc/connectionHandlers.ts new file mode 100644 index 0000000..1c2cfff --- /dev/null +++ b/src/main/ipc/connectionHandlers.ts @@ -0,0 +1,61 @@ +import { ipcMain, BrowserWindow } from 'electron' +import { ConnectionManager } from '../models/ConnectionManager' +import { ConnectionSchemas, validate } from './validation' +import Logger from '../../shared/utils/logger' + +/** + * 注册 Model Connection 相关的 IPC Handlers + */ +export function registerConnectionHandlers(connectionManager: ConnectionManager): void { + const broadcastChanged = (): void => { + BrowserWindow.getAllWindows().forEach((win) => { + win.webContents.send('connections-changed') + }) + } + + // 读取全部连接 + ipcMain.handle('get-connections', async () => { + return await connectionManager.getConnections() + }) + + // 协议元数据 + ipcMain.handle('get-connection-protocols', async () => { + return connectionManager.listProtocolInfos() + }) + + // 保存连接 + ipcMain.handle( + 'save-connection', + validate(ConnectionSchemas.saveConnection, async (args) => { + await connectionManager.saveConnection(args.capability, args.connection) + broadcastChanged() + }) + ) + + // 删除连接 + ipcMain.handle( + 'delete-connection', + validate(ConnectionSchemas.deleteConnection, async (args) => { + await connectionManager.deleteConnection(args.capability) + broadcastChanged() + }) + ) + + // 测试连接 + ipcMain.handle( + 'test-connection', + validate(ConnectionSchemas.testConnection, async (args) => { + return await connectionManager.testConnection(args.connection) + }) + ) + + // 拉取模型列表 + ipcMain.handle( + 'fetch-connection-models', + validate(ConnectionSchemas.fetchModels, async (args) => { + return await connectionManager.fetchModels(args.connection) + }) + ) + + Logger.info('ConnectionHandlers', 'Connection handlers registered') +} diff --git a/src/main/ipc/index.ts b/src/main/ipc/index.ts index f682db3..46d7cc9 100644 --- a/src/main/ipc/index.ts +++ b/src/main/ipc/index.ts @@ -1,4 +1,4 @@ -import { ProviderManager } from '../providers/ProviderManager' +import { ConnectionManager } from '../models/ConnectionManager' import { SessionAutoSwitchService } from '../services/SessionAutoSwitchService' import { KnowledgeService } from '../services/KnowledgeService' import { UpdateService } from '../services/UpdateService' @@ -9,7 +9,7 @@ import { ShortcutManager } from '../services/ShortcutManager' import type Store from 'electron-store' import type { StoreSchema } from '../config/types' import { registerChatHandlers } from './chatHandlers' -import { registerProviderHandlers } from './providerHandlers' +import { registerConnectionHandlers } from './connectionHandlers' import { registerSettingsHandlers } from './settingsHandlers' import { registerNotebookHandlers } from './notebookHandlers' import { registerNoteHandlers } from './noteHandlers' @@ -26,7 +26,7 @@ import { registerDialogHandlers } from './dialogHandlers' * 注册所有 IPC Handlers */ export function registerAllHandlers( - providerManager: ProviderManager, + connectionManager: ConnectionManager, sessionAutoSwitchService: SessionAutoSwitchService, knowledgeService: KnowledgeService, updateService: UpdateService, @@ -34,18 +34,18 @@ export function registerAllHandlers( store: Store ) { // 实例化 MindMapService - const mindMapService = new MindMapService(providerManager) + const mindMapService = new MindMapService(connectionManager) // 实例化 QuizService - const quizService = new QuizService(providerManager) + const quizService = new QuizService(connectionManager) // 实例化 AnkiCardService - const ankiCardService = new AnkiCardService(providerManager) + const ankiCardService = new AnkiCardService(connectionManager) - registerChatHandlers(providerManager, sessionAutoSwitchService, knowledgeService) - registerProviderHandlers(providerManager) + registerChatHandlers(connectionManager, sessionAutoSwitchService, knowledgeService) + registerConnectionHandlers(connectionManager) registerSettingsHandlers() registerDialogHandlers() registerNotebookHandlers() - registerNoteHandlers(providerManager) + registerNoteHandlers(connectionManager) registerKnowledgeHandlers(knowledgeService) registerMindMapHandlers(mindMapService) registerQuizHandlers(quizService) @@ -58,7 +58,7 @@ export function registerAllHandlers( export { registerChatHandlers, - registerProviderHandlers, + registerConnectionHandlers, registerSettingsHandlers, registerNotebookHandlers, registerNoteHandlers, diff --git a/src/main/ipc/noteHandlers.ts b/src/main/ipc/noteHandlers.ts index 5e416a6..cfcb44a 100644 --- a/src/main/ipc/noteHandlers.ts +++ b/src/main/ipc/noteHandlers.ts @@ -1,6 +1,6 @@ import { ipcMain } from 'electron' import { createNote, getNotesByNotebook, getNoteById, updateNote, deleteNote } from '../db/queries' -import { ProviderManager } from '../providers/ProviderManager' +import { ConnectionManager } from '../models/ConnectionManager' import Logger from '../../shared/utils/logger' import { NoteSchemas, validate } from './validation' @@ -8,18 +8,18 @@ import { NoteSchemas, validate } from './validation' * Generate note title using AI */ async function generateNoteTitle( - providerManager: ProviderManager, + connectionManager: ConnectionManager, content: string ): Promise { try { - const provider = await providerManager.getActiveChatProvider() - if (!provider) { + const client = await connectionManager.getChatClient() + if (!client) { return 'Untitled Note' } let generatedTitle = '' - await provider.sendMessageStream( + await client.sendMessageStream( [ { role: 'system', @@ -52,7 +52,7 @@ async function generateNoteTitle( /** * Register note-related IPC handlers */ -export function registerNoteHandlers(providerManager: ProviderManager) { +export function registerNoteHandlers(connectionManager: ConnectionManager) { // Create note(带参数验证) ipcMain.handle( 'create-note', @@ -64,7 +64,7 @@ export function registerNoteHandlers(providerManager: ProviderManager) { try { // If no title provided (empty string), use AI to generate - const title = args.title || (await generateNoteTitle(providerManager, args.content)) + const title = args.title || (await generateNoteTitle(connectionManager, args.content)) const note = createNote(args.notebookId, title, args.content) Logger.debug('NoteHandlers', 'Note created successfully:', note.id) return note diff --git a/src/main/ipc/providerHandlers.ts b/src/main/ipc/providerHandlers.ts deleted file mode 100644 index 78ba4a6..0000000 --- a/src/main/ipc/providerHandlers.ts +++ /dev/null @@ -1,275 +0,0 @@ -import { ipcMain, BrowserWindow } from 'electron' -import { ProviderManager } from '../providers/ProviderManager' -import { providersManager } from '../config' -import { enrichModelsWithType, mergeModels } from '../../shared/utils/modelClassifier' -import { ProviderSchemas, validate } from './validation' -import { getBuiltinModels } from '../../shared/config/models' - -/** - * 注册 Provider 配置相关的 IPC Handlers - */ -export function registerProviderHandlers(providerManager: ProviderManager) { - // 保存提供商配置(直接使用 Electron Store,立即生效)(带参数验证) - ipcMain.handle( - 'save-provider-config', - validate(ProviderSchemas.saveProviderConfig, async (args) => { - await providersManager.saveProviderConfig(args.providerName, args.config, args.enabled) - - // 如果是自定义供应商且刚被启用,注册到 ProviderManager - if (args.enabled && !providerManager.getDescriptor(args.providerName)) { - // 这是一个新的自定义供应商,需要注册 - const customConfig = { - providerName: args.providerName, - displayName: args.config.displayName || args.providerName, - baseUrl: args.config.baseUrl || '', - apiKey: args.config.apiKey || '' - } - - if (customConfig.baseUrl) { - await providerManager.registerCustomProvider(customConfig) - } - } - - // 广播 Provider 配置变更事件到所有窗口 - BrowserWindow.getAllWindows().forEach((win) => { - win.webContents.send('provider-config-changed') - }) - }) - ) - - // 获取单个提供商配置(从 Electron Store 读取)(带参数验证) - ipcMain.handle( - 'get-provider-config', - validate(ProviderSchemas.getProviderConfig, async (args) => { - return await providersManager.getProviderConfig(args.providerName) - }) - ) - - // 获取所有提供商配置(从 Electron Store 读取) - ipcMain.handle('get-all-provider-configs', async () => { - return await providersManager.getAllProviderConfigs() - }) - - // 删除提供商配置(带参数验证) - ipcMain.handle( - 'delete-provider-config', - validate(ProviderSchemas.deleteProviderConfig, async (args) => { - await providersManager.deleteProviderConfig(args.providerName) - // 广播 Provider 配置变更事件到所有窗口 - BrowserWindow.getAllWindows().forEach((win) => { - win.webContents.send('provider-config-changed') - }) - }) - ) - - // 验证提供商配置(带参数验证) - ipcMain.handle( - 'validate-provider-config', - validate(ProviderSchemas.validateProviderConfig, async (args) => { - const provider = providerManager.getProvider(args.providerName) - if (!provider || !provider.validateConfig) { - return false - } - return provider.validateConfig(args.config) - }) - ) - - // 获取模型列表(带参数验证) - ipcMain.handle( - 'fetch-models', - validate(ProviderSchemas.fetchModels, async (args) => { - try { - // 1. 获取内置模型(始终可用) - const builtinModels = getBuiltinModels(args.providerName) - - // 2. 尝试网络请求获取最新模型 - try { - let url = '' - let rawModels: any[] = [] - - // 内置供应商使用硬编码的 URL - if (args.providerName === 'openai') { - url = 'https://api.openai.com/v1/models' - } else if (args.providerName === 'deepseek') { - url = 'https://api.deepseek.com/models' - } else if (args.providerName === 'siliconflow') { - url = 'https://api.siliconflow.cn/v1/models' - } else if (args.providerName === 'qwen') { - url = 'https://dashscope.aliyuncs.com/compatible-mode/v1/models' - } else if (args.providerName === 'kimi') { - url = 'https://api.moonshot.cn/v1/models' - } else if (args.providerName === 'ollama') { - // Ollama: 先尝试 OpenAI 兼容格式,失败后回退到原生格式 - const providerConfig = await providersManager.getProviderConfig(args.providerName) - const baseUrl = providerConfig?.config.baseUrl || 'http://localhost:11434' - - // 移除末尾的 /api 或 / (如果存在) - const cleanBaseUrl = baseUrl.replace(/\/(api)?\/?$/, '') - - // 尝试 OpenAI 兼容格式 - const openaiUrl = `${cleanBaseUrl}/v1/models` - console.log(`[Ollama] Trying OpenAI-compatible format: ${openaiUrl}`) - - try { - const openaiResponse = await fetch(openaiUrl, { - method: 'GET', - headers: { - Accept: 'application/json' - } - }) - - if (openaiResponse.ok) { - const openaiData = await openaiResponse.json() - rawModels = openaiData.data || [] - console.log( - `[Ollama] Successfully fetched ${rawModels.length} models using OpenAI format` - ) - } else { - throw new Error(`OpenAI format failed with status: ${openaiResponse.status}`) - } - } catch (openaiError) { - // 回退到 Ollama 原生格式 - console.log( - `[Ollama] OpenAI format failed, falling back to native format:`, - openaiError - ) - const nativeUrl = `${cleanBaseUrl}/api/tags` - console.log(`[Ollama] Trying native format: ${nativeUrl}`) - - const nativeResponse = await fetch(nativeUrl, { - method: 'GET', - headers: { - Accept: 'application/json' - } - }) - - if (!nativeResponse.ok) { - throw new Error(`HTTP error! status: ${nativeResponse.status}`) - } - - const nativeData = await nativeResponse.json() - rawModels = nativeData.models || [] - console.log( - `[Ollama] Successfully fetched ${rawModels.length} models using native format` - ) - } - - // Ollama 特殊处理:规范化并合并 - const normalizedModels = rawModels.map((model: any) => ({ - id: model.id || model.name || '', - object: model.object || 'model', - owned_by: model.owned_by, - created: model.created, - type: model.type - })) - - const remoteModels = enrichModelsWithType(normalizedModels) - - // 智能合并策略:远程信息 + 内置元数据 - const mergedModels = mergeModels(builtinModels, remoteModels) - - console.log( - `[Models] ${args.providerName}: ${builtinModels.length} builtin + ${remoteModels.length} remote = ${mergedModels.length} total` - ) - - await providersManager.saveProviderModels(args.providerName, mergedModels) - - return { - models: mergedModels, - source: 'merged', - builtinCount: builtinModels.length, - remoteCount: remoteModels.length - } - } else if (args.providerName === 'zhipu') { - url = 'https://open.bigmodel.cn/api/paas/v4/models' - } else { - // 自定义供应商:从配置中获取 baseUrl - const providerConfig = await providersManager.getProviderConfig(args.providerName) - if (!providerConfig || !providerConfig.config.baseUrl) { - throw new Error(`Custom provider ${args.providerName} has no baseUrl configured`) - } - const baseUrl = providerConfig.config.baseUrl - url = baseUrl.endsWith('/') ? `${baseUrl}models` : `${baseUrl}/models` - } - - // 其他 provider 的通用处理 - const headers: Record = { - Accept: 'application/json', - Authorization: `Bearer ${args.apiKey}` - } - - const response = await fetch(url, { - method: 'GET', - headers - }) - - if (!response.ok) { - throw new Error(`HTTP error! status: ${response.status}`) - } - - const data = await response.json() - rawModels = data.data || data.models || [] - - // 规范化模型对象 - const normalizedModels = rawModels.map((model: any) => ({ - id: model.id || model.name || '', - object: model.object || 'model', - owned_by: model.owned_by, - created: model.created, - type: model.type - })) - - // 为模型添加类型信息 - const remoteModels = enrichModelsWithType(normalizedModels) - - // 3. 智能合并策略:远程信息 + 内置元数据 - // 远程字段优先: id, owned_by, created (反映最新状态) - // 内置字段优先: type, max_context, description (精心配置) - const mergedModels = mergeModels(builtinModels, remoteModels) - - console.log( - `[Models] ${args.providerName}: ${builtinModels.length} builtin + ${remoteModels.length} remote = ${mergedModels.length} total` - ) - - // 4. 保存合并后的模型列表 - await providersManager.saveProviderModels(args.providerName, mergedModels) - - return { - models: mergedModels, - source: 'merged', - builtinCount: builtinModels.length, - remoteCount: remoteModels.length - } - } catch (networkError) { - // 5. 网络失败,返回内置模型(如果有) - console.warn(`[Fetch Failed] ${args.providerName}:`, networkError) - - if (builtinModels.length > 0) { - console.log(`[Fallback] Using ${builtinModels.length} builtin models`) - return { - models: builtinModels, - source: 'builtin', - error: (networkError as Error).message - } - } - - // 6. 没有内置模型,抛出错误 - throw new Error(`无法获取模型列表: ${(networkError as Error).message}`) - } - } catch (error) { - console.error('Failed to fetch models:', error) - throw error - } - }) - ) - - // 获取已缓存的模型列表(带参数验证) - ipcMain.handle( - 'get-provider-models', - validate(ProviderSchemas.getProviderModels, async (args) => { - return await providersManager.getProviderModels(args.providerName) - }) - ) - - console.log('[IPC] Provider handlers registered') -} diff --git a/src/main/ipc/validation.ts b/src/main/ipc/validation.ts index 6d82f4b..c83f203 100644 --- a/src/main/ipc/validation.ts +++ b/src/main/ipc/validation.ts @@ -4,6 +4,7 @@ */ import { z } from 'zod' +import { API_PROTOCOLS, MODEL_CAPABILITIES } from '../../shared/types' import { Result, Err, Ok } from '../../shared/types/result' import Logger from '../../shared/utils/logger' @@ -83,35 +84,39 @@ export const NoteSchemas = { } /** - * Provider 相关的验证 schemas + * Model Connection 相关的验证 schemas */ -export const ProviderSchemas = { - saveProviderConfig: z.object({ - providerName: z.string().min(1, { message: 'Provider 名称不能为空' }), - config: z.record(z.string(), z.any()), - enabled: z.boolean() - }), - - getProviderConfig: z.object({ - providerName: z.string().min(1, 'Provider 名称不能为空') +export const ConnectionSchemas = { + saveConnection: z.object({ + capability: z.enum(MODEL_CAPABILITIES), + connection: z.object({ + protocol: z.enum(API_PROTOCOLS), + baseUrl: z.string().min(1, 'Base URL 不能为空'), + apiKey: z.string(), + modelId: z.string().min(1, 'Model ID 不能为空') + }) }), - deleteProviderConfig: z.object({ - providerName: z.string().min(1, 'Provider 名称不能为空') + deleteConnection: z.object({ + capability: z.enum(MODEL_CAPABILITIES) }), - validateProviderConfig: z.object({ - providerName: z.string().min(1, 'Provider 名称不能为空'), - config: z.record(z.string(), z.any()) + testConnection: z.object({ + connection: z.object({ + protocol: z.enum(API_PROTOCOLS), + baseUrl: z.string().min(1, 'Base URL 不能为空'), + apiKey: z.string(), + modelId: z.string() + }) }), fetchModels: z.object({ - providerName: z.string().min(1, 'Provider 名称不能为空'), - apiKey: z.string().min(1, 'API Key 不能为空') - }), - - getProviderModels: z.object({ - providerName: z.string().min(1, 'Provider 名称不能为空') + connection: z.object({ + protocol: z.enum(API_PROTOCOLS), + baseUrl: z.string().min(1, 'Base URL 不能为空'), + apiKey: z.string(), + modelId: z.string() + }) }) } diff --git a/src/main/models/ConnectionManager.ts b/src/main/models/ConnectionManager.ts new file mode 100644 index 0000000..59670bd --- /dev/null +++ b/src/main/models/ConnectionManager.ts @@ -0,0 +1,136 @@ +/** + * ConnectionManager + * + * 解析用户配置的 Model Connection 并构造 ModelClient。 + * 它不认识任何厂商,只按 protocol 分派。 + */ + +import type { + ConnectionMap, + ConnectionTestResult, + ModelCapability, + ModelConnection +} from '../../shared/types' +import { protocolSupportsEmbedding } from '../../shared/types' +import { API_PROTOCOLS } from '../../shared/types' +import { connectionConfigManager } from '../config' +import { getProtocolAdapter } from './protocols' +import { ModelClient } from './ModelClient' +import Logger from '../../shared/utils/logger' + +/** + * 协议元数据,供 Settings UI 决定展示哪些控件(拉取模型列表等)。 + */ +export interface ProtocolInfo { + protocol: ModelConnection['protocol'] + supportsModelListing: boolean + supportsEmbedding: boolean +} + +export class ConnectionManager { + /** + * 读取全部连接 + */ + async getConnections(): Promise { + return await connectionConfigManager.getConnections() + } + + /** + * 读取单个连接 + */ + async getConnection(capability: ModelCapability): Promise { + return await connectionConfigManager.getConnection(capability) + } + + /** + * 保存连接。embedding 能力会校验协议是否支持 embedding。 + */ + async saveConnection(capability: ModelCapability, connection: ModelConnection): Promise { + if (capability === 'embedding' && !protocolSupportsEmbedding(connection.protocol)) { + throw new Error(`Protocol "${connection.protocol}" does not support embeddings`) + } + await connectionConfigManager.saveConnection(capability, connection) + } + + /** + * 删除连接 + */ + async deleteConnection(capability: ModelCapability): Promise { + await connectionConfigManager.deleteConnection(capability) + } + + /** + * 获取 chat 客户端,未配置时返回 null(不自动 fallback) + */ + async getChatClient(): Promise { + const connection = await connectionConfigManager.getConnection('chat') + if (!connection) { + Logger.warn('ConnectionManager', 'No chat model configured') + return null + } + return new ModelClient('chat', connection) + } + + /** + * 获取 embedding 客户端,未配置或协议不支持时返回 null + */ + async getEmbeddingClient(): Promise { + const connection = await connectionConfigManager.getConnection('embedding') + if (!connection) { + Logger.warn('ConnectionManager', 'No embedding model configured') + return null + } + if (!protocolSupportsEmbedding(connection.protocol)) { + Logger.error( + 'ConnectionManager', + `Configured embedding protocol "${connection.protocol}" does not support embeddings` + ) + return null + } + return new ModelClient('embedding', connection) + } + + /** + * 测试连接:能列出模型即视为连通。 + */ + async testConnection(connection: ModelConnection): Promise { + try { + const adapter = getProtocolAdapter(connection.protocol) + if (!adapter.supportsModelListing || !adapter.listModels) { + return { ok: false, error: `Protocol "${connection.protocol}" cannot be tested` } + } + await adapter.listModels(connection) + return { ok: true } + } catch (error) { + return { ok: false, error: (error as Error).message } + } + } + + /** + * 拉取该 connection 可用的模型 ID 列表。 + */ + async fetchModels(connection: ModelConnection): Promise { + const adapter = getProtocolAdapter(connection.protocol) + if (!adapter.supportsModelListing || !adapter.listModels) { + throw new Error( + `Protocol "${connection.protocol}" does not support fetching models; please enter the Model ID manually` + ) + } + const models = await adapter.listModels(connection) + return [...new Set(models)].sort((a, b) => a.localeCompare(b)) + } + + /** + * 协议元数据 + */ + listProtocolInfos(): ProtocolInfo[] { + return API_PROTOCOLS.map((protocol) => { + const adapter = getProtocolAdapter(protocol) + return { + protocol, + supportsModelListing: adapter.supportsModelListing, + supportsEmbedding: protocolSupportsEmbedding(protocol) + } + }) + } +} diff --git a/src/main/models/ModelClient.ts b/src/main/models/ModelClient.ts new file mode 100644 index 0000000..1c23d6d --- /dev/null +++ b/src/main/models/ModelClient.ts @@ -0,0 +1,221 @@ +/** + * ModelClient + * + * 一个 Model Connection 的可执行封装:按 protocol 分派到对应的 adapter, + * 提供对话流式生成与 embedding 能力。业务代码只依赖它,不感知厂商。 + */ + +import { embed, embedMany, streamText } from 'ai' +import type { LanguageModel } from 'ai' +import type { APIMessage, StreamChunk } from '../../shared/types/chat' +import type { ModelCapability, ModelConnection } from '../../shared/types/connection' +import { getProtocolAdapter } from './protocols' +import type { ProtocolAdapter } from './protocols' +import type { EmbeddingConfig, EmbeddingResult } from './types' +import Logger from '../../shared/utils/logger' + +const DEFAULT_TEMPERATURE = 0.7 +const DEFAULT_MAX_TOKENS = 2048 + +/** + * 将 APIMessage 转换为 AI SDK 的 CoreMessage 格式 + */ +function convertToCoreMessages(messages: APIMessage[]) { + return messages.map((msg) => ({ + role: msg.role, + content: msg.content + })) +} + +export class ModelClient { + readonly capability: ModelCapability + readonly connection: ModelConnection + private adapter: ProtocolAdapter + + constructor(capability: ModelCapability, connection: ModelConnection) { + this.capability = capability + this.connection = connection + this.adapter = getProtocolAdapter(connection.protocol) + } + + get modelId(): string { + return this.connection.modelId + } + + get label(): string { + return `${this.connection.protocol} · ${this.connection.modelId}` + } + + /** + * 获取 AI SDK 模型实例(用于 streamObject / generateObject 等高级 API) + */ + getAIModel(modelId?: string): LanguageModel { + return this.adapter.createLanguageModel({ + ...this.connection, + modelId: modelId || this.connection.modelId + }) + } + + /** + * 流式发送消息 + */ + async sendMessageStream( + messages: APIMessage[], + onChunk: (chunk: StreamChunk) => void, + onError: (error: Error) => void, + onComplete: () => void + ): Promise { + const abortController = new AbortController() + const modelId = this.connection.modelId + + ;(async () => { + try { + Logger.debug('ModelClient', `Streaming with model: ${modelId}`) + + const result = streamText({ + model: this.getAIModel(), + messages: convertToCoreMessages(messages), + temperature: DEFAULT_TEMPERATURE, + maxOutputTokens: DEFAULT_MAX_TOKENS, + abortSignal: abortController.signal + }) + + // 使用 fullStream 而不是 textStream 以支持推理过程展示 + for await (const part of result.fullStream) { + if (abortController.signal.aborted) { + Logger.debug('ModelClient', 'Stream aborted by user') + break + } + + switch (part.type) { + case 'reasoning-start': + onChunk({ + content: '', + done: false, + metadata: { reasoningStart: true, reasoningId: part.id } + }) + break + + case 'reasoning-delta': + onChunk({ + content: part.text, + done: false, + metadata: { isReasoning: true, reasoningId: part.id } + }) + break + + case 'reasoning-end': + onChunk({ + content: '', + done: false, + metadata: { reasoningEnd: true, reasoningId: part.id } + }) + break + + case 'text-delta': + onChunk({ content: part.text, done: false }) + break + } + } + + const finalResult = await result + const usage = await finalResult.usage + const finishReason = await finalResult.finishReason + + onChunk({ + content: '', + done: true, + metadata: { + model: modelId, + finishReason, + usage: { + promptTokens: usage.inputTokens || 0, + completionTokens: usage.outputTokens || 0, + totalTokens: usage.totalTokens || (usage.inputTokens || 0) + (usage.outputTokens || 0) + } + } + }) + + onComplete() + } catch (error) { + if (abortController.signal.aborted) { + Logger.debug('ModelClient', 'Stream aborted') + onComplete() + } else { + Logger.error('ModelClient', 'Stream error:', error) + onError(error as Error) + } + } + })() + + return abortController + } + + /** + * 生成单个文本的 Embedding + */ + async createEmbedding(text: string, config?: EmbeddingConfig): Promise { + const modelId = config?.model || this.connection.modelId + if (!modelId) { + throw new Error('No embedding model configured') + } + + Logger.debug('ModelClient', `Creating embedding with model: ${modelId}`) + + const model = this.adapter.createEmbeddingModel({ ...this.connection, modelId }) + const providerOptions = this.adapter.embeddingProviderOptions?.( + this.connection, + config?.dimensions + ) + + const result = await embed({ + model, + value: text, + ...(providerOptions && { providerOptions }) + }) + + return { + embedding: new Float32Array(result.embedding), + model: modelId, + dimensions: result.embedding.length, + tokensUsed: result.usage?.tokens || 0 + } + } + + /** + * 批量生成 Embedding + */ + async createEmbeddings(texts: string[], config?: EmbeddingConfig): Promise { + const modelId = config?.model || this.connection.modelId + if (!modelId) { + throw new Error('No embedding model configured') + } + + Logger.debug( + 'ModelClient', + `Creating embeddings for ${texts.length} texts with model: ${modelId}` + ) + + const model = this.adapter.createEmbeddingModel({ ...this.connection, modelId }) + const providerOptions = this.adapter.embeddingProviderOptions?.( + this.connection, + config?.dimensions + ) + + const result = await embedMany({ + model, + values: texts, + ...(providerOptions && { providerOptions }) + }) + + const totalTokens = result.usage?.tokens || 0 + const tokensPerEmbedding = texts.length > 0 ? Math.floor(totalTokens / texts.length) : 0 + + return result.embeddings.map((embedding) => ({ + embedding: new Float32Array(embedding), + model: modelId, + dimensions: embedding.length, + tokensUsed: tokensPerEmbedding + })) + } +} diff --git a/src/main/models/protocols/anthropicMessages.ts b/src/main/models/protocols/anthropicMessages.ts new file mode 100644 index 0000000..7a9c611 --- /dev/null +++ b/src/main/models/protocols/anthropicMessages.ts @@ -0,0 +1,34 @@ +/** + * anthropic-messages 协议 + * + * Anthropic Messages API。Anthropic 不提供 embedding 端点。 + */ + +import { createAnthropic } from '@ai-sdk/anthropic' +import type { EmbeddingModel, LanguageModel } from 'ai' +import type { ModelConnection } from '../../../shared/types/connection' +import type { ProtocolAdapter } from './types' +import { listAnthropicModels } from './modelListing' + +function createProvider(connection: ModelConnection) { + return createAnthropic({ + baseURL: connection.baseUrl, + apiKey: connection.apiKey + }) +} + +export const anthropicMessagesAdapter: ProtocolAdapter = { + protocol: 'anthropic-messages', + + createLanguageModel(connection): LanguageModel { + return createProvider(connection)(connection.modelId) + }, + + createEmbeddingModel(): EmbeddingModel { + throw new Error('The anthropic-messages protocol does not support embeddings') + }, + + supportsModelListing: true, + + listModels: listAnthropicModels +} diff --git a/src/main/models/protocols/googleGenerativeAi.ts b/src/main/models/protocols/googleGenerativeAi.ts new file mode 100644 index 0000000..13ac38c --- /dev/null +++ b/src/main/models/protocols/googleGenerativeAi.ts @@ -0,0 +1,40 @@ +/** + * google-generative-ai 协议 + * + * Google Generative AI (Gemini) API,含 embedding 端点。 + */ + +import { createGoogleGenerativeAI } from '@ai-sdk/google' +import type { EmbeddingModel, LanguageModel } from 'ai' +import type { ModelConnection } from '../../../shared/types/connection' +import type { ProtocolAdapter } from './types' +import { listGoogleModels } from './modelListing' + +const PROVIDER_OPTIONS_KEY = 'google' + +function createProvider(connection: ModelConnection) { + return createGoogleGenerativeAI({ + baseURL: connection.baseUrl, + apiKey: connection.apiKey + }) +} + +export const googleGenerativeAiAdapter: ProtocolAdapter = { + protocol: 'google-generative-ai', + + createLanguageModel(connection): LanguageModel { + return createProvider(connection)(connection.modelId) + }, + + createEmbeddingModel(connection): EmbeddingModel { + return createProvider(connection).textEmbedding(connection.modelId) + }, + + embeddingProviderOptions(_connection, dimensions) { + return dimensions ? { [PROVIDER_OPTIONS_KEY]: { outputDimensionality: dimensions } } : undefined + }, + + supportsModelListing: true, + + listModels: listGoogleModels +} diff --git a/src/main/models/protocols/index.ts b/src/main/models/protocols/index.ts new file mode 100644 index 0000000..51c0ed6 --- /dev/null +++ b/src/main/models/protocols/index.ts @@ -0,0 +1,29 @@ +/** + * Protocol adapter 注册表 + * + * 按 protocol 分派到具体实现,业务代码不再按厂商名分支。 + */ + +import type { APIProtocol } from '../../../shared/types/connection' +import type { ProtocolAdapter } from './types' +import { openaiCompletionsAdapter } from './openaiCompletions' +import { openaiResponsesAdapter } from './openaiResponses' +import { anthropicMessagesAdapter } from './anthropicMessages' +import { googleGenerativeAiAdapter } from './googleGenerativeAi' + +const ADAPTERS: Record = { + 'openai-completions': openaiCompletionsAdapter, + 'openai-responses': openaiResponsesAdapter, + 'anthropic-messages': anthropicMessagesAdapter, + 'google-generative-ai': googleGenerativeAiAdapter +} + +export function getProtocolAdapter(protocol: APIProtocol): ProtocolAdapter { + const adapter = ADAPTERS[protocol] + if (!adapter) { + throw new Error(`Unsupported API protocol: ${protocol}`) + } + return adapter +} + +export type { ProtocolAdapter } from './types' diff --git a/src/main/models/protocols/modelListing.ts b/src/main/models/protocols/modelListing.ts new file mode 100644 index 0000000..5385ead --- /dev/null +++ b/src/main/models/protocols/modelListing.ts @@ -0,0 +1,110 @@ +/** + * 从各协议的模型列表端点获取模型 ID。 + * 所有实现都在失败时抛出带上下文的错误,由上层决定如何提示用户。 + */ + +import type { ModelConnection } from '../../../shared/types/connection' + +function joinUrl(baseUrl: string, path: string): string { + return `${baseUrl.replace(/\/+$/, '')}/${path.replace(/^\/+/, '')}` +} + +function extractIds(payload: unknown): string[] { + if (!payload || typeof payload !== 'object') { + return [] + } + + const record = payload as Record + const entries = Array.isArray(record.data) + ? record.data + : Array.isArray(record.models) + ? record.models + : [] + + const ids: string[] = [] + for (const entry of entries) { + if (typeof entry === 'string') { + ids.push(entry) + continue + } + if (entry && typeof entry === 'object') { + const item = entry as Record + const id = item.id ?? item.name ?? item.model + if (typeof id === 'string' && id.length > 0) { + ids.push(id) + } + } + } + + return ids +} + +async function readErrorMessage(response: Response): Promise { + try { + const text = await response.text() + return text ? `: ${text.slice(0, 300)}` : '' + } catch { + return '' + } +} + +/** + * OpenAI 兼容端点:GET {baseUrl}/models + * 覆盖 OpenAI / DeepSeek / Qwen / Kimi / SiliconFlow / 智谱 / Ollama / vLLM / + * LM Studio / SGLang / OpenRouter 等。 + */ +export async function listOpenAICompatibleModels(connection: ModelConnection): Promise { + const url = joinUrl(connection.baseUrl, 'models') + const response = await fetch(url, { + method: 'GET', + headers: { + Accept: 'application/json', + ...(connection.apiKey ? { Authorization: `Bearer ${connection.apiKey}` } : {}) + } + }) + + if (!response.ok) { + throw new Error(`GET ${url} returned ${response.status}${await readErrorMessage(response)}`) + } + + return extractIds(await response.json()) +} + +/** + * Anthropic Messages 端点:GET {baseUrl}/models + */ +export async function listAnthropicModels(connection: ModelConnection): Promise { + const url = joinUrl(connection.baseUrl, 'models') + const response = await fetch(url, { + method: 'GET', + headers: { + Accept: 'application/json', + 'x-api-key': connection.apiKey, + 'anthropic-version': '2023-06-01' + } + }) + + if (!response.ok) { + throw new Error(`GET ${url} returned ${response.status}${await readErrorMessage(response)}`) + } + + return extractIds(await response.json()) +} + +/** + * Google Generative AI 端点:GET {baseUrl}/models?key=... + * Google 返回的模型名带 `models/` 前缀,这里去掉。 + */ +export async function listGoogleModels(connection: ModelConnection): Promise { + const url = `${joinUrl(connection.baseUrl, 'models')}?key=${encodeURIComponent(connection.apiKey)}` + const response = await fetch(url, { + method: 'GET', + headers: { Accept: 'application/json' } + }) + + if (!response.ok) { + throw new Error(`GET ${url} returned ${response.status}${await readErrorMessage(response)}`) + } + + return extractIds(await response.json()).map((id) => id.replace(/^models\//, '')) +} diff --git a/src/main/models/protocols/openaiCompletions.ts b/src/main/models/protocols/openaiCompletions.ts new file mode 100644 index 0000000..04be83b --- /dev/null +++ b/src/main/models/protocols/openaiCompletions.ts @@ -0,0 +1,42 @@ +/** + * openai-completions 协议 + * + * OpenAI Chat Completions API。覆盖绝大多数服务:OpenAI、DeepSeek、Qwen、 + * Kimi、SiliconFlow、智谱、Ollama、LM Studio、vLLM、SGLang、OpenRouter 等。 + */ + +import { createOpenAICompatible } from '@ai-sdk/openai-compatible' +import type { EmbeddingModel, LanguageModel } from 'ai' +import type { ModelConnection } from '../../../shared/types/connection' +import type { ProtocolAdapter } from './types' +import { listOpenAICompatibleModels } from './modelListing' + +export const PROVIDER_OPTIONS_KEY = 'openai-compatible' + +function createProvider(connection: ModelConnection) { + return createOpenAICompatible({ + name: PROVIDER_OPTIONS_KEY, + baseURL: connection.baseUrl, + apiKey: connection.apiKey + }) +} + +export const openaiCompletionsAdapter: ProtocolAdapter = { + protocol: 'openai-completions', + + createLanguageModel(connection): LanguageModel { + return createProvider(connection)(connection.modelId) + }, + + createEmbeddingModel(connection): EmbeddingModel { + return createProvider(connection).textEmbeddingModel(connection.modelId) + }, + + embeddingProviderOptions(_connection, dimensions) { + return dimensions ? { [PROVIDER_OPTIONS_KEY]: { dimensions } } : undefined + }, + + supportsModelListing: true, + + listModels: listOpenAICompatibleModels +} diff --git a/src/main/models/protocols/openaiResponses.ts b/src/main/models/protocols/openaiResponses.ts new file mode 100644 index 0000000..85eb9b8 --- /dev/null +++ b/src/main/models/protocols/openaiResponses.ts @@ -0,0 +1,40 @@ +/** + * openai-responses 协议 + * + * OpenAI Responses API。与 openai-completions 的区别只在对话端点。 + */ + +import { createOpenAI } from '@ai-sdk/openai' +import type { EmbeddingModel, LanguageModel } from 'ai' +import type { ModelConnection } from '../../../shared/types/connection' +import type { ProtocolAdapter } from './types' +import { listOpenAICompatibleModels } from './modelListing' + +const PROVIDER_OPTIONS_KEY = 'openai' + +function createProvider(connection: ModelConnection) { + return createOpenAI({ + baseURL: connection.baseUrl, + apiKey: connection.apiKey + }) +} + +export const openaiResponsesAdapter: ProtocolAdapter = { + protocol: 'openai-responses', + + createLanguageModel(connection): LanguageModel { + return createProvider(connection).responses(connection.modelId) + }, + + createEmbeddingModel(connection): EmbeddingModel { + return createProvider(connection).textEmbeddingModel(connection.modelId) + }, + + embeddingProviderOptions(_connection, dimensions) { + return dimensions ? { [PROVIDER_OPTIONS_KEY]: { dimensions } } : undefined + }, + + supportsModelListing: true, + + listModels: listOpenAICompatibleModels +} diff --git a/src/main/models/protocols/types.ts b/src/main/models/protocols/types.ts new file mode 100644 index 0000000..bbfc976 --- /dev/null +++ b/src/main/models/protocols/types.ts @@ -0,0 +1,43 @@ +/** + * Protocol adapter 接口 + * + * 业务代码只依赖这个接口和 ModelConnection,不感知任何厂商。 + * 新增一个 API 协议只需实现一个 adapter。 + */ + +import type { EmbeddingModel, LanguageModel } from 'ai' +import type { SharedV2ProviderOptions } from '@ai-sdk/provider' +import type { APIProtocol, ModelConnection } from '../../../shared/types/connection' + +export interface ProtocolAdapter { + readonly protocol: APIProtocol + + /** + * 构造该协议下的语言模型。协议不支持对话时抛错。 + */ + createLanguageModel(connection: ModelConnection): LanguageModel + + /** + * 构造该协议下的 embedding 模型。协议不支持 embedding 时抛错。 + */ + createEmbeddingModel(connection: ModelConnection): EmbeddingModel + + /** + * 把用户声明的向量维度翻译成该协议的 providerOptions。 + * 协议/模型不支持时返回 undefined(由服务端忽略该参数)。 + */ + embeddingProviderOptions?( + connection: ModelConnection, + dimensions?: number + ): SharedV2ProviderOptions | undefined + + /** + * 该协议是否支持从端点拉取模型列表(/v1/models 等)。 + */ + supportsModelListing: boolean + + /** + * 拉取可选模型 ID 列表。协议不支持时抛错。 + */ + listModels?(connection: ModelConnection): Promise +} diff --git a/src/main/models/types.ts b/src/main/models/types.ts new file mode 100644 index 0000000..5e7ac89 --- /dev/null +++ b/src/main/models/types.ts @@ -0,0 +1,23 @@ +/** + * 模型调用相关的内部类型 + */ + +/** + * Embedding 配置 + */ +export interface EmbeddingConfig { + /** 覆盖 connection 上的 modelId */ + model?: string + /** 向量维度,部分模型支持 */ + dimensions?: number +} + +/** + * Embedding 结果 + */ +export interface EmbeddingResult { + embedding: Float32Array + model: string + dimensions: number + tokensUsed: number +} diff --git a/src/main/providers/ProviderManager.ts b/src/main/providers/ProviderManager.ts deleted file mode 100644 index f9cfb2c..0000000 --- a/src/main/providers/ProviderManager.ts +++ /dev/null @@ -1,293 +0,0 @@ -/** - * Provider Manager - * 协调 ProviderRegistry 和 ProviderConfigManager,提供统一的供应商访问接口 - */ - -import type { BaseProvider } from './capabilities/BaseProvider' -import type { ChatProvider } from './types' -import type { EmbeddingProvider } from './types' -import type { CustomProviderConfig, ProviderDescriptor } from './registry/ProviderDescriptor' -import { ProviderRegistry } from './registry/ProviderRegistry' -import { BUILTIN_PROVIDERS } from './registry/builtinProviders' -import { AISDKProvider } from './base/AISDKProvider' -import { settingsManager, providerConfigManager } from '../config' -import { getAllBuiltinModels } from '../../shared/config/models' -import Logger from '../../shared/utils/logger' - -/** - * ProviderManager - * 供应商管理器,协调注册表和配置管理 - */ -export class ProviderManager { - private registry: ProviderRegistry - - constructor() { - this.registry = new ProviderRegistry() - - // 注册所有内置供应商 - this.registerBuiltinProviders() - - Logger.info('ProviderManager', 'Provider manager initialized') - } - - /** - * 初始化 Provider Manager(异步) - * 在应用启动时调用,初始化内置模型列表 - */ - async initialize(): Promise { - await this.initializeBuiltinModels() - } - - /** - * 注册所有内置供应商 - */ - private registerBuiltinProviders(): void { - this.registry.registerMany(BUILTIN_PROVIDERS) - Logger.info( - 'ProviderManager', - `Registered ${BUILTIN_PROVIDERS.length} builtin providers: ${BUILTIN_PROVIDERS.map((p) => p.name).join(', ')}` - ) - } - - /** - * 初始化内置模型列表 - * 如果 electron-store 中没有模型缓存,自动写入内置模型 - */ - private async initializeBuiltinModels(): Promise { - try { - const builtinModels = getAllBuiltinModels() - - for (const [providerName, models] of Object.entries(builtinModels)) { - const cachedModels = await providerConfigManager.getProviderModels(providerName) - - // 如果没有缓存,写入内置模型 - if (!cachedModels || cachedModels.length === 0) { - Logger.info( - 'ProviderManager', - `Initializing ${models.length} builtin models for ${providerName}` - ) - await providerConfigManager.saveProviderModels(providerName, models) - } else { - Logger.info( - 'ProviderManager', - `Provider ${providerName} already has ${cachedModels.length} cached models, skipping initialization` - ) - } - } - - Logger.info('ProviderManager', 'Builtin models initialization complete') - } catch (error) { - Logger.error('ProviderManager', 'Failed to initialize builtin models:', error) - } - } - - /** - * 获取已配置的 provider(合并配置) - * @param name - 供应商名称 - * @returns BaseProvider 或 null - */ - async getConfiguredProvider(name: string): Promise { - const provider = this.registry.getProvider(name) - if (!provider) { - Logger.warn('ProviderManager', `Provider ${name} not found in registry`) - return null - } - - // 获取用户配置 - const config = await providerConfigManager.getProviderConfig(name) - if (!config || !config.enabled) { - Logger.warn('ProviderManager', `Provider ${name} is not enabled`) - return null - } - - // 配置 provider - provider.configure(config.config) - return provider - } - - /** - * 获取活跃的对话 provider - * 如果用户设置了默认对话模型但不可用,会直接返回 null,不会自动 fallback - */ - async getActiveChatProvider(): Promise { - try { - const settings = await settingsManager.getAllSettings() - const defaultChatModel = settings.defaultChatModel - - // 如果用户设置了默认对话模型,解析并使用 - if (defaultChatModel && defaultChatModel.includes(':')) { - const [providerName, ...modelIdParts] = defaultChatModel.split(':') - const modelId = modelIdParts.join(':') - const provider = await this.getConfiguredProvider(providerName) - - if (!provider) { - Logger.error( - 'ProviderManager', - `Provider for default chat model "${providerName}" is not available or not enabled` - ) - return null - } - - // 检查是否支持对话能力 - const compatProvider = provider as AISDKProvider - if (!compatProvider.hasChatCapability()) { - Logger.error( - 'ProviderManager', - `Provider ${providerName} does not support chat capability` - ) - return null - } - - // 使用指定的模型配置 - compatProvider.configure({ model: modelId }) - Logger.info('ProviderManager', `Using default chat model: ${providerName} - ${modelId}`) - return compatProvider as ChatProvider - } - - // 如果没有设置默认对话模型,返回 null - Logger.warn('ProviderManager', 'No default chat model configured') - return null - } catch (error) { - Logger.error('ProviderManager', 'Failed to get active chat provider:', error) - return null - } - } - - /** - * 获取活跃的嵌入 provider - * 如果用户设置了默认嵌入模型但不可用,会直接返回 null,不会自动 fallback - */ - async getActiveEmbeddingProvider(): Promise { - try { - const settings = await settingsManager.getAllSettings() - const defaultEmbeddingModel = settings.defaultEmbeddingModel - - // 如果用户设置了默认嵌入模型,解析并使用 - if (defaultEmbeddingModel && defaultEmbeddingModel.includes(':')) { - const [providerName, ...modelIdParts] = defaultEmbeddingModel.split(':') - const modelId = modelIdParts.join(':') - const provider = await this.getConfiguredProvider(providerName) - - if (!provider) { - Logger.error( - 'ProviderManager', - `Provider for default embedding model "${providerName}" is not available or not enabled` - ) - return null - } - - // 检查是否支持嵌入能力 - const compatProvider = provider as AISDKProvider - if (!compatProvider.hasEmbeddingCapability()) { - Logger.error( - 'ProviderManager', - `Provider ${providerName} does not support embedding capability` - ) - return null - } - - // 使用指定的模型配置 - compatProvider.configure({ model: modelId }) - Logger.info( - 'ProviderManager', - `Using default embedding model: ${providerName} - ${modelId}` - ) - return compatProvider as EmbeddingProvider - } - - // 如果没有设置默认嵌入模型,返回 null - Logger.warn('ProviderManager', 'No default embedding model configured') - return null - } catch (error) { - Logger.error('ProviderManager', 'Failed to get active embedding provider:', error) - return null - } - } - - /** - * 注册自定义供应商 - * @param config - 自定义供应商配置 - */ - async registerCustomProvider(config: CustomProviderConfig): Promise { - const descriptor: ProviderDescriptor = { - name: config.providerName, - displayName: config.displayName, - isBuiltin: false, - defaultBaseUrl: config.baseUrl, - capabilities: { - chat: true, // 自定义供应商默认假设支持对话 - embedding: true // 可以通过 fetchModels 后根据模型类型更新 - }, - createProvider: (descriptor) => new AISDKProvider(descriptor) - } - - // 注册到 registry - this.registry.register(descriptor) - - // 保存配置 - await providerConfigManager.saveProviderConfig( - config.providerName, - { - baseUrl: config.baseUrl, - apiKey: config.apiKey - }, - true // 默认启用 - ) - - Logger.info('ProviderManager', `Registered custom provider: ${config.providerName}`) - } - - /** - * 列出所有已注册的供应商名称 - */ - listProviders(): string[] { - return this.registry.listProviderNames() - } - - /** - * 获取供应商描述符 - */ - getDescriptor(name: string): ProviderDescriptor | undefined { - return this.registry.getDescriptor(name) - } - - /** - * 列出所有供应商描述符 - */ - listDescriptors(): ProviderDescriptor[] { - return this.registry.listDescriptors() - } - - /** - * 根据能力获取供应商 - */ - getProvidersByCapability( - capability: 'chat' | 'embedding' | 'rerank' | 'imageGeneration' - ): ProviderDescriptor[] { - return this.registry.getProvidersByCapability(capability) - } - - /** - * 获取 provider(不带配置,用于向后兼容) - * @deprecated 使用 getConfiguredProvider 替代 - */ - getProvider(name: string): BaseProvider | undefined { - return this.registry.getProvider(name) - } - - /** - * 获取活跃 provider(向后兼容) - * @deprecated 使用 getActiveChatProvider 替代 - */ - async getActiveProvider(): Promise { - return this.getActiveChatProvider() - } - - /** - * 获取 embedding provider(向后兼容) - * @deprecated 使用 getActiveEmbeddingProvider 替代 - */ - async getEmbeddingProvider(): Promise { - return this.getActiveEmbeddingProvider() - } -} diff --git a/src/main/providers/base/AISDKProvider.ts b/src/main/providers/base/AISDKProvider.ts deleted file mode 100644 index d2f984f..0000000 --- a/src/main/providers/base/AISDKProvider.ts +++ /dev/null @@ -1,464 +0,0 @@ -/** - * AI SDK Provider - * 基于 Vercel AI SDK 的统一 Provider 实现 - * 支持所有 OpenAI 兼容的 API(OpenAI, DeepSeek, Qwen, Kimi, SiliconFlow 等) - */ - -import { createOpenAI } from '@ai-sdk/openai' -import { createOpenAICompatible } from '@ai-sdk/openai-compatible' -import { createDeepSeek } from '@ai-sdk/deepseek' -import { createAlibaba } from '@ai-sdk/alibaba' -import { createOllama } from 'ollama-ai-provider-v2' -import { streamText, embed, embedMany } from 'ai' -import type { BaseProvider, LLMProviderConfig } from '../capabilities/BaseProvider' -import type { ChatCapability } from '../capabilities/ChatCapability' -import type { EmbeddingCapability } from '../capabilities/EmbeddingCapability' -import type { APIMessage, StreamChunk } from '../../../shared/types/chat' -import type { EmbeddingConfig, EmbeddingResult } from '../capabilities/EmbeddingCapability' -import type { ProviderDescriptor } from '../registry/ProviderDescriptor' -import Logger from '../../../shared/utils/logger' - -/** - * 将 APIMessage 转换为 AI SDK 的 CoreMessage 格式 - */ -function convertToCoreMessages(messages: APIMessage[]) { - return messages.map((msg) => ({ - role: msg.role, - content: msg.content - })) -} - -/** - * AISDKProvider - * 基于 Vercel AI SDK 的统一 Provider 实现 - * 根据 ProviderDescriptor 的能力配置动态组合功能 - */ -export class AISDKProvider implements BaseProvider { - readonly name: string - private descriptor: ProviderDescriptor - protected config: LLMProviderConfig - - // AI SDK provider 实例 - private aiProvider: - | ReturnType - | ReturnType - | ReturnType - | ReturnType - | ReturnType - | null = null - - constructor(descriptor: ProviderDescriptor) { - this.name = descriptor.name - this.descriptor = descriptor - - // 初始化默认配置 - this.config = { - baseUrl: descriptor.defaultBaseUrl, - model: descriptor.defaultChatModel || descriptor.defaultEmbeddingModel, - temperature: 0.7, - maxTokens: 2048 - } - - Logger.info('AISDKProvider', `Provider ${this.name} initialized`) - } - - /** - * 配置 Provider - */ - configure(config: LLMProviderConfig): void { - this.config = { ...this.config, ...config } - - // 重新创建 AI SDK provider 实例 - if (config.apiKey || this.name === 'ollama') { - if (this.name === 'openai') { - // OpenAI 使用官方 provider - this.aiProvider = createOpenAI({ - baseURL: this.config.baseUrl, - apiKey: config.apiKey - }) - } else if (this.name === 'deepseek') { - // DeepSeek 使用官方 provider - this.aiProvider = createDeepSeek({ - baseURL: this.config.baseUrl, - apiKey: config.apiKey - }) - } else if (this.name === 'qwen') { - // Qwen 使用官方 Alibaba / DashScope provider - this.aiProvider = createAlibaba({ - baseURL: this.config.baseUrl, - apiKey: config.apiKey - }) - } else if (this.name === 'ollama') { - // Ollama 使用社区 provider - this.aiProvider = createOllama({ - baseURL: this.config.baseUrl || 'http://localhost:11434/api' - }) - } else { - // 其他所有 provider 都使用 OpenAI Compatible (Kimi, SiliconFlow) - this.aiProvider = createOpenAICompatible({ - name: this.name, - baseURL: this.config.baseUrl || '', - apiKey: config.apiKey || '' - }) - } - } - - Logger.debug('AISDKProvider', `Provider ${this.name} configured`) - } - - /** - * 验证配置是否有效 - */ - async validateConfig(config: LLMProviderConfig): Promise { - try { - const response = await fetch(`${config.baseUrl}/models`, { - headers: { - Authorization: `Bearer ${config.apiKey}` - } - }) - return response.ok - } catch { - return false - } - } - - // ==================== 能力检查方法 ==================== - - /** - * 检查是否支持对话能力 - * TypeScript 类型守卫 - */ - hasChatCapability(): this is BaseProvider & ChatCapability { - return this.descriptor.capabilities.chat && this.aiProvider !== null - } - - /** - * 检查是否支持嵌入能力 - * TypeScript 类型守卫 - */ - hasEmbeddingCapability(): this is BaseProvider & EmbeddingCapability { - return this.descriptor.capabilities.embedding && this.aiProvider !== null - } - - // ==================== 对话能力方法 ==================== - - /** - * 流式发送消息 - * 如果不支持对话能力会抛出错误 - */ - async sendMessageStream( - messages: APIMessage[], - onChunk: (chunk: StreamChunk) => void, - onError: (error: Error) => void, - onComplete: () => void - ): Promise { - if (!this.aiProvider) { - const error = new Error( - `Provider ${this.name} is not configured. Please configure API key first.` - ) - onError(error) - const abortController = new AbortController() - abortController.abort() - return abortController - } - - if (!this.descriptor.capabilities.chat) { - const error = new Error(`Provider ${this.name} does not support chat capability`) - onError(error) - const abortController = new AbortController() - abortController.abort() - return abortController - } - - // 创建 AbortController - const abortController = new AbortController() - - // 异步执行流式生成 - ;(async () => { - try { - const modelId = this.config.model || this.descriptor.defaultChatModel! - - Logger.debug('AISDKProvider', `Streaming with model: ${modelId}`) - - // 转换消息格式 - const coreMessages = convertToCoreMessages(messages) - - // 获取语言模型 - const model = this.aiProvider!(modelId) as any - - // 调用 AI SDK streamText - const result = streamText({ - model, - messages: coreMessages, - temperature: this.config.temperature, - maxOutputTokens: this.config.maxTokens, - abortSignal: abortController.signal - }) - - // 处理流式响应 - // 使用 fullStream 而不是 textStream 以支持推理过程展示 - for await (const part of result.fullStream) { - if (abortController.signal.aborted) { - Logger.debug('AISDKProvider', 'Stream aborted by user') - break - } - - // 处理不同类型的流式部分 - switch (part.type) { - case 'reasoning-start': - // 推理块开始 - onChunk({ - content: '', - done: false, - metadata: { - reasoningStart: true, - reasoningId: part.id - } - }) - break - - case 'reasoning-delta': - // 推理增量内容 - onChunk({ - content: part.text, - done: false, - metadata: { - isReasoning: true, - reasoningId: part.id - } - }) - break - - case 'reasoning-end': - // 推理块结束 - onChunk({ - content: '', - done: false, - metadata: { - reasoningEnd: true, - reasoningId: part.id - } - }) - break - - case 'text-delta': - // 文本增量内容(最终答案) - onChunk({ - content: part.text, - done: false - }) - break - } - } - - // 等待结果完成,获取 metadata - const finalResult = await result - const usage = await finalResult.usage - const finishReason = await finalResult.finishReason - - // 发送完成标记,包含 metadata - onChunk({ - content: '', - done: true, - metadata: { - model: modelId, - finishReason: finishReason, - usage: { - promptTokens: usage.inputTokens || 0, - completionTokens: usage.outputTokens || 0, - totalTokens: usage.totalTokens || (usage.inputTokens || 0) + (usage.outputTokens || 0) - } - } - }) - - onComplete() - } catch (error) { - if (abortController.signal.aborted) { - Logger.debug('AISDKProvider', 'Stream aborted') - onComplete() - } else { - Logger.error('AISDKProvider', 'Stream error:', error) - onError(error as Error) - } - } - })() - - return abortController - } - - /** - * 获取默认对话模型 - */ - getDefaultChatModel(): string { - if (!this.descriptor.capabilities.chat) { - throw new Error(`Provider ${this.name} does not support chat capability`) - } - return this.descriptor.defaultChatModel || '' - } - - /** - * 获取 AI SDK 模型实例 (用于 streamObject 等高级 API) - * @param modelId - 模型ID,如果不提供则使用配置的默认模型 - */ - getAIModel(modelId?: string): any { - if (!this.aiProvider) { - throw new Error(`Provider ${this.name} is not configured. Please configure API key first.`) - } - const model = modelId || this.config.model || this.descriptor.defaultChatModel - if (!model) { - throw new Error(`No model specified for provider ${this.name}`) - } - return this.aiProvider(model) - } - - // ==================== 嵌入能力方法 ==================== - - /** - * 生成单个文本的 Embedding - * 如果不支持嵌入能力会抛出错误 - */ - async createEmbedding(text: string, config?: EmbeddingConfig): Promise { - if (!this.aiProvider) { - throw new Error(`Provider ${this.name} is not configured. Please configure API key first.`) - } - - if (!this.descriptor.capabilities.embedding) { - throw new Error(`Provider ${this.name} does not support embedding capability`) - } - - const modelId = - config?.model || this.config.model || this.descriptor.defaultEmbeddingModel || '' - - if (!modelId) { - throw new Error(`No embedding model specified for provider ${this.name}`) - } - - Logger.debug('AISDKProvider', `Creating embedding with model: ${modelId}`) - - // 使用 AI SDK 的 embed 函数 - // 不同 provider 使用不同的方法名 - const embeddingModel = - this.name === 'ollama' - ? (this.aiProvider as any).embedding(modelId) // Ollama: embedding - : (this.aiProvider as any).textEmbeddingModel(modelId) // OpenAI, DeepSeek, Qwen, OpenAI-Compatible: textEmbeddingModel - - // 构建 providerOptions - const providerOptions = this.buildProviderOptions(config) - - const result = await embed({ - model: embeddingModel, - value: text, - ...(providerOptions && { providerOptions }) // 条件添加 providerOptions - }) - - return { - embedding: new Float32Array(result.embedding), - model: modelId, - dimensions: result.embedding.length, - tokensUsed: result.usage?.tokens || 0 - } - } - - /** - * 批量生成 Embedding - * 如果不支持嵌入能力会抛出错误 - */ - async createEmbeddings(texts: string[], config?: EmbeddingConfig): Promise { - if (!this.aiProvider) { - throw new Error(`Provider ${this.name} is not configured. Please configure API key first.`) - } - - if (!this.descriptor.capabilities.embedding) { - throw new Error(`Provider ${this.name} does not support embedding capability`) - } - - const modelId = - config?.model || this.config.model || this.descriptor.defaultEmbeddingModel || '' - - if (!modelId) { - throw new Error(`No embedding model specified for provider ${this.name}`) - } - - Logger.debug( - 'AISDKProvider', - `Creating embeddings for ${texts.length} texts with model: ${modelId}` - ) - - // 使用 AI SDK 的 embedMany 函数 - // 不同 provider 使用不同的方法名 - const embeddingModel = - this.name === 'ollama' - ? (this.aiProvider as any).embedding(modelId) // Ollama: embedding - : (this.aiProvider as any).textEmbeddingModel(modelId) // OpenAI, DeepSeek, Qwen, OpenAI-Compatible: textEmbeddingModel - - // 构建 providerOptions - const providerOptions = this.buildProviderOptions(config) - - const result = await embedMany({ - model: embeddingModel, - values: texts, - ...(providerOptions && { providerOptions }) // 条件添加 providerOptions - }) - - const totalTokens = result.usage?.tokens || 0 - const tokensPerEmbedding = Math.floor(totalTokens / texts.length) - - return result.embeddings.map((embedding) => ({ - embedding: new Float32Array(embedding), - model: modelId, - dimensions: embedding.length, - tokensUsed: tokensPerEmbedding - })) - } - - /** - * 获取默认 Embedding 模型 - */ - getDefaultEmbeddingModel(): string { - if (!this.descriptor.capabilities.embedding) { - throw new Error(`Provider ${this.name} does not support embedding capability`) - } - return this.descriptor.defaultEmbeddingModel || '' - } - - /** - * 构建 provider-specific 选项 - * 根据 provider 类型和配置生成 providerOptions - */ - private buildProviderOptions(config?: EmbeddingConfig): Record | undefined { - if (!config?.dimensions) { - return undefined - } - - // 支持 dimensions 参数的 providers(白名单机制) - const supportedProviders = ['openai', 'deepseek', 'zhipu', 'siliconflow', 'openai-compatible'] - - if (!supportedProviders.includes(this.name)) { - Logger.debug( - 'AISDKProvider', - `Provider ${this.name} does not support dimensions parameter, ignoring` - ) - return undefined - } - - // 对于 OpenAI 兼容的 provider,使用统一的格式 - // SiliconFlow 使用 'openai' 作为 providerKey - const providerKey = this.name === 'siliconflow' ? 'openai' : this.name - - return { - [providerKey]: { - dimensions: config.dimensions - } - } - } - - // ==================== 向后兼容方法(已废弃) ==================== - - /** - * @deprecated 使用 hasChatCapability() 或 hasEmbeddingCapability() 替代 - * 检查是否支持 Embedding - */ - supportsEmbedding(): boolean { - return this.hasEmbeddingCapability() - } -} diff --git a/src/main/providers/capabilities/BaseProvider.ts b/src/main/providers/capabilities/BaseProvider.ts deleted file mode 100644 index 345d589..0000000 --- a/src/main/providers/capabilities/BaseProvider.ts +++ /dev/null @@ -1,40 +0,0 @@ -/** - * BaseProvider 接口 - * 所有 Provider 必须实现的基础接口 - */ - -/** - * Provider 配置 - */ -export interface LLMProviderConfig { - apiKey?: string - baseUrl?: string - model?: string - temperature?: number - maxTokens?: number - [key: string]: any -} - -/** - * BaseProvider 基础接口 - * 定义所有 Provider 必须实现的基础方法 - */ -export interface BaseProvider { - /** - * Provider 名称 (唯一标识) - */ - readonly name: string - - /** - * 配置 Provider - * @param config - Provider 配置项 - */ - configure(config: LLMProviderConfig): void - - /** - * 验证配置是否有效(可选) - * @param config - 需要验证的配置 - * @returns Promise - 配置是否有效 - */ - validateConfig?(config: LLMProviderConfig): Promise -} diff --git a/src/main/providers/capabilities/ChatCapability.ts b/src/main/providers/capabilities/ChatCapability.ts deleted file mode 100644 index 8171e99..0000000 --- a/src/main/providers/capabilities/ChatCapability.ts +++ /dev/null @@ -1,33 +0,0 @@ -/** - * ChatCapability 接口 - * 定义对话能力 - */ - -import type { APIMessage, StreamChunk } from '../../../shared/types/chat' - -/** - * ChatCapability 对话能力接口 - * 实现此接口的 Provider 支持对话功能 - */ -export interface ChatCapability { - /** - * 流式发送消息 - * @param messages - 聊天消息历史 - * @param onChunk - 接收到新 chunk 时的回调 - * @param onError - 发生错误时的回调 - * @param onComplete - 完成时的回调 - * @returns Promise - 用于中断请求的 AbortController - */ - sendMessageStream( - messages: APIMessage[], - onChunk: (chunk: StreamChunk) => void, - onError: (error: Error) => void, - onComplete: () => void - ): Promise - - /** - * 获取默认对话模型 - * @returns 默认模型名称 - */ - getDefaultChatModel(): string -} diff --git a/src/main/providers/capabilities/EmbeddingCapability.ts b/src/main/providers/capabilities/EmbeddingCapability.ts deleted file mode 100644 index d281177..0000000 --- a/src/main/providers/capabilities/EmbeddingCapability.ts +++ /dev/null @@ -1,50 +0,0 @@ -/** - * EmbeddingCapability 接口 - * 定义嵌入能力 - */ - -/** - * Embedding 配置 - */ -export interface EmbeddingConfig { - model?: string // embedding 模型名称 - dimensions?: number // 向量维度(部分模型支持) -} - -/** - * Embedding 结果 - */ -export interface EmbeddingResult { - embedding: Float32Array // 向量数据 - model: string // 使用的模型 - dimensions: number // 向量维度 - tokensUsed: number // 消耗的 token 数 -} - -/** - * EmbeddingCapability 嵌入能力接口 - * 实现此接口的 Provider 支持嵌入向量生成功能 - */ -export interface EmbeddingCapability { - /** - * 生成单个文本的 Embedding - * @param text - 输入文本 - * @param config - Embedding 配置 - * @returns Promise - Embedding 结果 - */ - createEmbedding(text: string, config?: EmbeddingConfig): Promise - - /** - * 批量生成 Embedding - * @param texts - 输入文本数组 - * @param config - Embedding 配置 - * @returns Promise - Embedding 结果数组 - */ - createEmbeddings(texts: string[], config?: EmbeddingConfig): Promise - - /** - * 获取默认 Embedding 模型 - * @returns 默认 Embedding 模型名称 - */ - getDefaultEmbeddingModel(): string -} diff --git a/src/main/providers/capabilities/ImageGenerationCapability.ts b/src/main/providers/capabilities/ImageGenerationCapability.ts deleted file mode 100644 index 9b701c6..0000000 --- a/src/main/providers/capabilities/ImageGenerationCapability.ts +++ /dev/null @@ -1,47 +0,0 @@ -/** - * ImageGenerationCapability 接口 - * 定义图像生成能力(未来扩展) - */ - -/** - * 图像生成配置 - */ -export interface ImageGenerationConfig { - model?: string // 图像生成模型名称 - size?: string // 图像尺寸,如 '1024x1024' - quality?: 'standard' | 'hd' // 图像质量 - style?: 'vivid' | 'natural' // 图像风格 - n?: number // 生成图像数量 -} - -/** - * 图像生成结果 - */ -export interface ImageGenerationResult { - url?: string // 图像 URL(如果是 URL 模式) - b64Json?: string // Base64 编码的图像(如果是 base64 模式) - revisedPrompt?: string // 修订后的提示词 -} - -/** - * ImageGenerationCapability 图像生成能力接口 - * 实现此接口的 Provider 支持图像生成功能 - * - * @remarks - * 此接口为未来扩展预留,暂未实现 - */ -export interface ImageGenerationCapability { - /** - * 生成图像 - * @param prompt - 图像生成提示词 - * @param config - 图像生成配置 - * @returns Promise - 生成的图像结果 - */ - generateImage(prompt: string, config?: ImageGenerationConfig): Promise - - /** - * 获取默认图像生成模型 - * @returns 默认图像生成模型名称 - */ - getDefaultImageModel(): string -} diff --git a/src/main/providers/capabilities/RerankCapability.ts b/src/main/providers/capabilities/RerankCapability.ts deleted file mode 100644 index b14d2b8..0000000 --- a/src/main/providers/capabilities/RerankCapability.ts +++ /dev/null @@ -1,54 +0,0 @@ -/** - * RerankCapability 接口 - * 定义重排序能力(未来扩展) - */ - -/** - * Rerank 配置 - */ -export interface RerankConfig { - model?: string // rerank 模型名称 - topN?: number // 返回前 N 个结果 -} - -/** - * Rerank 文档 - */ -export interface RerankDocument { - id: string // 文档 ID - text: string // 文档文本 - metadata?: Record // 文档元数据 -} - -/** - * Rerank 结果 - */ -export interface RerankResult { - documentId: string // 文档 ID - score: number // 相关性分数 - index: number // 原始索引 -} - -/** - * RerankCapability 重排序能力接口 - * 实现此接口的 Provider 支持重排序功能 - * - * @remarks - * 此接口为未来扩展预留,暂未实现 - */ -export interface RerankCapability { - /** - * 重排序文档 - * @param query - 查询文本 - * @param documents - 文档列表 - * @param config - Rerank 配置 - * @returns Promise - 排序后的结果 - */ - rerank(query: string, documents: RerankDocument[], config?: RerankConfig): Promise - - /** - * 获取默认 Rerank 模型 - * @returns 默认 Rerank 模型名称 - */ - getDefaultRerankModel(): string -} diff --git a/src/main/providers/registry/ProviderDescriptor.ts b/src/main/providers/registry/ProviderDescriptor.ts deleted file mode 100644 index eb7b60c..0000000 --- a/src/main/providers/registry/ProviderDescriptor.ts +++ /dev/null @@ -1,52 +0,0 @@ -/** - * Provider Descriptor - * 供应商描述符,用于配置驱动的供应商注册 - */ - -import type { BaseProvider } from '../capabilities/BaseProvider' - -/** - * Provider 能力配置 - */ -export interface ProviderCapabilities { - chat: boolean // 是否支持对话 - embedding: boolean // 是否支持嵌入 - rerank?: boolean // 是否支持重排序(未来扩展) - imageGeneration?: boolean // 是否支持图像生成(未来扩展) -} - -/** - * Provider 描述符 - * 用于配置驱动的供应商定义 - */ -export interface ProviderDescriptor { - // 基本信息 - name: string // 供应商标识(如 'openai', 'deepseek') - displayName: string // 显示名称(如 'OpenAI', 'DeepSeek') - isBuiltin: boolean // 是否为内置供应商 - - // 默认配置 - defaultBaseUrl: string // 默认 API 地址 - defaultChatModel?: string // 默认对话模型 - defaultEmbeddingModel?: string // 默认嵌入模型 - defaultRerankModel?: string // 默认重排序模型(未来扩展) - defaultImageModel?: string // 默认图像生成模型(未来扩展) - - // 能力声明 - capabilities: ProviderCapabilities - - // Provider 工厂函数 - // 用于创建 Provider 实例 - createProvider: (descriptor: ProviderDescriptor) => BaseProvider -} - -/** - * 自定义供应商配置 - * 用户通过 UI 添加自定义供应商时使用的配置 - */ -export interface CustomProviderConfig { - providerName: string // 供应商名称 - displayName: string // 显示名称 - baseUrl: string // API 地址 - apiKey: string // API Key -} diff --git a/src/main/providers/registry/ProviderRegistry.ts b/src/main/providers/registry/ProviderRegistry.ts deleted file mode 100644 index 2854572..0000000 --- a/src/main/providers/registry/ProviderRegistry.ts +++ /dev/null @@ -1,162 +0,0 @@ -/** - * Provider Registry - * 供应商注册表,管理所有已注册的供应商描述符和实例 - */ - -import type { BaseProvider } from '../capabilities/BaseProvider' -import type { ProviderDescriptor } from './ProviderDescriptor' -import Logger from '../../../shared/utils/logger' - -/** - * ProviderRegistry - * 管理供应商描述符和实例的注册表 - */ -export class ProviderRegistry { - // 供应商描述符映射 (name -> descriptor) - private descriptors: Map = new Map() - - // 供应商实例缓存 (name -> instance) - private instances: Map = new Map() - - constructor() { - Logger.info('ProviderRegistry', 'Provider registry initialized') - } - - /** - * 注册供应商描述符 - * @param descriptor - 供应商描述符 - */ - register(descriptor: ProviderDescriptor): void { - if (this.descriptors.has(descriptor.name)) { - Logger.warn( - 'ProviderRegistry', - `Provider ${descriptor.name} already registered, will be overwritten` - ) - } - - this.descriptors.set(descriptor.name, descriptor) - // 清除旧的实例缓存(如果有) - this.instances.delete(descriptor.name) - - Logger.info('ProviderRegistry', `Registered provider: ${descriptor.name}`) - } - - /** - * 批量注册供应商描述符 - * @param descriptors - 供应商描述符数组 - */ - registerMany(descriptors: ProviderDescriptor[]): void { - descriptors.forEach((descriptor) => this.register(descriptor)) - } - - /** - * 获取供应商实例(懒加载创建) - * @param name - 供应商名称 - * @returns Provider 实例或 undefined - */ - getProvider(name: string): BaseProvider | undefined { - // 先检查缓存 - if (this.instances.has(name)) { - return this.instances.get(name) - } - - // 没有缓存,从描述符创建 - const descriptor = this.descriptors.get(name) - if (!descriptor) { - Logger.warn('ProviderRegistry', `Provider ${name} not registered`) - return undefined - } - - try { - const instance = descriptor.createProvider(descriptor) - this.instances.set(name, instance) - Logger.info('ProviderRegistry', `Created instance for provider: ${name}`) - return instance - } catch (error) { - Logger.error('ProviderRegistry', `Failed to create provider ${name}:`, error) - return undefined - } - } - - /** - * 获取供应商描述符 - * @param name - 供应商名称 - * @returns ProviderDescriptor 或 undefined - */ - getDescriptor(name: string): ProviderDescriptor | undefined { - return this.descriptors.get(name) - } - - /** - * 列出所有已注册的供应商描述符 - * @returns ProviderDescriptor 数组 - */ - listDescriptors(): ProviderDescriptor[] { - return Array.from(this.descriptors.values()) - } - - /** - * 列出所有已注册的供应商名称 - * @returns 供应商名称数组 - */ - listProviderNames(): string[] { - return Array.from(this.descriptors.keys()) - } - - /** - * 根据能力查询供应商 - * @param capability - 能力名称 ('chat' | 'embedding' | 'rerank' | 'imageGeneration') - * @returns 支持该能力的供应商描述符数组 - */ - getProvidersByCapability( - capability: 'chat' | 'embedding' | 'rerank' | 'imageGeneration' - ): ProviderDescriptor[] { - return Array.from(this.descriptors.values()).filter((descriptor) => { - return descriptor.capabilities[capability] === true - }) - } - - /** - * 获取内置供应商列表 - * @returns 内置供应商描述符数组 - */ - getBuiltinProviders(): ProviderDescriptor[] { - return Array.from(this.descriptors.values()).filter((descriptor) => descriptor.isBuiltin) - } - - /** - * 获取自定义供应商列表 - * @returns 自定义供应商描述符数组 - */ - getCustomProviders(): ProviderDescriptor[] { - return Array.from(this.descriptors.values()).filter((descriptor) => !descriptor.isBuiltin) - } - - /** - * 检查供应商是否已注册 - * @param name - 供应商名称 - * @returns 是否已注册 - */ - has(name: string): boolean { - return this.descriptors.has(name) - } - - /** - * 移除供应商 - * @param name - 供应商名称 - */ - unregister(name: string): void { - this.descriptors.delete(name) - this.instances.delete(name) - Logger.info('ProviderRegistry', `Unregistered provider: ${name}`) - } - - /** - * 清除所有实例缓存 - * 用于强制重新创建所有实例 - */ - clearInstanceCache(): void { - this.instances.clear() - Logger.info('ProviderRegistry', 'Cleared all provider instances') - } -} diff --git a/src/main/providers/registry/builtinProviders.ts b/src/main/providers/registry/builtinProviders.ts deleted file mode 100644 index 9163b04..0000000 --- a/src/main/providers/registry/builtinProviders.ts +++ /dev/null @@ -1,128 +0,0 @@ -/** - * Builtin Providers - * 内置供应商配置注册表 - */ - -import type { ProviderDescriptor } from './ProviderDescriptor' -import { AISDKProvider } from '../base/AISDKProvider' - -/** - * 内置供应商注册表 - * 所有内置供应商都在这里定义 - * - * 添加新的内置供应商只需在这个数组中添加配置即可 - */ -export const BUILTIN_PROVIDERS: ProviderDescriptor[] = [ - { - name: 'openai', - displayName: 'OpenAI', - isBuiltin: true, - defaultBaseUrl: 'https://api.openai.com/v1', - defaultChatModel: 'gpt-4o', - defaultEmbeddingModel: 'text-embedding-3-small', - capabilities: { - chat: true, - embedding: true - }, - createProvider: (descriptor) => new AISDKProvider(descriptor) - }, - - { - name: 'deepseek', - displayName: 'DeepSeek', - isBuiltin: true, - defaultBaseUrl: 'https://api.deepseek.com', - defaultChatModel: 'deepseek-chat', - defaultEmbeddingModel: 'deepseek-embedding', // 假设 DeepSeek 支持 embedding - capabilities: { - chat: true, - embedding: true - }, - createProvider: (descriptor) => new AISDKProvider(descriptor) - }, - - { - name: 'qwen', - displayName: 'Qwen', - isBuiltin: true, - defaultBaseUrl: 'https://dashscope.aliyuncs.com/compatible-mode/v1', - defaultChatModel: 'qwen-max', - defaultEmbeddingModel: 'text-embedding-v2', - capabilities: { - chat: true, - embedding: true - }, - createProvider: (descriptor) => new AISDKProvider(descriptor) - }, - - { - name: 'kimi', - displayName: 'Kimi', - isBuiltin: true, - defaultBaseUrl: 'https://api.moonshot.cn/v1', - defaultChatModel: 'kimi-k2-turbo-preview', - capabilities: { - chat: true, - embedding: false // Kimi 不支持 embedding - }, - createProvider: (descriptor) => new AISDKProvider(descriptor) - }, - - { - name: 'siliconflow', - displayName: 'SiliconFlow', - isBuiltin: true, - defaultBaseUrl: 'https://api.siliconflow.cn/v1', - defaultChatModel: 'deepseek-ai/DeepSeek-V3', - defaultEmbeddingModel: 'BAAI/bge-m3', - capabilities: { - chat: true, - embedding: true // SiliconFlow 作为模型聚合平台支持 embedding - }, - createProvider: (descriptor) => new AISDKProvider(descriptor) - }, - - { - name: 'ollama', - displayName: 'Ollama', - isBuiltin: true, - defaultBaseUrl: 'http://localhost:11434/api', - capabilities: { - chat: true, - embedding: true - }, - createProvider: (descriptor) => new AISDKProvider(descriptor) - }, - - { - name: 'zhipu', - displayName: '智谱AI', - isBuiltin: true, - defaultBaseUrl: 'https://open.bigmodel.cn/api/paas/v4', - defaultChatModel: 'glm-4-plus', - defaultEmbeddingModel: 'embedding-3', - capabilities: { - chat: true, - embedding: true - }, - createProvider: (descriptor) => new AISDKProvider(descriptor) - } -] - -/** - * 根据名称获取内置供应商描述符 - * @param name - 供应商名称 - * @returns ProviderDescriptor 或 undefined - */ -export function getBuiltinProvider(name: string): ProviderDescriptor | undefined { - return BUILTIN_PROVIDERS.find((p) => p.name === name) -} - -/** - * 检查是否为内置供应商 - * @param name - 供应商名称 - * @returns 是否为内置供应商 - */ -export function isBuiltinProvider(name: string): boolean { - return BUILTIN_PROVIDERS.some((p) => p.name === name) -} diff --git a/src/main/providers/types.ts b/src/main/providers/types.ts deleted file mode 100644 index a8de720..0000000 --- a/src/main/providers/types.ts +++ /dev/null @@ -1,97 +0,0 @@ -/** - * AI Provider 类型定义 - * 基于能力组合模式的新架构 - */ - -import type { APIMessage, StreamChunk } from '../../shared/types/chat' - -// ==================== 导出共享类型 ==================== -export type { APIMessage as ChatMessage, StreamChunk } - -// ==================== 导出新的能力接口 ==================== -export type { BaseProvider, LLMProviderConfig } from './capabilities/BaseProvider' -export type { ChatCapability } from './capabilities/ChatCapability' -export type { - EmbeddingCapability, - EmbeddingConfig, - EmbeddingResult -} from './capabilities/EmbeddingCapability' -export type { - RerankCapability, - RerankConfig, - RerankDocument, - RerankResult -} from './capabilities/RerankCapability' -export type { - ImageGenerationCapability, - ImageGenerationConfig, - ImageGenerationResult -} from './capabilities/ImageGenerationCapability' - -// ==================== Provider 组合类型 ==================== -import type { BaseProvider } from './capabilities/BaseProvider' -import type { ChatCapability } from './capabilities/ChatCapability' -import type { EmbeddingCapability } from './capabilities/EmbeddingCapability' -import type { RerankCapability } from './capabilities/RerankCapability' -import type { ImageGenerationCapability } from './capabilities/ImageGenerationCapability' - -/** - * 对话 Provider - * 支持对话功能的 Provider - */ -export type ChatProvider = BaseProvider & ChatCapability - -/** - * 嵌入 Provider - * 支持嵌入功能的 Provider - */ -export type EmbeddingProvider = BaseProvider & EmbeddingCapability - -/** - * 完整功能 Provider - * 同时支持对话和嵌入功能的 Provider - */ -export type FullFeaturedProvider = BaseProvider & ChatCapability & EmbeddingCapability - -/** - * 重排序 Provider (未来扩展) - * 支持重排序功能的 Provider - */ -export type RerankProvider = BaseProvider & RerankCapability - -/** - * 图像生成 Provider (未来扩展) - * 支持图像生成功能的 Provider - */ -export type ImageProvider = BaseProvider & ImageGenerationCapability - -/** - * 多模态 Provider (未来扩展) - * 支持多种能力的 Provider - */ -export type MultimodalProvider = BaseProvider & - ChatCapability & - EmbeddingCapability & - ImageGenerationCapability - -// ==================== 旧接口(向后兼容,已废弃) ==================== - -/** - * @deprecated 请使用新的能力组合类型: ChatProvider, EmbeddingProvider, FullFeaturedProvider - * 旧的 LLMProvider 接口,保留用于向后兼容 - */ -export interface LLMProvider { - readonly name: string - configure(config: any): void - sendMessageStream( - messages: APIMessage[], - onChunk: (chunk: StreamChunk) => void, - onError: (error: Error) => void, - onComplete: () => void - ): Promise - validateConfig?(config: any): Promise - supportsEmbedding?(): boolean - createEmbedding?(text: string, config?: any): Promise - createEmbeddings?(texts: string[], config?: any): Promise - getDefaultEmbeddingModel?(): string -} diff --git a/src/main/services/AnkiCardService.ts b/src/main/services/AnkiCardService.ts index 8cb316e..6617c50 100644 --- a/src/main/services/AnkiCardService.ts +++ b/src/main/services/AnkiCardService.ts @@ -12,8 +12,7 @@ import type { AnkiGenerationOptions } from '../../shared/types/anki' import { eq, desc, and, inArray } from 'drizzle-orm' -import { ProviderManager } from '../providers/ProviderManager' -import { AISDKProvider } from '../providers/base/AISDKProvider' +import { ConnectionManager } from '../models/ConnectionManager' import { streamObject } from 'ai' import { z } from 'zod' import Logger from '../../shared/utils/logger' @@ -115,7 +114,7 @@ function createAnkiSchema(cardCount: number) { * Anki卡片服务 */ export class AnkiCardService { - constructor(private providerManager: ProviderManager) {} + constructor(private connectionManager: ConnectionManager) {} /** * 聚合笔记本内容 @@ -177,17 +176,12 @@ export class AnkiCardService { options?: AnkiGenerationOptions, onProgress?: AnkiProgressCallback ): Promise { - const provider = await this.providerManager.getActiveChatProvider() - if (!provider) { - throw new Error('没有可用的对话模型,请先配置LLM提供商') + const client = await this.connectionManager.getChatClient() + if (!client) { + throw new Error('没有可用的对话模型,请先在设置中配置模型连接') } - // 确保 provider 是 AISDKProvider 实例 - if (!(provider instanceof AISDKProvider)) { - throw new Error('当前 provider 不支持结构化输出') - } - - Logger.info('AnkiCardService', `Using provider: ${provider.name}`) + Logger.info('AnkiCardService', `Using model: ${client.label}`) // 获取基础提示词 const promptTemplate = await getAnkiPrompt(options?.customPrompt) @@ -205,7 +199,7 @@ export class AnkiCardService { onProgress?.('generating_cards', 30) // 获取 AI SDK 模型实例 - const model = provider.getAIModel() + const model = client.getAIModel() // 创建动态Schema const AnkiSchema = createAnkiSchema(cardCount) @@ -412,9 +406,9 @@ export class AnkiCardService { const generationTime = Date.now() - startTime - // 捕获当前 provider 名称,避免在更新时再次异步调用 - const activeProvider = await this.providerManager.getActiveChatProvider() - const providerName = activeProvider?.name || 'unknown' + // 捕获当前模型 ID,避免在更新时再次异步调用 + const activeProvider = await this.connectionManager.getChatClient() + const providerName = activeProvider?.modelId || 'unknown' db.update(ankiCards) .set({ diff --git a/src/main/services/EmbeddingService.ts b/src/main/services/EmbeddingService.ts index bd23f12..d27a9e2 100644 --- a/src/main/services/EmbeddingService.ts +++ b/src/main/services/EmbeddingService.ts @@ -3,8 +3,8 @@ * 封装向量生成逻辑,支持批处理和错误重试 */ -import { ProviderManager } from '../providers/ProviderManager' -import type { EmbeddingResult, EmbeddingConfig } from '../providers/types' +import { ConnectionManager } from '../models/ConnectionManager' +import type { EmbeddingResult, EmbeddingConfig } from '../models/types' import Logger from '../../shared/utils/logger' /** @@ -22,11 +22,11 @@ export interface EmbeddingServiceConfig { * 封装向量生成逻辑,支持批处理、错误重试、速率控制 */ export class EmbeddingService { - private providerManager: ProviderManager + private connectionManager: ConnectionManager private config: Required - constructor(providerManager: ProviderManager, config?: EmbeddingServiceConfig) { - this.providerManager = providerManager + constructor(connectionManager: ConnectionManager, config?: EmbeddingServiceConfig) { + this.connectionManager = connectionManager this.config = { batchSize: config?.batchSize ?? 20, maxRetries: config?.maxRetries ?? 3, @@ -39,13 +39,13 @@ export class EmbeddingService { * 生成单个文本的嵌入向量 */ async embed(text: string, config?: EmbeddingConfig): Promise { - const provider = await this.providerManager.getActiveEmbeddingProvider() + const client = await this.connectionManager.getEmbeddingClient() - if (!provider) { - throw new Error('No embedding provider available') + if (!client) { + throw new Error('No embedding model configured') } - return await this.withRetry(() => provider.createEmbedding(text, config)) + return await this.withRetry(() => client.createEmbedding(text, config)) } /** @@ -61,10 +61,10 @@ export class EmbeddingService { return [] } - const provider = await this.providerManager.getActiveEmbeddingProvider() + const client = await this.connectionManager.getEmbeddingClient() - if (!provider) { - throw new Error('No embedding provider available') + if (!client) { + throw new Error('No embedding model configured') } const results: EmbeddingResult[] = [] @@ -76,7 +76,7 @@ export class EmbeddingService { const batch = batches[i] try { - const batchResults = await this.withRetry(() => provider.createEmbeddings(batch, config)) + const batchResults = await this.withRetry(() => client.createEmbeddings(batch, config)) results.push(...batchResults) if (onProgress) { @@ -99,19 +99,19 @@ export class EmbeddingService { } /** - * 获取当前 Embedding Provider 的默认 Embedding 模型 + * 获取当前 Embedding Connection 的模型 ID */ async getDefaultModel(): Promise { - const provider = await this.providerManager.getActiveEmbeddingProvider() - return provider?.getDefaultEmbeddingModel() + const client = await this.connectionManager.getEmbeddingClient() + return client?.modelId } /** - * 检查当前 Embedding Provider 是否支持 Embedding + * 检查是否配置了 Embedding Connection */ async isAvailable(): Promise { - const provider = await this.providerManager.getActiveEmbeddingProvider() - return provider !== null + const client = await this.connectionManager.getEmbeddingClient() + return client !== null } /** diff --git a/src/main/services/FileParserService.ts b/src/main/services/FileParserService.ts index fe68286..e658823 100644 --- a/src/main/services/FileParserService.ts +++ b/src/main/services/FileParserService.ts @@ -16,15 +16,7 @@ import type { IDocumentLoader, DocumentLoadResult, LoadOptions } from './loaders * 支持的文件类型 */ export type SupportedFileType = - | 'pdf' - | 'docx' - | 'doc' - | 'pptx' - | 'ppt' - | 'txt' - | 'md' - | 'markdown' - | 'text' + 'pdf' | 'docx' | 'doc' | 'pptx' | 'ppt' | 'txt' | 'md' | 'markdown' | 'text' /** * 文件解析服务 diff --git a/src/main/services/KnowledgeService.ts b/src/main/services/KnowledgeService.ts index 5cef6a0..ef70d39 100644 --- a/src/main/services/KnowledgeService.ts +++ b/src/main/services/KnowledgeService.ts @@ -16,7 +16,7 @@ import { ChunkingService, type ChunkOptions } from './ChunkingService' import { FileParserService } from './FileParserService' import { WebFetchService } from './WebFetchService' import { vectorStoreManager } from '../vectorstore' -import { ProviderManager } from '../providers/ProviderManager' +import { ConnectionManager } from '../models/ConnectionManager' import Logger from '../../shared/utils/logger' /** @@ -73,8 +73,8 @@ export class KnowledgeService { private webFetchService: WebFetchService private knowledgeFilesDir: string - constructor(providerManager: ProviderManager) { - this.embeddingService = new EmbeddingService(providerManager) + constructor(connectionManager: ConnectionManager) { + this.embeddingService = new EmbeddingService(connectionManager) this.chunkingService = new ChunkingService() this.fileParserService = new FileParserService() this.webFetchService = new WebFetchService() diff --git a/src/main/services/MindMapService.ts b/src/main/services/MindMapService.ts index db2e1d6..6b3da69 100644 --- a/src/main/services/MindMapService.ts +++ b/src/main/services/MindMapService.ts @@ -8,9 +8,8 @@ import { mindMaps, documents, chunks, notebooks, items } from '../db/schema' import type { MindMap, NewMindMap } from '../db/schema' import type { MindMapTreeNode, MindMapGenerationResult } from '../../shared/types/mindmap' import { eq, desc, and, inArray } from 'drizzle-orm' -import { ProviderManager } from '../providers/ProviderManager' +import { ConnectionManager } from '../models/ConnectionManager' // import { KnowledgeService } from './KnowledgeService' -import { AISDKProvider } from '../providers/base/AISDKProvider' import { streamObject } from 'ai' import { z } from 'zod' import Logger from '../../shared/utils/logger' @@ -70,7 +69,7 @@ async function getMindMapPrompt(): Promise { */ export class MindMapService { constructor( - private providerManager: ProviderManager + private connectionManager: ConnectionManager // Reserved for future use // private _knowledgeService: KnowledgeService @@ -131,17 +130,12 @@ export class MindMapService { content: string, onProgress?: MindMapProgressCallback ): Promise { - const provider = await this.providerManager.getActiveChatProvider() - if (!provider) { - throw new Error('没有可用的对话模型,请先配置LLM提供商') + const client = await this.connectionManager.getChatClient() + if (!client) { + throw new Error('没有可用的对话模型,请先在设置中配置模型连接') } - // 确保 provider 是 AISDKProvider 实例 - if (!(provider instanceof AISDKProvider)) { - throw new Error('当前 provider 不支持结构化输出') - } - - Logger.info('MindMapService', `Using provider: ${provider.name}`) + Logger.info('MindMapService', `Using model: ${client.label}`) const promptTemplate = await getMindMapPrompt() const prompt = promptTemplate.replace('{{CONTENT}}', content) @@ -150,7 +144,7 @@ export class MindMapService { onProgress?.('generating_mindmap', 30) // 获取 AI SDK 模型实例 - const model = provider.getAIModel() + const model = client.getAIModel() // 使用 streamObject 生成结构化数据 const { partialObjectStream, object } = streamObject({ @@ -364,7 +358,7 @@ export class MindMapService { treeData: validatedResult.rootNode as any, chunkMapping: validatedResult.chunkMapping as any, metadata: { - model: (await this.providerManager.getActiveChatProvider())?.name || 'unknown', + model: (await this.connectionManager.getChatClient())?.modelId || 'unknown', totalNodes: validatedResult.metadata.totalNodes, maxDepth: validatedResult.metadata.maxDepth, generationTime diff --git a/src/main/services/QuizService.ts b/src/main/services/QuizService.ts index 9c03ab8..477ceb7 100644 --- a/src/main/services/QuizService.ts +++ b/src/main/services/QuizService.ts @@ -8,8 +8,7 @@ import { quizzes, documents, chunks, notebooks, items, quizSessions } from '../d import type { Quiz, NewQuiz, QuizSession, NewQuizSession } from '../db/schema' import type { QuizQuestion, QuizGenerationResult } from '../../shared/types/quiz' import { eq, desc, and, inArray } from 'drizzle-orm' -import { ProviderManager } from '../providers/ProviderManager' -import { AISDKProvider } from '../providers/base/AISDKProvider' +import { ConnectionManager } from '../models/ConnectionManager' import { streamObject } from 'ai' import { z } from 'zod' import Logger from '../../shared/utils/logger' @@ -115,7 +114,7 @@ function createQuizSchema(questionCount: number) { * 答题服务 */ export class QuizService { - constructor(private providerManager: ProviderManager) {} + constructor(private connectionManager: ConnectionManager) {} /** * 聚合笔记本内容 @@ -177,17 +176,12 @@ export class QuizService { options?: QuizGenerationOptions, onProgress?: QuizProgressCallback ): Promise { - const provider = await this.providerManager.getActiveChatProvider() - if (!provider) { - throw new Error('没有可用的对话模型,请先配置LLM提供商') + const client = await this.connectionManager.getChatClient() + if (!client) { + throw new Error('没有可用的对话模型,请先在设置中配置模型连接') } - // 确保 provider 是 AISDKProvider 实例 - if (!(provider instanceof AISDKProvider)) { - throw new Error('当前 provider 不支持结构化输出') - } - - Logger.info('QuizService', `Using provider: ${provider.name}`) + Logger.info('QuizService', `Using model: ${client.label}`) // 获取基础提示词 const promptTemplate = await getQuizPrompt(options?.customPrompt) @@ -214,7 +208,7 @@ export class QuizService { onProgress?.('generating_quiz', 30) // 获取 AI SDK 模型实例 - const model = provider.getAIModel() + const model = client.getAIModel() // 创建动态Schema const QuizSchema = createQuizSchema(questionCount) @@ -416,7 +410,7 @@ export class QuizService { questionsData: validatedResult.questions as any, chunkMapping: chunkMapping as any, metadata: { - model: (await this.providerManager.getActiveChatProvider())?.name || 'unknown', + model: (await this.connectionManager.getChatClient())?.modelId || 'unknown', totalQuestions: validatedResult.metadata.totalQuestions, generationTime } as any, @@ -485,7 +479,7 @@ export class QuizService { throw new Error('题库不存在') } - const questions = quiz.questionsData as any as QuizQuestion[] + const questions = quiz.questionsData as QuizQuestion[] // 计算分数 let correctCount = 0 diff --git a/src/main/services/SessionAutoSwitchService.ts b/src/main/services/SessionAutoSwitchService.ts index 93776d5..7f15a5e 100644 --- a/src/main/services/SessionAutoSwitchService.ts +++ b/src/main/services/SessionAutoSwitchService.ts @@ -1,5 +1,5 @@ import * as queries from '../db/queries' -import { ProviderManager } from '../providers/ProviderManager' +import { ConnectionManager } from '../models/ConnectionManager' import Logger from '../../shared/utils/logger' /** @@ -10,10 +10,10 @@ export class SessionAutoSwitchService { // Token threshold: 80% of GPT-4 context window (128k tokens) private static readonly TOKEN_THRESHOLD = 100000 - private providerManager: ProviderManager + private connectionManager: ConnectionManager - constructor(providerManager: ProviderManager) { - this.providerManager = providerManager + constructor(connectionManager: ConnectionManager) { + this.connectionManager = connectionManager } /** @@ -99,16 +99,16 @@ ${conversationText} Please provide summary:` // Call AI to generate summary - const provider = await this.providerManager.getActiveChatProvider() - if (!provider) { - // If no provider configured, return a basic summary + const client = await this.connectionManager.getChatClient() + if (!client) { + // If no model connection configured, return a basic summary return `This conversation contains ${messages.length} messages.` } return new Promise((resolve) => { let summaryContent = '' - provider.sendMessageStream( + client.sendMessageStream( [ { role: 'user', diff --git a/src/preload/index.d.ts b/src/preload/index.d.ts index 42e539a..5407055 100644 --- a/src/preload/index.d.ts +++ b/src/preload/index.d.ts @@ -1,6 +1,14 @@ import { ElectronAPI } from '@electron-toolkit/preload' import type { ChatSession, ChatMessage } from '../shared/types/chat' -import type { Notebook, Note, ProviderConfig, AppSettings } from '../shared/types' +import type { + Notebook, + Note, + AppSettings, + ConnectionMap, + ConnectionTestResult, + ModelCapability, + ModelConnection +} from '../shared/types' import type { KnowledgeDocument, KnowledgeChunk, @@ -19,8 +27,11 @@ export type { ChatMessage, Notebook, Note, - ProviderConfig, AppSettings, + ConnectionMap, + ConnectionTestResult, + ModelCapability, + ModelConnection, MindMap, Quiz, QuizSession, @@ -200,35 +211,18 @@ declare global { callback: (data: { oldSessionId: string; newSessionId: string }) => void ) => () => void - // Provider 配置相关 - saveProviderConfig: (config: ProviderConfig) => Promise - getProviderConfig: (providerName: string) => Promise - getAllProviderConfigs: () => Promise - deleteProviderConfig: (providerName: string) => Promise - validateProviderConfig: (providerName: string, config: any) => Promise - fetchModels: ( - providerName: string, - apiKey: string - ) => Promise< - | { - models: { - id: string - object: string - owned_by?: string - created?: number - type?: string - }[] - source: 'merged' | 'builtin' - builtinCount?: number - remoteCount?: number - error?: string - } - | { id: string; object: string; owned_by?: string; created?: number }[] - > - getProviderModels: ( - providerName: string - ) => Promise<{ id: string; object: string; owned_by?: string; created?: number }[]> - onProviderConfigChanged: (callback: () => void) => () => void + // Model Connection 相关 + connections: { + getAll: () => Promise + getProtocols: () => Promise< + { protocol: string; supportsModelListing: boolean; supportsEmbedding: boolean }[] + > + save: (capability: ModelCapability, connection: ModelConnection) => Promise + remove: (capability: ModelCapability) => Promise + test: (connection: ModelConnection) => Promise + fetchModels: (connection: ModelConnection) => Promise + onChanged: (callback: () => void) => () => void + } // 知识库相关 knowledge: { diff --git a/src/preload/index.ts b/src/preload/index.ts index 630a1ea..ba0581d 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -121,25 +121,23 @@ const api = { return () => ipcRenderer.removeListener('session-auto-switched', listener) }, - // Provider 配置相关 - saveProviderConfig: (config: any) => invokeWithTimeout('save-provider-config', 5000, config), - getProviderConfig: (providerName: string) => - ipcRenderer.invoke('get-provider-config', { providerName }), - getAllProviderConfigs: () => ipcRenderer.invoke('get-all-provider-configs'), - deleteProviderConfig: (providerName: string) => - ipcRenderer.invoke('delete-provider-config', { providerName }), - validateProviderConfig: (providerName: string, config: any) => - invokeWithTimeout('validate-provider-config', 15000, { providerName, config }), // 15秒超时(网络验证) - fetchModels: (providerName: string, apiKey: string) => - invokeWithTimeout('fetch-models', 15000, { providerName, apiKey }), // 15秒超时(网络请求) - getProviderModels: (providerName: string) => - ipcRenderer.invoke('get-provider-models', { providerName }), + // Model Connection 相关 + connections: { + getAll: () => ipcRenderer.invoke('get-connections'), + getProtocols: () => ipcRenderer.invoke('get-connection-protocols'), + save: (capability: string, connection: any) => + invokeWithTimeout('save-connection', 5000, { capability, connection }), + remove: (capability: string) => ipcRenderer.invoke('delete-connection', { capability }), + test: (connection: any) => invokeWithTimeout('test-connection', 15000, { connection }), // 15秒超时(网络验证) + fetchModels: (connection: any) => + invokeWithTimeout('fetch-connection-models', 15000, { connection }), // 15秒超时(网络请求) - // Provider 配置变更监听 - onProviderConfigChanged: (callback: () => void) => { - const listener = () => callback() - ipcRenderer.on('provider-config-changed', listener) - return () => ipcRenderer.removeListener('provider-config-changed', listener) + // 连接配置变更监听 + onChanged: (callback: () => void) => { + const listener = () => callback() + ipcRenderer.on('connections-changed', listener) + return () => ipcRenderer.removeListener('connections-changed', listener) + } }, // Knowledge 知识库相关 diff --git a/src/renderer/src/components/common/RenameDialog.tsx b/src/renderer/src/components/common/RenameDialog.tsx index 813647a..9eb543f 100644 --- a/src/renderer/src/components/common/RenameDialog.tsx +++ b/src/renderer/src/components/common/RenameDialog.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect, ReactElement } from 'react' +import { useState, ReactElement } from 'react' import { useTranslation } from 'react-i18next' import { Dialog, @@ -29,9 +29,12 @@ export default function RenameDialog({ const { t } = useTranslation(['common', 'notebook']) const [title, setTitle] = useState(currentTitle) - useEffect(() => { + // 当外部标题变化时重置输入框(React 推荐的 render 期间调整模式,避免 effect 级联渲染) + const [syncedTitle, setSyncedTitle] = useState(currentTitle) + if (syncedTitle !== currentTitle) { + setSyncedTitle(currentTitle) setTitle(currentTitle) - }, [currentTitle]) + } const handleSubmit = (e: React.FormEvent): void => { e.preventDefault() diff --git a/src/renderer/src/components/layouts/ResizableLayout.tsx b/src/renderer/src/components/layouts/ResizableLayout.tsx index 7cd0306..9ca3afe 100644 --- a/src/renderer/src/components/layouts/ResizableLayout.tsx +++ b/src/renderer/src/components/layouts/ResizableLayout.tsx @@ -194,7 +194,6 @@ export default function ResizableLayout({ : undefined } > - {/* eslint-disable-next-line react-hooks/refs */} {React.cloneElement(centerPanel as ReactElement, centerPanelProps as any)} diff --git a/src/renderer/src/components/notebook/ProcessPanel.tsx b/src/renderer/src/components/notebook/ProcessPanel.tsx index 3cd180d..03d606f 100644 --- a/src/renderer/src/components/notebook/ProcessPanel.tsx +++ b/src/renderer/src/components/notebook/ProcessPanel.tsx @@ -32,8 +32,7 @@ export default function ProcessPanel({ }: ProcessPanelProps = {}): ReactElement { const { t } = useTranslation('ui') const [input, setInput] = useState('') - const [hasProvider, setHasProvider] = useState(true) - const [defaultChatModel, setDefaultChatModel] = useState(null) + const [hasChatModel, setHasChatModel] = useState(false) const { currentSession, messages, isNotebookStreaming, sendMessage, abortMessage } = useChatStore() const { currentNotebook, updateNotebook } = useNotebookStore() @@ -46,24 +45,17 @@ export default function ProcessPanel({ const isCurrentNotebookStreaming = currentNotebookId ? isNotebookStreaming(currentNotebookId) : false - const canSend = - currentSession && !isCurrentNotebookStreaming && input.trim() && hasProvider && defaultChatModel + const canSend = currentSession && !isCurrentNotebookStreaming && input.trim() && hasChatModel const canStop = currentSession && isCurrentNotebookStreaming - // Check if there are available providers and get default model - const checkProvider = async (): Promise => { + // Check whether a chat model connection is configured + const checkChatModel = async (): Promise => { try { - const providers = await window.api.getAllProviderConfigs() - const hasEnabledProvider = providers.some((p) => p.enabled) - setHasProvider(hasEnabledProvider) - - // Get default chat model - const defaultModel = await window.api.settings.get('defaultChatModel') - setDefaultChatModel(defaultModel || null) + const connections = await window.api.connections.getAll() + setHasChatModel(Boolean(connections.chat)) } catch (error) { - console.error('Failed to check Provider configuration:', error) - setHasProvider(false) - setDefaultChatModel(null) + console.error('Failed to check model connection:', error) + setHasChatModel(false) } } @@ -71,22 +63,14 @@ export default function ProcessPanel({ useEffect(() => { // 1. Check on page load // eslint-disable-next-line react-hooks/set-state-in-effect - void checkProvider() - - // 2. Listen for Provider configuration change events - const cleanup = window.api.onProviderConfigChanged(() => { - void checkProvider() - }) + void checkChatModel() - // 3. Listen for settings change events - const cleanupSettings = window.api.settings.onSettingsChange((newSettings) => { - setDefaultChatModel(newSettings.defaultChatModel || null) + // 2. Listen for connection configuration change events + const cleanup = window.api.connections.onChanged(() => { + void checkChatModel() }) - return () => { - cleanup() - cleanupSettings() - } + return cleanup }, []) const handleSend = (): void => { @@ -321,15 +305,11 @@ export default function ProcessPanel({ placeholder={ !currentSession ? t('selectSession') - : !hasProvider + : !hasChatModel ? t('noProviderConfigured') - : !defaultChatModel - ? t('noDefaultModel') - : t('inputMessage') - } - disabled={ - !currentSession || isCurrentNotebookStreaming || !hasProvider || !defaultChatModel + : t('inputMessage') } + disabled={!currentSession || isCurrentNotebookStreaming || !hasChatModel} rows={1} className="w-full bg-transparent border-0 pl-4 pr-14 py-3 text-sm text-foreground placeholder-muted-foreground resize-none focus-visible:ring-0 focus-visible:ring-offset-0 overflow-y-auto min-h-[84px] max-h-[280px] themed-scrollbar select-text" /> @@ -353,7 +333,11 @@ export default function ProcessPanel({ onClick={handleSend} disabled={!canSend} title={ - !hasProvider ? t('noProviderConfigured') : !currentSession ? t('selectSession') : '' + !hasChatModel + ? t('noProviderConfigured') + : !currentSession + ? t('selectSession') + : '' } size="icon" className="absolute right-2 bottom-3 w-8 h-8 rounded-full" diff --git a/src/renderer/src/components/notebook/SourcePanel.tsx b/src/renderer/src/components/notebook/SourcePanel.tsx index 24e627b..490a86e 100644 --- a/src/renderer/src/components/notebook/SourcePanel.tsx +++ b/src/renderer/src/components/notebook/SourcePanel.tsx @@ -232,7 +232,7 @@ export default function SourcePanel(): ReactElement { const { id: notebookId } = useParams() const [showAddMenu, setShowAddMenu] = useState(false) const [modalType, setModalType] = useState(null) - const [defaultEmbeddingModel, setDefaultEmbeddingModel] = useState(undefined) + const [hasEmbeddingModel, setHasEmbeddingModel] = useState(false) const [selectedDocument, setSelectedDocument] = useState(null) const { @@ -253,17 +253,17 @@ export default function SourcePanel(): ReactElement { const { openSettings } = useUIStore() - // 加载默认嵌入模型设置 + // 检查是否配置了 embedding connection useEffect(() => { - const loadEmbeddingModel = async () => { - const model = await window.api.settings.get('defaultEmbeddingModel') - setDefaultEmbeddingModel(model) + const loadEmbeddingConnection = async () => { + const connections = await window.api.connections.getAll() + setHasEmbeddingModel(Boolean(connections.embedding)) } - loadEmbeddingModel() + void loadEmbeddingConnection() - // 监听设置变化 - const unsubscribe = window.api.settings.onSettingsChange((newSettings) => { - setDefaultEmbeddingModel(newSettings.defaultEmbeddingModel) + // 监听连接配置变化 + const unsubscribe = window.api.connections.onChanged(() => { + void loadEmbeddingConnection() }) return unsubscribe @@ -285,17 +285,19 @@ export default function SourcePanel(): ReactElement { // eslint-disable-next-line react-hooks/exhaustive-deps }, [notebookId]) - // 当 notebook 切换时清空选中的文档 - useEffect(() => { + // 当 notebook 切换时清空选中的文档(render 期间调整,避免 effect 级联渲染) + const [selectionNotebookId, setSelectionNotebookId] = useState(notebookId) + if (selectionNotebookId !== notebookId) { + setSelectionNotebookId(notebookId) setSelectedDocument(null) - }, [notebookId]) + } // 处理文件上传 const handleFileUpload = useCallback(async () => { if (!notebookId) return // 检查是否配置了默认嵌入模型 - if (!defaultEmbeddingModel) { + if (!hasEmbeddingModel) { alert(t('noEmbeddingModelConfigured')) return } @@ -305,7 +307,7 @@ export default function SourcePanel(): ReactElement { await addDocumentFromFile(notebookId, filePath) } setShowAddMenu(false) - }, [notebookId, defaultEmbeddingModel, selectFiles, addDocumentFromFile, t]) + }, [notebookId, hasEmbeddingModel, selectFiles, addDocumentFromFile, t]) // 处理 URL 导入 const handleUrlImport = useCallback( @@ -313,7 +315,7 @@ export default function SourcePanel(): ReactElement { if (!notebookId || !data.url) return // 检查是否配置了默认嵌入模型 - if (!defaultEmbeddingModel) { + if (!hasEmbeddingModel) { alert(t('noEmbeddingModelConfigured')) setModalType(null) return @@ -322,7 +324,7 @@ export default function SourcePanel(): ReactElement { await addDocumentFromUrl(notebookId, data.url) setModalType(null) }, - [notebookId, defaultEmbeddingModel, addDocumentFromUrl, t] + [notebookId, hasEmbeddingModel, addDocumentFromUrl, t] ) // 处理文本粘贴 @@ -331,7 +333,7 @@ export default function SourcePanel(): ReactElement { if (!notebookId || !data.title || !data.content) return // 检查是否配置了默认嵌入模型 - if (!defaultEmbeddingModel) { + if (!hasEmbeddingModel) { alert(t('noEmbeddingModelConfigured')) setModalType(null) return @@ -344,7 +346,7 @@ export default function SourcePanel(): ReactElement { }) setModalType(null) }, - [notebookId, defaultEmbeddingModel, addDocument, t] + [notebookId, hasEmbeddingModel, addDocument, t] ) // 处理笔记导入 @@ -353,7 +355,7 @@ export default function SourcePanel(): ReactElement { if (!notebookId || !data.noteId) return // 检查是否配置了默认嵌入模型 - if (!defaultEmbeddingModel) { + if (!hasEmbeddingModel) { alert(t('noEmbeddingModelConfigured')) setModalType(null) return @@ -373,7 +375,7 @@ export default function SourcePanel(): ReactElement { setModalType(null) } }, - [notebookId, defaultEmbeddingModel, addNoteToKnowledge, t] + [notebookId, hasEmbeddingModel, addNoteToKnowledge, t] ) // 处理删除文档 @@ -458,11 +460,11 @@ export default function SourcePanel(): ReactElement { > @@ -529,7 +531,7 @@ export default function SourcePanel(): ReactElement { { + // 当外部 items 改变时,更新本地状态(render 期间调整,避免 effect 级联渲染) + const [syncedItems, setSyncedItems] = useState(items) + if (syncedItems !== items) { + setSyncedItems(items) setLocalItems(items) - }, [items]) + } if (items.length === 0) { return ( diff --git a/src/renderer/src/components/notebook/quiz/QuizResultView.tsx b/src/renderer/src/components/notebook/quiz/QuizResultView.tsx index 89f85b3..c42b785 100644 --- a/src/renderer/src/components/notebook/quiz/QuizResultView.tsx +++ b/src/renderer/src/components/notebook/quiz/QuizResultView.tsx @@ -1,4 +1,4 @@ -import { useEffect } from 'react' +import { useEffect, useRef } from 'react' import { useTranslation } from 'react-i18next' import { useQuizStore } from '../../../store/quizStore' import { Button } from '../../ui/button' @@ -17,16 +17,14 @@ export default function QuizResultView() { } = useQuizStore() // 提交答题会话(保存到数据库) - const handleSubmit = async () => { - await submitQuiz() - } - - // 首次进入结果页自动提交 + // 首次进入结果页自动提交,且只提交一次 + const hasSubmittedRef = useRef(false) useEffect(() => { - if (currentQuiz) { - handleSubmit() + if (currentQuiz && !hasSubmittedRef.current) { + hasSubmittedRef.current = true + void submitQuiz() } - }, []) + }, [currentQuiz, submitQuiz]) if (!currentQuiz) return null diff --git a/src/renderer/src/components/settings/AddProviderDialog.tsx b/src/renderer/src/components/settings/AddProviderDialog.tsx deleted file mode 100644 index d716baa..0000000 --- a/src/renderer/src/components/settings/AddProviderDialog.tsx +++ /dev/null @@ -1,200 +0,0 @@ -import { useState, useEffect, useRef, ReactElement } from 'react' -import { Eye, EyeOff } from 'lucide-react' -import { useTranslation } from 'react-i18next' -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle -} from '../ui/dialog' -import { Button } from '../ui/button' -import { Input } from '../ui/input' - -interface AddProviderDialogProps { - isOpen: boolean - onClose: () => void - onConfirm: (data: { providerName: string; apiKey: string; baseUrl: string }) => void - existingProviders: string[] -} - -export default function AddProviderDialog({ - isOpen, - onClose, - onConfirm, - existingProviders -}: AddProviderDialogProps): ReactElement { - const { t } = useTranslation('settings') - const [providerName, setProviderName] = useState('') - const [apiKey, setApiKey] = useState('') - const [baseUrl, setBaseUrl] = useState('') - const [showApiKey, setShowApiKey] = useState(false) - const [errors, setErrors] = useState({ - providerName: '', - apiKey: '', - baseUrl: '' - }) - const inputRef = useRef(null) - - // 自动聚焦到第一个输入框 - useEffect(() => { - if (isOpen && inputRef.current) { - setTimeout(() => { - inputRef.current?.focus() - }, 50) - } - }, [isOpen]) - - // URL 验证函数 - const isValidUrl = (url: string): boolean => { - try { - const urlObj = new URL(url) - // 检查协议 - if (urlObj.protocol !== 'http:' && urlObj.protocol !== 'https:') { - return false - } - // 检查是否包含非 ASCII 字符 - // eslint-disable-next-line no-control-regex - if (/[^\x00-\x7F]/.test(url)) { - return false - } - return true - } catch { - return false - } - } - - // 表单验证 - const validateForm = (): boolean => { - const newErrors = { - providerName: '', - apiKey: '', - baseUrl: '' - } - - // 供应商名称验证 - const trimmedName = providerName.trim() - if (!trimmedName) { - newErrors.providerName = t('providerNameRequired') - } else if (existingProviders.includes(trimmedName.toLowerCase())) { - newErrors.providerName = t('providerNameExists') - } - - // API Key 验证 - if (!apiKey.trim()) { - newErrors.apiKey = t('apiKeyRequired') - } - - // Base URL 验证 - const trimmedUrl = baseUrl.trim() - if (!trimmedUrl) { - newErrors.baseUrl = t('apiUrlRequired') - } else if (!isValidUrl(trimmedUrl)) { - newErrors.baseUrl = t('apiUrlInvalid') - } - - setErrors(newErrors) - return !Object.values(newErrors).some((error) => error !== '') - } - - // 提交处理 - const handleSubmit = (e: React.FormEvent): void => { - e.preventDefault() - if (validateForm()) { - onConfirm({ - providerName: providerName.trim().toLowerCase(), - apiKey: apiKey.trim(), - baseUrl: baseUrl.trim() - }) - handleClose() - } - } - - // 关闭时重置表单 - const handleClose = (): void => { - setProviderName('') - setApiKey('') - setBaseUrl('') - setShowApiKey(false) - setErrors({ - providerName: '', - apiKey: '', - baseUrl: '' - }) - onClose() - } - - return ( - - - - {t('addCustomProvider')} - {t('addProviderDesc')} - - -
- {/* 供应商名称 */} -
- - setProviderName(e.target.value)} - placeholder={t('providerNamePlaceholder')} - /> - {errors.providerName && ( -

{errors.providerName}

- )} -
- - {/* API Key */} -
- -
- setApiKey(e.target.value)} - placeholder="sk-..." - className="pr-12" - /> - -
- {errors.apiKey &&

{errors.apiKey}

} -
- - {/* API 地址 */} -
- - setBaseUrl(e.target.value)} - placeholder="https://api.example.com/v1" - /> - {errors.baseUrl &&

{errors.baseUrl}

} -
- - - - - -
-
-
- ) -} diff --git a/src/renderer/src/components/settings/DeleteProviderDialog.tsx b/src/renderer/src/components/settings/DeleteProviderDialog.tsx deleted file mode 100644 index fafe0c1..0000000 --- a/src/renderer/src/components/settings/DeleteProviderDialog.tsx +++ /dev/null @@ -1,54 +0,0 @@ -import { ReactElement } from 'react' -import { useTranslation } from 'react-i18next' -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle -} from '../ui/dialog' -import { Button } from '../ui/button' - -interface DeleteProviderDialogProps { - isOpen: boolean - providerName: string - onClose: () => void - onConfirm: () => void -} - -export default function DeleteProviderDialog({ - isOpen, - providerName, - onClose, - onConfirm -}: DeleteProviderDialogProps): ReactElement { - const { t } = useTranslation('settings') - - const handleConfirm = (): void => { - onConfirm() - onClose() - } - - return ( - - - - {t('deleteProvider')} - - {t('deleteProviderConfirm', { name: providerName })} - - - - - - - - - - ) -} diff --git a/src/renderer/src/components/settings/GeneralSettings.tsx b/src/renderer/src/components/settings/GeneralSettings.tsx index 72f0014..45ac9b9 100644 --- a/src/renderer/src/components/settings/GeneralSettings.tsx +++ b/src/renderer/src/components/settings/GeneralSettings.tsx @@ -1,4 +1,4 @@ -import { ReactElement, useEffect, useMemo } from 'react' +import { ReactElement, useEffect } from 'react' import { Sun, Moon } from 'lucide-react' import { useTranslation } from 'react-i18next' import { useI18nStore } from '../../store/i18nStore' @@ -14,17 +14,9 @@ import { import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../ui/select' import { Button } from '../ui/button' -interface ProviderConfig { - providerName: string - config: Record - enabled: boolean - updatedAt: number -} - interface GeneralSettingsProps { settings: AppSettings onSettingsChange: (updates: Partial) => void - providers: ProviderConfig[] } const languages = [ @@ -34,73 +26,11 @@ const languages = [ export default function GeneralSettings({ settings, - onSettingsChange, - providers + onSettingsChange }: GeneralSettingsProps): ReactElement { const { t } = useTranslation('settings') const { changeLanguage } = useI18nStore() - // 分别获取对话模型和嵌入模型 - const { availableChatModels, availableEmbeddingModels } = useMemo(() => { - const chatModels: Array<{ id: string; provider: string; label: string }> = [] - const embeddingModels: Array<{ id: string; provider: string; label: string }> = [] - - providers.forEach((provider) => { - if (provider.enabled && provider.config.models && Array.isArray(provider.config.models)) { - // 从 modelDetails 获取完整的模型信息(包含 type 字段) - const modelDetails = provider.config.modelDetails || [] - - provider.config.models.forEach((modelId: string) => { - const modelDetail = modelDetails.find((m: any) => m.id === modelId) - const modelObj = { - id: `${provider.providerName}:${modelId}`, - provider: provider.providerName, - label: modelId - } - - // 根据类型分类 - if (modelDetail?.type === 'embedding') { - embeddingModels.push(modelObj) - } else if (modelDetail?.type === 'chat' || !modelDetail?.type) { - chatModels.push(modelObj) - } - }) - } - }) - - return { availableChatModels: chatModels, availableEmbeddingModels: embeddingModels } - }, [providers]) - - // 检查默认模型是否仍然可用,如果不可用则清空 - useEffect(() => { - if (providers.length === 0) { - return - } - - const isChatModelAvailable = settings.defaultChatModel - ? availableChatModels.some((model) => model.id === settings.defaultChatModel) - : true - - if (!isChatModelAvailable && settings.defaultChatModel) { - onSettingsChange({ defaultChatModel: '' }) - } - - const isEmbeddingModelAvailable = settings.defaultEmbeddingModel - ? availableEmbeddingModels.some((model) => model.id === settings.defaultEmbeddingModel) - : true - - if (!isEmbeddingModelAvailable && settings.defaultEmbeddingModel) { - onSettingsChange({ defaultEmbeddingModel: '' }) - } - }, [ - providers, - settings.defaultChatModel, - settings.defaultEmbeddingModel, - availableChatModels, - availableEmbeddingModels, - onSettingsChange - ]) - // 当主题变化时,立即更新 DOM 以预览效果 useEffect(() => { if (settings.theme === 'dark') { @@ -170,79 +100,6 @@ export default function GeneralSettings({ - - {/* 默认对话模型设置 */} - - - {t('defaultChatModel')} - - {availableChatModels.length > 0 ? t('defaultChatModelDesc') : t('noAvailableModel')} - - - {availableChatModels.length > 0 && ( - - )} - - - {/* 默认嵌入模型设置 */} - - - {t('defaultEmbeddingModel')} - - {availableEmbeddingModels.length > 0 - ? t('defaultEmbeddingModelDesc') - : t('noAvailableModel')} - - - {availableEmbeddingModels.length > 0 && ( - - )} - ) diff --git a/src/renderer/src/components/settings/ModelConnectionForm.tsx b/src/renderer/src/components/settings/ModelConnectionForm.tsx new file mode 100644 index 0000000..8ebc74c --- /dev/null +++ b/src/renderer/src/components/settings/ModelConnectionForm.tsx @@ -0,0 +1,316 @@ +import { ReactElement, useState } from 'react' +import { Eye, EyeOff, Download, Loader2, Trash2, CheckCircle2, XCircle } from 'lucide-react' +import { useTranslation } from 'react-i18next' +import type { APIProtocol, ModelCapability, ModelConnection } from '../../../../shared/types' +import { Button } from '../ui/button' +import { Input } from '../ui/input' +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../ui/select' + +export interface ProtocolInfo { + protocol: string + supportsModelListing: boolean + supportsEmbedding: boolean +} + +interface ModelConnectionFormProps { + capability: ModelCapability + connection: ModelConnection | undefined + protocols: ProtocolInfo[] + onChange: (connection: ModelConnection) => void + onClear: () => void +} + +interface QuickSetup { + id: string + labelKey: string + protocol: APIProtocol + baseUrl: string + apiKey?: string +} + +/** + * Quick setup 只是自动填表,不是 Provider 实现。 + */ +const QUICK_SETUPS: QuickSetup[] = [ + { + id: 'openai', + labelKey: 'presetOpenAI', + protocol: 'openai-completions', + baseUrl: 'https://api.openai.com/v1' + }, + { + id: 'ollama', + labelKey: 'presetOllama', + protocol: 'openai-completions', + baseUrl: 'http://localhost:11434/v1', + apiKey: 'ollama' + }, + { + id: 'openrouter', + labelKey: 'presetOpenRouter', + protocol: 'openai-completions', + baseUrl: 'https://openrouter.ai/api/v1' + } +] + +const EMPTY_CONNECTION: ModelConnection = { + protocol: 'openai-completions', + baseUrl: '', + apiKey: '', + modelId: '' +} + +export default function ModelConnectionForm({ + capability, + connection, + protocols, + onChange, + onClear +}: ModelConnectionFormProps): ReactElement { + const { t } = useTranslation('settings') + const [showApiKey, setShowApiKey] = useState(false) + const [testing, setTesting] = useState(false) + const [testResult, setTestResult] = useState<{ ok: boolean; error?: string } | null>(null) + const [fetching, setFetching] = useState(false) + const [fetchError, setFetchError] = useState('') + const [modelOptions, setModelOptions] = useState([]) + + const value = connection ?? EMPTY_CONNECTION + const availableProtocols = + capability === 'embedding' ? protocols.filter((p) => p.supportsEmbedding) : protocols + const protocolInfo = protocols.find((p) => p.protocol === value.protocol) + const canListModels = protocolInfo?.supportsModelListing ?? false + + const update = (patch: Partial): void => { + setTestResult(null) + onChange({ ...value, ...patch }) + } + + const applyQuickSetup = (presetId: string): void => { + const preset = QUICK_SETUPS.find((p) => p.id === presetId) + if (!preset) return + if ( + capability === 'embedding' && + !protocols.find((p) => p.protocol === preset.protocol)?.supportsEmbedding + ) { + return + } + update({ + protocol: preset.protocol, + baseUrl: preset.baseUrl, + apiKey: preset.apiKey ?? value.apiKey + }) + } + + const handleTest = async (): Promise => { + setTesting(true) + setTestResult(null) + try { + const result = await window.api.connections.test(value) + setTestResult(result) + } catch (error) { + setTestResult({ ok: false, error: (error as Error).message }) + } finally { + setTesting(false) + } + } + + const handleFetchModels = async (): Promise => { + setFetching(true) + setFetchError('') + try { + const models = await window.api.connections.fetchModels(value) + setModelOptions(models) + if (models.length === 0) { + setFetchError(t('fetchModelsEmpty')) + } + } catch (error) { + setFetchError((error as Error).message) + } finally { + setFetching(false) + } + } + + const listId = `model-options-${capability}` + + return ( +
+
+
+

+ {capability === 'chat' ? t('chatModel') : t('embeddingModel')} +

+

+ {capability === 'chat' ? t('chatModelDesc') : t('embeddingModelDesc')} +

+
+ {connection && ( + + )} +
+ + {/* Quick setup */} +
+ +
+ {QUICK_SETUPS.filter( + (preset) => + capability === 'chat' || + protocols.find((p) => p.protocol === preset.protocol)?.supportsEmbedding + ).map((preset) => ( + + ))} + +
+
+ + {/* Protocol */} +
+ + +
+ + {/* Base URL */} +
+ + update({ baseUrl: e.target.value })} + placeholder="https://api.example.com/v1" + /> +
+ + {/* API Key */} +
+ +
+ update({ apiKey: e.target.value })} + placeholder="sk-..." + className="pr-12" + /> + +
+
+ + {/* Model ID */} +
+
+ + +
+ update({ modelId: e.target.value })} + placeholder={t('modelIdPlaceholder')} + /> + + {modelOptions.map((modelId) => ( + + {!canListModels && ( +

{t('fetchModelsUnsupported')}

+ )} + {fetchError &&

{fetchError}

} +
+ + {/* Test connection */} +
+ + {testResult?.ok && ( + + + {t('connectionSuccessful')} + + )} + {testResult && !testResult.ok && ( + + + {testResult.error || t('connectionFailed')} + + )} +
+
+ ) +} diff --git a/src/renderer/src/components/settings/ModelsSettings.tsx b/src/renderer/src/components/settings/ModelsSettings.tsx new file mode 100644 index 0000000..4fe1504 --- /dev/null +++ b/src/renderer/src/components/settings/ModelsSettings.tsx @@ -0,0 +1,56 @@ +import { ReactElement, useEffect, useState } from 'react' +import type { ConnectionMap, ModelCapability } from '../../../../shared/types' +import ModelConnectionForm, { type ProtocolInfo } from './ModelConnectionForm' + +interface ModelsSettingsProps { + connections: ConnectionMap + onConnectionsChange: (connections: ConnectionMap) => void +} + +export default function ModelsSettings({ + connections, + onConnectionsChange +}: ModelsSettingsProps): ReactElement { + const [protocols, setProtocols] = useState([]) + + useEffect(() => { + const loadProtocols = async (): Promise => { + const infos = await window.api.connections.getProtocols() + setProtocols(infos) + } + void loadProtocols() + }, []) + + const setConnection = ( + capability: ModelCapability, + connection: ConnectionMap[ModelCapability] + ) => { + onConnectionsChange({ ...connections, [capability]: connection }) + } + + const clearConnection = (capability: ModelCapability): void => { + const next: ConnectionMap = { ...connections } + delete next[capability] + onConnectionsChange(next) + } + + return ( +
+ setConnection('chat', connection)} + onClear={() => clearConnection('chat')} + /> +
+ setConnection('embedding', connection)} + onClear={() => clearConnection('embedding')} + /> +
+ ) +} diff --git a/src/renderer/src/components/settings/ProviderConfigPanel.tsx b/src/renderer/src/components/settings/ProviderConfigPanel.tsx deleted file mode 100644 index 3353776..0000000 --- a/src/renderer/src/components/settings/ProviderConfigPanel.tsx +++ /dev/null @@ -1,306 +0,0 @@ -import { ReactElement, useState, useMemo } from 'react' -import { Search, Eye, EyeOff, ExternalLink, Download, Loader2, Trash2 } from 'lucide-react' -import { useTranslation } from 'react-i18next' -import { type Model, ModelType } from '../../../../shared/types' -import { categorizeModels } from '../../../../shared/utils/modelClassifier' -import { Button } from '../ui/button' -import { Input } from '../ui/input' -import { Switch } from '../ui/switch' -import { Checkbox } from '../ui/checkbox' - -interface ProviderConfig { - providerName: string - config: Record - enabled: boolean - updatedAt: number -} - -interface ProviderConfigPanelProps { - displayName: string - description: string - platformUrl: string - provider: ProviderConfig - models: Model[] - isFetching: boolean - onConfigChange: (config: Record) => void - onEnabledChange: (enabled: boolean) => void - onFetchModels: () => void - onDelete?: () => void // 可选:删除供应商回调(仅自定义供应商) - defaultBaseUrl?: string // 可选:默认 Base URL(用于恢复默认) -} - -export default function ProviderConfigPanel({ - displayName, - description, - platformUrl, - provider, - models, - isFetching, - onConfigChange, - onEnabledChange, - onFetchModels, - onDelete, - defaultBaseUrl -}: ProviderConfigPanelProps): ReactElement { - const { t } = useTranslation('settings') - const [showApiKey, setShowApiKey] = useState(false) - const [modelSearchQuery, setModelSearchQuery] = useState('') - const [showCategory, setShowCategory] = useState<'all' | 'chat' | 'embedding' | 'other'>('all') - const [apiUrlError, setApiUrlError] = useState('') - - const handleApiKeyChange = (apiKey: string) => { - onConfigChange({ ...provider.config, apiKey }) - } - - const handleBaseUrlChange = (baseUrl: string) => { - // 检查是否包含非 ASCII 字符 - // eslint-disable-next-line no-control-regex - if (baseUrl && /[^\x00-\x7F]/.test(baseUrl)) { - setApiUrlError(t('apiUrlInvalid')) - } else { - setApiUrlError('') - } - onConfigChange({ ...provider.config, baseUrl }) - } - - const handleResetBaseUrl = () => { - if (defaultBaseUrl) { - handleBaseUrlChange(defaultBaseUrl) - } - } - - const handleModelToggle = (modelId: string, checked: boolean) => { - const currentModels = provider.config.models || [] - const newModels = checked - ? [...currentModels, modelId] - : currentModels.filter((m: string) => m !== modelId) - onConfigChange({ ...provider.config, models: newModels }) - } - - // 对模型进行分类 - const categorized = useMemo(() => categorizeModels(models || []), [models]) - - // 根据选中的分类和搜索条件过滤模型 - const filteredModels = useMemo(() => { - let modelsToShow: Model[] = [] - - if (showCategory === 'all') { - modelsToShow = models || [] - } else if (showCategory === 'chat') { - modelsToShow = categorized.chat - } else if (showCategory === 'embedding') { - modelsToShow = categorized.embedding - } else if (showCategory === 'other') { - modelsToShow = [...categorized.reranker, ...categorized.other] - } - - // 应用搜索过滤 - return modelsToShow.filter((model) => - model.id.toLowerCase().includes(modelSearchQuery.toLowerCase()) - ) - }, [models, categorized, showCategory, modelSearchQuery]) - - return ( -
- {/* 顶部标题和开关 */} -
-
-

{displayName}

- {provider.enabled && ( - - {t('active')} - - )} -
-
- {onDelete && ( - - )} - -
-
- - {/* 描述 */} -

{description}

- - {/* API Key */} -
-

{t('apiKey')}

-
- handleApiKeyChange(e.target.value)} - placeholder="sk-..." - className="pr-12" - /> - -
-
- {t('getApiKeyFrom')} - - {displayName} {t('platform')} - - -
-
- - {/* API URL - 所有供应商都显示 */} -
-
-

{t('apiUrl')}

- {defaultBaseUrl && provider.config.baseUrl !== defaultBaseUrl && ( - - )} -
- handleBaseUrlChange(e.target.value)} - placeholder="https://api.example.com/v1" - className={apiUrlError ? 'border-destructive' : ''} - /> - {apiUrlError ? ( -

{apiUrlError}

- ) : ( -

{t('apiUrlHint')}

- )} -
- - {/* Models */} -
-
-

{t('models')}

- -
- - {models && models.length > 0 && ( - <> - {/* 分类标签 */} -
- - - - {(categorized.reranker.length > 0 || categorized.other.length > 0) && ( - - )} -
- -
- - setModelSearchQuery(e.target.value)} - className="pl-9" - /> -
- -
-

- {showCategory === 'all' - ? t('totalModels', { count: models.length }) - : t('showingModels', { count: filteredModels.length, total: models.length })} -

- {filteredModels.map((model) => ( - - ))} -
- - )} -
-
- ) -} diff --git a/src/renderer/src/components/settings/ProvidersSettings.tsx b/src/renderer/src/components/settings/ProvidersSettings.tsx deleted file mode 100644 index a982d0b..0000000 --- a/src/renderer/src/components/settings/ProvidersSettings.tsx +++ /dev/null @@ -1,651 +0,0 @@ -import { useState, useEffect, ReactElement } from 'react' -import { Search } from 'lucide-react' -import { useTranslation } from 'react-i18next' -import ProviderConfigPanel from './ProviderConfigPanel' -import AddProviderDialog from './AddProviderDialog' -import DeleteProviderDialog from './DeleteProviderDialog' -import { ScrollArea } from '../ui/scroll-area' -import { Input } from '../ui/input' -import { Button } from '../ui/button' - -interface ProviderConfig { - providerName: string - config: Record - enabled: boolean - updatedAt: number -} - -interface ProvidersSettingsProps { - providers: ProviderConfig[] - onProvidersChange: (updatedProviders: ProviderConfig[]) => void - onRefresh: () => Promise -} - -interface Model { - id: string - object: string - owned_by?: string - created?: number -} - -export default function ProvidersSettings({ - providers, - onProvidersChange, - onRefresh -}: ProvidersSettingsProps): ReactElement { - const { t } = useTranslation('settings') - const [activeProvider, setActiveProvider] = useState('deepseek') - const [searchQuery, setSearchQuery] = useState('') - const [models, setModels] = useState>({}) - const [fetchingModels, setFetchingModels] = useState>({}) - const [isAddDialogOpen, setIsAddDialogOpen] = useState(false) - const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false) - const [providerToDelete, setProviderToDelete] = useState('') - - // 定义每个提供商的默认 baseUrl - const defaultBaseUrls: Record = { - openai: 'https://api.openai.com/v1', - deepseek: 'https://api.deepseek.com', - siliconflow: 'https://api.siliconflow.cn/v1', - qwen: 'https://dashscope.aliyuncs.com/compatible-mode/v1', - kimi: 'https://api.moonshot.cn/v1', - ollama: 'http://localhost:11434/api', - zhipu: 'https://open.bigmodel.cn/api/paas/v4' - } - - // 加载已缓存的模型列表 - useEffect(() => { - const loadCachedModels = async () => { - const providerList = ['deepseek', 'openai', 'siliconflow', 'qwen', 'kimi', 'ollama', 'zhipu'] - const loadedModels: Record = {} - - for (const providerName of providerList) { - try { - const cachedModels = await window.api.getProviderModels(providerName) - loadedModels[providerName] = cachedModels - - // 同步到 provider.config.modelDetails,以便 GeneralSettings 可以读取模型类型 - const provider = providers.find((p) => p.providerName === providerName) - if (provider && cachedModels.length > 0) { - // 检查是否已经有 modelDetails,避免重复更新 - if (!provider.config.modelDetails || provider.config.modelDetails.length === 0) { - console.log( - `[Sync] Syncing ${cachedModels.length} cached models to ${providerName}.config.modelDetails` - ) - updateProviderConfig(providerName, { - config: { - ...provider.config, - modelDetails: cachedModels - } - }) - } - } - } catch (error) { - console.error(`Failed to load cached models for ${providerName}:`, error) - } - } - - setModels(loadedModels) - } - - loadCachedModels() - }, []) // eslint-disable-line react-hooks/exhaustive-deps - - // Get specific provider configuration - const getProviderConfig = (providerName: string) => { - const existingProvider = providers.find((p) => p.providerName === providerName) - - if (existingProvider) { - // 如果提供商存在,但没有设置 baseUrl,则添加默认值 - return { - ...existingProvider, - config: { - ...existingProvider.config, - baseUrl: existingProvider.config.baseUrl || defaultBaseUrls[providerName] || '' - } - } - } - - // 如果提供商不存在,创建一个带有默认配置的对象 - return { - providerName, - config: { - baseUrl: defaultBaseUrls[providerName] || '', - apiKey: '', - models: [], - displayName: providerName - }, - enabled: false, - updatedAt: 0 - } - } - - // Update provider configuration - const updateProviderConfig = ( - providerName: string, - updates: Partial> - ) => { - const updatedProviders = [...providers] - const index = updatedProviders.findIndex((p) => p.providerName === providerName) - - if (index !== -1) { - updatedProviders[index] = { - ...updatedProviders[index], - ...updates, - config: updates.config !== undefined ? updates.config : updatedProviders[index].config, - updatedAt: Date.now() - } - } else { - // If doesn't exist, create new configuration - updatedProviders.push({ - providerName, - config: updates.config || {}, - enabled: updates.enabled || false, - updatedAt: Date.now() - }) - } - - onProvidersChange(updatedProviders) - } - - // Fetch model list - const fetchModels = async (providerName: string) => { - const provider = getProviderConfig(providerName) - const apiKey = provider.config.apiKey - - // 检查是否有API Key - if (!apiKey) { - alert(t('enterApiKey')) - return - } - - setFetchingModels((prev) => ({ ...prev, [providerName]: true })) - - try { - const result = await window.api.fetchModels(providerName, apiKey) - - // 类型守卫:检查是否是新格式(包含 models 和 source 字段) - const isNewFormat = ( - res: any - ): res is { - models: Model[] - source: 'merged' | 'builtin' - builtinCount?: number - remoteCount?: number - error?: string - } => { - return res && typeof res === 'object' && 'models' in res && 'source' in res - } - - if (isNewFormat(result)) { - // 新格式:包含 models 和 source - const modelList = result.models - setModels((prev) => ({ ...prev, [providerName]: modelList })) - - // 保存完整的模型信息到 config - updateProviderConfig(providerName, { - config: { - ...provider.config, - modelDetails: modelList - } - }) - - // 根据来源显示不同的提示 - if (result.source === 'merged') { - console.log( - `[Models Updated] ${providerName}: 已智能合并 ${result.builtinCount} 个内置模型和 ${result.remoteCount} 个远程模型` - ) - alert( - `✅ 模型列表已更新 (智能合并)\n\n内置模型: ${result.builtinCount} 个\n远程模型: ${result.remoteCount} 个\n合并后: ${modelList.length} 个\n\n策略: 远程信息 + 内置元数据` - ) - } else if (result.source === 'builtin') { - console.warn(`[Models Fallback] ${providerName}: 网络请求失败,使用内置模型`) - alert( - `⚠️ 网络请求失败\n已加载 ${modelList.length} 个内置模型\n\n错误: ${result.error || '未知'}` - ) - } - } else { - // 旧格式:直接返回 Model[](向后兼容) - const modelList = result - setModels((prev) => ({ ...prev, [providerName]: modelList })) - - updateProviderConfig(providerName, { - config: { - ...provider.config, - modelDetails: modelList - } - }) - - console.log(`[Models Fetched] ${providerName}: ${modelList.length} 个模型`) - } - } catch (error) { - console.error('Failed to fetch model list:', error) - alert(`${t('fetchModelFailed')}${error instanceof Error ? error.message : 'Unknown error'}`) - } finally { - setFetchingModels((prev) => ({ ...prev, [providerName]: false })) - } - } - - const openaiProvider = getProviderConfig('openai') - const deepseekProvider = getProviderConfig('deepseek') - const siliconflowProvider = getProviderConfig('siliconflow') - const qwenProvider = getProviderConfig('qwen') - const kimiProvider = getProviderConfig('kimi') - const ollamaProvider = getProviderConfig('ollama') - const zhipuProvider = getProviderConfig('zhipu') - - const providerList = [ - { - id: 'deepseek', - name: t('deepseekName'), - description: t('deepseekDesc'), - platformUrl: 'https://platform.deepseek.com', - enabled: deepseekProvider.enabled - }, - { - id: 'openai', - name: t('openaiName'), - description: t('openaiDesc'), - platformUrl: 'https://platform.openai.com', - enabled: openaiProvider.enabled - }, - { - id: 'siliconflow', - name: t('siliconflowName'), - description: t('siliconflowDesc'), - platformUrl: 'https://siliconflow.cn', - enabled: siliconflowProvider.enabled - }, - { - id: 'qwen', - name: t('qwenName'), - description: t('qwenDesc'), - platformUrl: 'https://dashscope.aliyun.com', - enabled: qwenProvider.enabled - }, - { - id: 'kimi', - name: t('kimiName'), - description: t('kimiDesc'), - platformUrl: 'https://platform.moonshot.cn', - enabled: kimiProvider.enabled - }, - { - id: 'ollama', - name: 'Ollama', - description: 'Local LLM runner', - platformUrl: 'https://ollama.com', - enabled: ollamaProvider.enabled - }, - { - id: 'zhipu', - name: t('zhipuName'), - description: t('zhipuDesc'), - platformUrl: 'https://open.bigmodel.cn', - enabled: zhipuProvider.enabled - } - ] - - // 获取自定义供应商列表 - const getCustomProviders = () => { - const builtInProviders = [ - 'deepseek', - 'openai', - 'siliconflow', - 'qwen', - 'kimi', - 'ollama', - 'zhipu' - ] - return providers - .filter((p) => !builtInProviders.includes(p.providerName)) - .map((p) => ({ - id: p.providerName, - name: p.config.displayName || p.providerName, - enabled: p.enabled - })) - } - - const customProviders = getCustomProviders() - - // 处理添加供应商 - const handleAddProvider = async (data: { - providerName: string - apiKey: string - baseUrl: string - }) => { - const newProvider: ProviderConfig = { - providerName: data.providerName, - config: { - apiKey: data.apiKey, - baseUrl: data.baseUrl, - displayName: data.providerName, - models: [] - }, - enabled: false, - updatedAt: Date.now() - } - - // 立即保存到后端 - await window.api.saveProviderConfig(newProvider) - - // 刷新状态(同步 original 和 pending) - await onRefresh() - - // 切换到新供应商 - setActiveProvider(data.providerName) - } - - // 打开删除确认对话框 - const handleDeleteClick = (providerName: string) => { - setProviderToDelete(providerName) - setIsDeleteDialogOpen(true) - } - - // 确认删除供应商 - const handleDeleteConfirm = async () => { - // 立即从后端删除 - await window.api.deleteProviderConfig(providerToDelete) - - // 刷新状态(同步 original 和 pending) - await onRefresh() - - // 如果删除的是当前选中的供应商,切换到 deepseek - if (activeProvider === providerToDelete) { - setActiveProvider('deepseek') - } - - setProviderToDelete('') - } - - // 自定义供应商模型获取(使用后端 API 避免 CORS 问题) - const fetchCustomProviderModels = async (providerName: string) => { - const provider = getProviderConfig(providerName) - const apiKey = provider.config.apiKey - const baseUrl = provider.config.baseUrl - - if (!apiKey) { - alert(t('enterApiKey')) - return - } - - if (!baseUrl) { - alert(t('enterApiKeyAndUrl')) - return - } - - // 检查 URL 是否包含非 ASCII 字符 - // eslint-disable-next-line no-control-regex - if (/[^\x00-\x7F]/.test(baseUrl)) { - alert(t('apiUrlInvalid')) - return - } - - setFetchingModels((prev) => ({ ...prev, [providerName]: true })) - - try { - // 先将当前配置保存到后端,确保后端能读取到 baseUrl - await window.api.saveProviderConfig({ - providerName, - config: provider.config, - enabled: provider.enabled, - updatedAt: Date.now() - }) - - // 使用后端 API,后端会从配置中读取 baseUrl - const result = await window.api.fetchModels(providerName, apiKey) - - // 类型守卫:检查是否是新格式 - const isNewFormat = ( - res: any - ): res is { - models: Model[] - source: 'merged' | 'builtin' - builtinCount?: number - remoteCount?: number - error?: string - } => { - return res && typeof res === 'object' && 'models' in res && 'source' in res - } - - const modelList = isNewFormat(result) ? result.models : result - setModels((prev) => ({ ...prev, [providerName]: modelList })) - - // 保存完整的模型信息到 config - updateProviderConfig(providerName, { - config: { - ...provider.config, - modelDetails: modelList - } - }) - } catch (error) { - console.error('Failed to fetch model list:', error) - alert(`${t('fetchModelFailed')}${error instanceof Error ? error.message : 'Unknown error'}`) - } finally { - setFetchingModels((prev) => ({ ...prev, [providerName]: false })) - } - } - - const filteredProviders = providerList.filter((provider) => - provider.name.toLowerCase().includes(searchQuery.toLowerCase()) - ) - - return ( -
- {/* 左侧供应商列表 */} -
- {/* 搜索框 */} -
- - setSearchQuery(e.target.value)} - className="pl-9" - /> -
- - {/* 供应商列表 */} -
- {/* 内置供应商 */} - {filteredProviders.map((provider) => ( - - ))} - - {/* 分隔线 */} - {customProviders.length > 0 &&
} - - {/* 自定义供应商 */} - {customProviders - .filter((p) => p.name.toLowerCase().includes(searchQuery.toLowerCase())) - .map((provider) => ( - - ))} -
- - {/* 添加自定义提供商按钮 */} - -
- - {/* 右侧配置区域 */} - - {activeProvider === 'deepseek' && ( - updateProviderConfig('deepseek', { config })} - onEnabledChange={(enabled) => updateProviderConfig('deepseek', { enabled })} - onFetchModels={() => fetchModels('deepseek')} - defaultBaseUrl={defaultBaseUrls.deepseek} - /> - )} - - {activeProvider === 'openai' && ( - updateProviderConfig('openai', { config })} - onEnabledChange={(enabled) => updateProviderConfig('openai', { enabled })} - onFetchModels={() => fetchModels('openai')} - defaultBaseUrl={defaultBaseUrls.openai} - /> - )} - - {activeProvider === 'siliconflow' && ( - updateProviderConfig('siliconflow', { config })} - onEnabledChange={(enabled) => updateProviderConfig('siliconflow', { enabled })} - onFetchModels={() => fetchModels('siliconflow')} - defaultBaseUrl={defaultBaseUrls.siliconflow} - /> - )} - - {activeProvider === 'qwen' && ( - updateProviderConfig('qwen', { config })} - onEnabledChange={(enabled) => updateProviderConfig('qwen', { enabled })} - onFetchModels={() => fetchModels('qwen')} - defaultBaseUrl={defaultBaseUrls.qwen} - /> - )} - - {activeProvider === 'kimi' && ( - updateProviderConfig('kimi', { config })} - onEnabledChange={(enabled) => updateProviderConfig('kimi', { enabled })} - onFetchModels={() => fetchModels('kimi')} - defaultBaseUrl={defaultBaseUrls.kimi} - /> - )} - - {activeProvider === 'ollama' && ( - updateProviderConfig('ollama', { config })} - onEnabledChange={(enabled) => updateProviderConfig('ollama', { enabled })} - onFetchModels={() => fetchModels('ollama')} - defaultBaseUrl={defaultBaseUrls.ollama} - /> - )} - - {activeProvider === 'zhipu' && ( - updateProviderConfig('zhipu', { config })} - onEnabledChange={(enabled) => updateProviderConfig('zhipu', { enabled })} - onFetchModels={() => fetchModels('zhipu')} - defaultBaseUrl={defaultBaseUrls.zhipu} - /> - )} - - {/* 自定义供应商配置 */} - {!['deepseek', 'openai', 'siliconflow', 'qwen', 'kimi', 'ollama', 'zhipu'].includes( - activeProvider - ) && - (() => { - const customProvider = getProviderConfig(activeProvider) - return customProvider && customProvider.providerName ? ( - updateProviderConfig(activeProvider, { config })} - onEnabledChange={(enabled) => updateProviderConfig(activeProvider, { enabled })} - onFetchModels={() => fetchCustomProviderModels(activeProvider)} - onDelete={() => handleDeleteClick(activeProvider)} - /> - ) : null - })()} - - - {/* 添加供应商对话框 */} - setIsAddDialogOpen(false)} - onConfirm={handleAddProvider} - existingProviders={[...providerList.map((p) => p.id), ...customProviders.map((p) => p.id)]} - /> - - {/* 删除供应商对话框 */} - setIsDeleteDialogOpen(false)} - onConfirm={handleDeleteConfirm} - /> -
- ) -} diff --git a/src/renderer/src/components/settings/SettingsDialog.tsx b/src/renderer/src/components/settings/SettingsDialog.tsx index c59acae..30ed686 100644 --- a/src/renderer/src/components/settings/SettingsDialog.tsx +++ b/src/renderer/src/components/settings/SettingsDialog.tsx @@ -3,7 +3,7 @@ import { useState, useEffect, useMemo, ReactElement } from 'react' import { useTranslation } from 'react-i18next' import GeneralSettings from './GeneralSettings' import { useUIStore } from '../../store/uiStore' -import ProvidersSettings from './ProvidersSettings' +import ModelsSettings from './ModelsSettings' import PromptsSettings from './PromptsSettings' import ShortcutSettings from './ShortcutSettings' import AboutSettings from './AboutSettings' @@ -17,14 +17,8 @@ import { SidebarMenuButton, SidebarProvider } from '../ui/sidebar' -import type { AppSettings } from '../../../../shared/types' - -interface ProviderConfig { - providerName: string - config: Record - enabled: boolean - updatedAt: number -} +import type { AppSettings, ConnectionMap } from '../../../../shared/types' +import { MODEL_CAPABILITIES } from '../../../../shared/types' export default function SettingsDialog(): ReactElement { const { t } = useTranslation('settings') @@ -32,8 +26,8 @@ export default function SettingsDialog(): ReactElement { const [activeSection, setActiveSection] = useState('general') const [originalSettings, setOriginalSettings] = useState(null) const [pendingSettings, setPendingSettings] = useState(null) - const [originalProviders, setOriginalProviders] = useState([]) - const [pendingProviders, setPendingProviders] = useState([]) + const [originalConnections, setOriginalConnections] = useState({}) + const [pendingConnections, setPendingConnections] = useState({}) const handleOpenChange = (open: boolean) => { if (!open) { @@ -48,23 +42,23 @@ export default function SettingsDialog(): ReactElement { setOriginalSettings(settings) setPendingSettings(settings) - const providers = await window.api.getAllProviderConfigs() - setOriginalProviders(providers) - setPendingProviders(providers) + const connections = await window.api.connections.getAll() + setOriginalConnections(connections) + setPendingConnections(connections) } loadSettings() }, []) // 使用 useMemo 计算是否有变化 const hasChanges = useMemo(() => { - if (originalSettings && pendingSettings && originalProviders && pendingProviders) { - const settingsChanged = JSON.stringify(originalSettings) !== JSON.stringify(pendingSettings) - const providersChanged = - JSON.stringify(originalProviders) !== JSON.stringify(pendingProviders) - return settingsChanged || providersChanged + if (!originalSettings || !pendingSettings) { + return false } - return false - }, [originalSettings, pendingSettings, originalProviders, pendingProviders]) + const settingsChanged = JSON.stringify(originalSettings) !== JSON.stringify(pendingSettings) + const connectionsChanged = + JSON.stringify(originalConnections) !== JSON.stringify(pendingConnections) + return settingsChanged || connectionsChanged + }, [originalSettings, pendingSettings, originalConnections, pendingConnections]) const menuItems = [ { @@ -75,11 +69,11 @@ export default function SettingsDialog(): ReactElement { description: t('generalSettingsDesc') }, { - id: 'provider', + id: 'models', icon: Database, - label: t('aiProviders'), - title: t('aiProviders'), - description: t('aiProvidersDesc') + label: t('modelConnections'), + title: t('modelConnections'), + description: t('modelConnectionsDesc') }, { id: 'prompts', @@ -111,31 +105,29 @@ export default function SettingsDialog(): ReactElement { } } - // 更新临时提供商配置 - const updatePendingProviders = (updatedProviders: ProviderConfig[]) => { - setPendingProviders(updatedProviders) - } - - // 刷新提供商配置(用于新增/删除后同步状态) - const refreshProviders = async () => { - const providers = await window.api.getAllProviderConfigs() - setOriginalProviders(providers) - setPendingProviders(providers) - } - // 确认保存 const handleConfirm = async () => { - // 保存通用设置 if (pendingSettings) { await window.api.settings.update(pendingSettings) setOriginalSettings(pendingSettings) } - // 保存提供商配置(新增/删除已经立即保存,这里只保存修改) - for (const provider of pendingProviders) { - await window.api.saveProviderConfig(provider) + // 保存或删除每个能力的连接 + for (const capability of MODEL_CAPABILITIES) { + const connection = pendingConnections[capability] + if (connection && connection.baseUrl && connection.modelId) { + await window.api.connections.save(capability, connection) + } else { + const wasConfigured = Boolean(originalConnections[capability]) + if (wasConfigured) { + await window.api.connections.remove(capability) + } + } } - setOriginalProviders(pendingProviders) + + const saved = await window.api.connections.getAll() + setOriginalConnections(saved) + setPendingConnections(saved) } // 取消变更 @@ -143,9 +135,7 @@ export default function SettingsDialog(): ReactElement { if (originalSettings) { setPendingSettings(originalSettings) } - if (originalProviders) { - setPendingProviders(originalProviders) - } + setPendingConnections(originalConnections) } return ( @@ -202,14 +192,12 @@ export default function SettingsDialog(): ReactElement { )} - {activeSection === 'provider' && ( - )} {pendingSettings && activeSection === 'prompts' && ( diff --git a/src/renderer/src/hooks/use-mobile.tsx b/src/renderer/src/hooks/use-mobile.tsx index 4331d5c..ce6074e 100644 --- a/src/renderer/src/hooks/use-mobile.tsx +++ b/src/renderer/src/hooks/use-mobile.tsx @@ -2,18 +2,19 @@ import * as React from 'react' const MOBILE_BREAKPOINT = 768 -export function useIsMobile() { - const [isMobile, setIsMobile] = React.useState(undefined) +export function useIsMobile(): boolean { + const [isMobile, setIsMobile] = React.useState( + () => window.innerWidth < MOBILE_BREAKPOINT + ) React.useEffect(() => { const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`) - const onChange = () => { + const onChange = (): void => { setIsMobile(window.innerWidth < MOBILE_BREAKPOINT) } mql.addEventListener('change', onChange) - setIsMobile(window.innerWidth < MOBILE_BREAKPOINT) return () => mql.removeEventListener('change', onChange) }, []) - return !!isMobile + return isMobile } diff --git a/src/renderer/src/locales/en-US/settings.json b/src/renderer/src/locales/en-US/settings.json index c06e215..dab8cc8 100644 --- a/src/renderer/src/locales/en-US/settings.json +++ b/src/renderer/src/locales/en-US/settings.json @@ -1,128 +1,40 @@ { - "title": "Settings", - "general": "General", - "aiProvider": "AI Provider", "about": "About", "language": "Language", "theme": "Theme", - "appearance": "Appearance", - "modelSettings": "Model Settings", "apiKey": "API Key", - "apiKeyPlaceholder": "Please enter your API key", "baseUrl": "Base URL", - "baseUrlPlaceholder": "Please enter base URL", - "model": "Model", - "selectModel": "Select Model", - "testConnection": "Test Connection", - "connectionSuccessful": "Connection Successful", - "connectionFailed": "Connection Failed", - "saveSettings": "Save Settings", - "resetSettings": "Reset Settings", - "confirmReset": "Are you sure you want to reset all settings?", - "autoSave": "Auto Save", - "fontSize": "Font Size", - "fontFamily": "Font Family", - "lineHeight": "Line Height", - "backup": "Backup", - "restore": "Restore", - "exportData": "Export Data", - "importData": "Import Data", - "storageLocation": "Storage Location", - "cacheSize": "Cache Size", - "clearCache": "Clear Cache", - "logs": "Logs", - "openLogFolder": "Open Log Folder", - "reportIssue": "Report Issue", + "testConnection": "Test connection", + "connectionSuccessful": "Connection successful", + "connectionFailed": "Connection failed", "checkUpdates": "Check Updates", - "currentVersion": "Current Version", - "latestVersion": "Latest Version", - "upToDate": "Up to Date", - "updateAvailable": "Update Available", - "downloadUpdate": "Download Update", + "latestVersion": "Latest version", + "updateAvailable": "New version {{version}} available", "shortcuts": "Shortcuts", - "resetShortcuts": "Reset Shortcuts", - "experimental": "Experimental Features", - "advanced": "Advanced Settings", "settings": "Settings", "generalSettings": "General Settings", - "language": "Language", - "theme": "Theme", "autoLaunch": "Auto Launch", - "about": "About", - "aiProviders": "AI Providers", "themeMode": "Theme Mode", "selectTheme": "Choose the app's appearance theme", "light": "Light", "dark": "Dark", "autoLaunchDesc": "Automatically run the app when system starts", "languageDesc": "Select app interface language", - "defaultChatModel": "Default Chat Model", - "defaultChatModelDesc": "Select the default model for new conversations", - "defaultEmbeddingModel": "Default Embedding Model", - "defaultEmbeddingModelDesc": "Select the embedding model for knowledge base indexing", - "noAvailableModel": "No available models, please go to provider page to enable providers and select models", - "pleaseSelectModel": "Please select default model", "pleaseSelect": "Please Select", "none": "None", - "about": "About", "officialWebsite": "Official Website", "feedback": "Feedback", - "checkUpdates": "Check Updates", - "latestVersion": "Latest version", "version": "Version", "checking": "Checking...", - "updateAvailable": "New version {{version}} available", "downloadAndInstall": "Download & Install {{version}}", "downloading": "Downloading {{percent}}%", "installing": "Installing update...", "installUpdate": "Install Update & Restart", "checkUpdateError": "Update check failed, click to retry", - "searchProvider": "Search providers...", - "addCustomProvider": "Add Custom Provider", - "enterApiKey": "Please enter API Key first", - "fetchModelFailed": "Failed to fetch model list: ", - "baseUrl": "Base URL", - "baseUrlHint": "Base URL for the API service", "resetToDefault": "Reset to Default", - "getApiKeyFrom": "Get your API key from", - "platform": "Platform", "models": "Models", "fetchModels": "Fetch Models", "fetching": "Fetching...", - "searchModels": "Search models...", - "totalModels": "{{count}} models in total", - "allModels": "All Models", - "chatModels": "Chat Models", - "embeddingModels": "Embedding Models", - "otherModels": "Other Models", - "showingModels": "Showing {{count}} / {{total}} models", - "active": "Active", - "deepseekName": "DeepSeek", - "deepseekDesc": "DeepSeek AI models with reasoning capabilities", - "openaiName": "OpenAI", - "openaiDesc": "OpenAI GPT models including GPT-4 and GPT-3.5", - "siliconflowName": "SiliconFlow", - "siliconflowDesc": "SiliconFlow AI model platform providing various open-source and commercial models", - "qwenName": "Qwen", - "qwenDesc": "Alibaba Cloud Qwen models with long context and multimodal capabilities", - "kimiName": "Kimi", - "kimiDesc": "Moonshot AI Kimi models with 128K long context support", - "zhipuName": "Zhipu AI", - "zhipuDesc": "Zhipu AI GLM models with multimodal capabilities", - "addProviderDesc": "Add custom AI provider to extend functionality", - "providerName": "Provider Name", - "providerNamePlaceholder": "e.g., my-provider", - "providerNameRequired": "Provider name is required", - "providerNameExists": "This provider name already exists", - "apiUrl": "API URL", - "apiUrlRequired": "API URL is required", - "apiUrlInvalid": "Please enter a valid URL (ASCII characters only)", - "apiKeyRequired": "API Key is required", - "customProviderDesc": "Custom AI Provider", - "enterApiKeyAndUrl": "Please enter API Key and API URL first", - "apiUrlHint": "API endpoint URL for custom provider", - "deleteProvider": "Delete Provider", - "deleteProviderConfirm": "Are you sure you want to delete provider \"{{name}}\"? This action cannot be undone.", "promptSettings": "Prompt Settings", "mindMapPrompt": "Mind Map Generation Prompt", "mindMapPromptDesc": "Customize the AI prompt used for generating mind maps", @@ -132,11 +44,26 @@ "englishPrompt": "English Prompt", "resetPrompt": "Revert Changes", "promptPlaceholder": "Enter prompt content...", - "shortcuts": "Shortcuts", "shortcutsDesc": "View and customize application shortcut settings", "cancel": "Cancel", "confirm": "Confirm", - "generalSettingsDesc": "Configure basic application settings including language, theme, and default AI models", - "aiProvidersDesc": "Manage AI service provider configurations including API keys and model selection", - "aboutDesc": "View application version information and related details" + "generalSettingsDesc": "Configure basic application settings including language and theme", + "aboutDesc": "View application version information and related details", + "modelConnections": "Models", + "modelConnectionsDesc": "Configure the API protocol, base URL, API key and model ID for chat and embedding", + "chatModel": "Chat Model", + "chatModelDesc": "Used for conversations, mind maps, quizzes and Anki cards", + "embeddingModel": "Embedding Model", + "embeddingModelDesc": "Used to index the knowledge base for RAG retrieval", + "quickSetup": "Quick setup", + "presetOpenAI": "OpenAI", + "presetOllama": "Ollama", + "presetOpenRouter": "OpenRouter", + "presetCustom": "Custom endpoint", + "apiProtocol": "API Protocol", + "modelId": "Model ID", + "modelIdPlaceholder": "e.g. gpt-4o-mini", + "fetchModelsUnsupported": "This protocol does not expose a model list; enter the Model ID manually.", + "fetchModelsEmpty": "The endpoint returned no models; enter the Model ID manually.", + "testing": "Testing..." } diff --git a/src/renderer/src/locales/en-US/ui.json b/src/renderer/src/locales/en-US/ui.json index c8cf1de..03b6760 100644 --- a/src/renderer/src/locales/en-US/ui.json +++ b/src/renderer/src/locales/en-US/ui.json @@ -71,8 +71,7 @@ "clickToEditTitle": "Click to edit title", "selectOrCreateNotebook": "Select or create a notebook", "selectSession": "Please select a session first", - "noProviderConfigured": "AI Provider not configured, please configure in settings", - "noDefaultModel": "Please select a default chat model in settings", + "noProviderConfigured": "No chat model configured, please configure one in settings", "inputMessage": "Type a message... (Shift+Enter for new line)", "knowledgeBase": "Knowledge Base", "knowledgeBaseDesc": "Add documents to provide context for conversations", @@ -104,6 +103,5 @@ "emptyNoteCannotImport": "Note content is empty and cannot be imported to knowledge base", "backToList": "Back to List", "openUrl": "Open URL", - "openFile": "Open File", "cannotPreview": "Cannot preview this file type" } diff --git a/src/renderer/src/locales/zh-CN/settings.json b/src/renderer/src/locales/zh-CN/settings.json index 31956bd..2a314b5 100644 --- a/src/renderer/src/locales/zh-CN/settings.json +++ b/src/renderer/src/locales/zh-CN/settings.json @@ -5,22 +5,14 @@ "theme": "主题", "autoLaunch": "自动启动", "about": "关于", - "aiProviders": "AI提供商", "themeMode": "主题模式", "selectTheme": "选择应用的外观主题", "light": "浅色", "dark": "深色", "autoLaunchDesc": "系统启动时自动运行应用", "languageDesc": "选择应用界面语言", - "defaultChatModel": "默认对话模型", - "defaultChatModelDesc": "选择新对话时使用的默认模型", - "defaultEmbeddingModel": "默认嵌入模型", - "defaultEmbeddingModelDesc": "选择知识库索引时使用的嵌入模型", - "noAvailableModel": "没有可用的模型,请前往提供商页面启用供应商并选择模型", - "pleaseSelectModel": "请选择默认模型", "pleaseSelect": "请选择", "none": "无", - "about": "关于", "officialWebsite": "官方网站", "feedback": "意见反馈", "checkUpdates": "检查更新", @@ -33,53 +25,12 @@ "installing": "正在安装更新...", "installUpdate": "安装更新并重启", "checkUpdateError": "检查更新失败,点击重试", - "searchProvider": "搜索提供商...", - "addCustomProvider": "添加自定义提供商", - "enterApiKey": "请输入API密钥", - "fetchModelFailed": "获取模型列表失败:", "apiKey": "API Key", "baseUrl": "Base URL", - "baseUrlHint": "API 服务的基础地址", "resetToDefault": "恢复默认", - "getApiKeyFrom": "获取您的API密钥请访问", - "platform": "官方平台", "models": "模型", "fetchModels": "获取模型", "fetching": "获取中...", - "searchModels": "搜索模型...", - "totalModels": "共 {{count}} 个模型", - "allModels": "全部模型", - "chatModels": "对话模型", - "embeddingModels": "嵌入模型", - "otherModels": "其他模型", - "showingModels": "显示 {{count}} / {{total}} 个模型", - "active": "已启用", - "deepseekName": "DeepSeek", - "deepseekDesc": "DeepSeek AI 模型,具备强大的推理能力", - "openaiName": "OpenAI", - "openaiDesc": "OpenAI GPT 模型,包括 GPT-4 和 GPT-3.5", - "siliconflowName": "硅基流动", - "siliconflowDesc": "硅基流动 AI 模型平台,提供多种开源和商用模型", - "qwenName": "通义千问", - "qwenDesc": "阿里云通义千问大模型,支持超长上下文和多模态能力", - "kimiName": "Kimi", - "kimiDesc": "月之暗面 Kimi 大模型,支持 128K 超长上下文", - "zhipuName": "智谱AI", - "zhipuDesc": "智谱AI GLM 大模型,支持多模态能力", - "addProviderDesc": "添加自定义 AI 提供商以扩展功能", - "providerName": "供应商名称", - "providerNamePlaceholder": "例如:my-provider", - "providerNameRequired": "供应商名称不能为空", - "providerNameExists": "该供应商名称已存在", - "apiUrl": "API 地址", - "apiUrlRequired": "API 地址不能为空", - "apiUrlInvalid": "请输入有效的 URL 地址(仅支持英文字符)", - "apiKeyRequired": "API Key 不能为空", - "customProviderDesc": "自定义 AI 提供商", - "enterApiKeyAndUrl": "请先输入 API Key 和 API 地址", - "apiUrlHint": "自定义供应商的 API 端点地址", - "deleteProvider": "删除供应商", - "deleteProviderConfirm": "确定要删除供应商 \"{{name}}\" 吗?此操作无法撤销。", "promptSettings": "提示词设置", "mindMapPrompt": "思维导图生成提示词", "mindMapPromptDesc": "自定义思维导图生成时使用的AI提示词", @@ -93,7 +44,26 @@ "shortcutsDesc": "查看和自定义应用的快捷键设置", "cancel": "取消", "confirm": "确认", - "generalSettingsDesc": "配置应用的基本设置,包括语言、主题和默认AI模型", - "aiProvidersDesc": "管理AI服务提供商的配置,包括API密钥和模型选择", - "aboutDesc": "查看应用版本信息和相关说明" + "generalSettingsDesc": "配置应用的基本设置,包括语言与主题", + "aboutDesc": "查看应用版本信息和相关说明", + "modelConnections": "模型连接", + "modelConnectionsDesc": "配置对话与嵌入模型使用的 API 协议、Base URL、API Key 与 Model ID", + "chatModel": "对话模型", + "chatModelDesc": "用于对话、思维导图、答题与 Anki 卡片生成", + "embeddingModel": "嵌入模型", + "embeddingModelDesc": "用于知识库索引与 RAG 检索", + "quickSetup": "快速设置", + "presetOpenAI": "OpenAI", + "presetOllama": "Ollama", + "presetOpenRouter": "OpenRouter", + "presetCustom": "自定义端点", + "apiProtocol": "API 协议", + "modelId": "Model ID", + "modelIdPlaceholder": "例如 gpt-4o-mini", + "fetchModelsUnsupported": "该协议不支持拉取模型列表,请手动填写 Model ID。", + "fetchModelsEmpty": "端点未返回任何模型,请手动填写 Model ID。", + "testConnection": "测试连接", + "testing": "测试中...", + "connectionSuccessful": "连接成功", + "connectionFailed": "连接失败" } diff --git a/src/renderer/src/locales/zh-CN/ui.json b/src/renderer/src/locales/zh-CN/ui.json index 3eafddd..70a8f7d 100644 --- a/src/renderer/src/locales/zh-CN/ui.json +++ b/src/renderer/src/locales/zh-CN/ui.json @@ -19,8 +19,7 @@ "clickToEditTitle": "点击编辑标题", "selectOrCreateNotebook": "请选择或创建笔记本", "selectSession": "请先选择会话", - "noProviderConfigured": "未配置 AI Provider,请在设置中配置", - "noDefaultModel": "请先在设置中选择默认对话模型", + "noProviderConfigured": "未配置对话模型,请在设置中配置", "inputMessage": "输入消息... (Shift+Enter 换行)", "knowledgeBase": "知识库", "knowledgeBaseDesc": "添加文档为对话提供背景知识", diff --git a/src/shared/config/models/deepseek.ts b/src/shared/config/models/deepseek.ts deleted file mode 100644 index 206424d..0000000 --- a/src/shared/config/models/deepseek.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { ModelType } from '../../types' -import type { ProviderLocalModels } from './types' - -/** - * DeepSeek 内置模型列表 - * 基于 cherry-studio 和官方文档 - * 最后更新:2025-01-15 - */ -export const DEEPSEEK_BUILTIN_MODELS: ProviderLocalModels = { - providerName: 'deepseek', - lastUpdated: '2025-01-15', - models: [ - { - id: 'deepseek-chat', - type: ModelType.CHAT, - owned_by: 'deepseek', - max_context: 64000, - description: 'DeepSeek Chat' - }, - { - id: 'deepseek-reasoner', - type: ModelType.CHAT, - owned_by: 'deepseek', - max_context: 64000, - description: 'DeepSeek Reasoner' - } - ] -} diff --git a/src/shared/config/models/index.ts b/src/shared/config/models/index.ts deleted file mode 100644 index d2e4426..0000000 --- a/src/shared/config/models/index.ts +++ /dev/null @@ -1,121 +0,0 @@ -import { Model } from '../../types' -import { OPENAI_BUILTIN_MODELS } from './openai' -import { DEEPSEEK_BUILTIN_MODELS } from './deepseek' -import { SILICONFLOW_BUILTIN_MODELS } from './siliconflow' -import { QWEN_BUILTIN_MODELS } from './qwen' -import { ZHIPU_BUILTIN_MODELS } from './zhipu' -import { KIMI_BUILTIN_MODELS } from './kimi' -import { OLLAMA_BUILTIN_MODELS } from './ollama' -import type { ProviderLocalModels, LocalModelDefinition } from './types' - -/** - * 所有 Provider 的内置模型配置 - */ -const BUILTIN_MODELS_MAP: Record = { - openai: OPENAI_BUILTIN_MODELS, - deepseek: DEEPSEEK_BUILTIN_MODELS, - siliconflow: SILICONFLOW_BUILTIN_MODELS, - qwen: QWEN_BUILTIN_MODELS, - zhipu: ZHIPU_BUILTIN_MODELS, - kimi: KIMI_BUILTIN_MODELS, - ollama: OLLAMA_BUILTIN_MODELS -} - -/** - * 将本地模型定义转换为标准 Model 对象 - */ -function convertToModel(def: LocalModelDefinition, providerName: string): Model { - return { - id: def.id, - object: 'model', - owned_by: def.owned_by, - type: def.type, - // 将 lastUpdated 转为 created 时间戳(Unix timestamp) - created: Math.floor( - new Date(BUILTIN_MODELS_MAP[providerName]?.lastUpdated || Date.now()).getTime() / 1000 - ) - } -} - -/** - * 获取指定 Provider 的内置模型列表 - * @param providerName Provider 名称 - * @returns 内置模型列表,如果未找到则返回空数组 - */ -export function getBuiltinModels(providerName: string): Model[] { - const config = BUILTIN_MODELS_MAP[providerName] - if (!config) { - return [] - } - - return config.models.map((model) => convertToModel(model, providerName)) -} - -/** - * 检查是否有内置模型配置 - * @param providerName Provider 名称 - * @returns 是否存在内置模型配置 - */ -export function hasBuiltinModels(providerName: string): boolean { - return providerName in BUILTIN_MODELS_MAP -} - -/** - * 获取所有 Provider 的内置模型列表 - * @returns 所有内置模型的映射表 { providerName: Model[] } - */ -export function getAllBuiltinModels(): Record { - const result: Record = {} - - for (const providerName in BUILTIN_MODELS_MAP) { - result[providerName] = getBuiltinModels(providerName) - } - - return result -} - -/** - * 根据类型过滤内置模型 - * @param providerName Provider 名称 - * @param type 模型类型 - * @returns 过滤后的模型列表 - */ -export function getBuiltinModelsByType(providerName: string, type: string): Model[] { - const models = getBuiltinModels(providerName) - return models.filter((m) => m.type === type) -} - -/** - * 获取内置模型的统计信息 - */ -export function getBuiltinModelsStats(): { - totalProviders: number - totalModels: number - byProvider: Record - byType: Record -} { - const byProvider: Record = {} - const byType: Record = {} - let totalModels = 0 - - for (const providerName in BUILTIN_MODELS_MAP) { - const models = BUILTIN_MODELS_MAP[providerName].models - byProvider[providerName] = models.length - totalModels += models.length - - models.forEach((model) => { - const type = model.type.toString() - byType[type] = (byType[type] || 0) + 1 - }) - } - - return { - totalProviders: Object.keys(BUILTIN_MODELS_MAP).length, - totalModels, - byProvider, - byType - } -} - -// 导出类型 -export * from './types' diff --git a/src/shared/config/models/kimi.ts b/src/shared/config/models/kimi.ts deleted file mode 100644 index c45066d..0000000 --- a/src/shared/config/models/kimi.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { ModelType } from '../../types' -import type { ProviderLocalModels } from './types' - -/** - * Kimi (Moonshot) 内置模型列表 - * 基于 cherry-studio 和官方文档 - * 最后更新:2025-01-15 - */ -export const KIMI_BUILTIN_MODELS: ProviderLocalModels = { - providerName: 'kimi', - lastUpdated: '2025-01-15', - models: [ - // Moonshot v1 系列 - { - id: 'moonshot-v1-auto', - type: ModelType.CHAT, - owned_by: 'moonshot', - max_context: 128000, - description: 'Moonshot v1 Auto' - }, - { - id: 'moonshot-v1-8k', - type: ModelType.CHAT, - owned_by: 'moonshot', - max_context: 8000, - description: 'Moonshot v1 8K' - }, - { - id: 'moonshot-v1-32k', - type: ModelType.CHAT, - owned_by: 'moonshot', - max_context: 32000, - description: 'Moonshot v1 32K' - }, - { - id: 'moonshot-v1-128k', - type: ModelType.CHAT, - owned_by: 'moonshot', - max_context: 128000, - description: 'Moonshot v1 128K' - }, - - // Kimi k2 系列 - { - id: 'kimi-k2-0711-preview', - type: ModelType.CHAT, - owned_by: 'moonshot', - max_context: 200000, - description: 'Kimi k2 Preview' - } - ] -} diff --git a/src/shared/config/models/ollama.ts b/src/shared/config/models/ollama.ts deleted file mode 100644 index dc382ec..0000000 --- a/src/shared/config/models/ollama.ts +++ /dev/null @@ -1,87 +0,0 @@ -import { ModelType } from '../../types' -import type { ProviderLocalModels } from './types' - -/** - * Ollama 常见模型列表 - * 数据来源:https://ollama.com/library - * 最后更新:2025-01-15 - */ -export const OLLAMA_BUILTIN_MODELS: ProviderLocalModels = { - providerName: 'ollama', - lastUpdated: '2025-01-15', - models: [ - // Chat Models - { - id: 'llama3.2', - type: ModelType.CHAT, - owned_by: 'meta', - max_context: 128000, - description: 'Llama 3.2' - }, - { - id: 'llama3.1', - type: ModelType.CHAT, - owned_by: 'meta', - max_context: 128000, - description: 'Llama 3.1' - }, - { - id: 'qwen2.5', - type: ModelType.CHAT, - owned_by: 'qwen', - max_context: 32768, - description: 'Qwen 2.5' - }, - { - id: 'deepseek-r1', - type: ModelType.CHAT, - owned_by: 'deepseek', - max_context: 64000, - description: 'DeepSeek R1' - }, - { - id: 'mistral', - type: ModelType.CHAT, - owned_by: 'mistral', - max_context: 32768, - description: 'Mistral' - }, - { - id: 'gemma2', - type: ModelType.CHAT, - owned_by: 'google', - max_context: 8192, - description: 'Gemma 2' - }, - - // Embedding Models - { - id: 'nomic-embed-text', - type: ModelType.EMBEDDING, - owned_by: 'nomic-ai', - max_context: 8192, - description: 'Nomic Embed Text - 768 维' - }, - { - id: 'mxbai-embed-large', - type: ModelType.EMBEDDING, - owned_by: 'mixedbread', - max_context: 512, - description: 'MXBai Embed Large - 1024 维' - }, - { - id: 'all-minilm', - type: ModelType.EMBEDDING, - owned_by: 'sentence-transformers', - max_context: 256, - description: 'All MiniLM - 384 维' - }, - { - id: 'bge-m3', - type: ModelType.EMBEDDING, - owned_by: 'BAAI', - max_context: 8191, - description: 'BGE M3 - 1024 维' - } - ] -} diff --git a/src/shared/config/models/openai.ts b/src/shared/config/models/openai.ts deleted file mode 100644 index 494a4bc..0000000 --- a/src/shared/config/models/openai.ts +++ /dev/null @@ -1,132 +0,0 @@ -import { ModelType } from '../../types' -import type { ProviderLocalModels } from './types' - -/** - * OpenAI 内置模型列表 - * 基于 cherry-studio 和官方文档 - * 最后更新:2025-01-15 - */ -export const OPENAI_BUILTIN_MODELS: ProviderLocalModels = { - providerName: 'openai', - lastUpdated: '2025-01-15', - models: [ - // GPT-5 系列(cherry-studio) - { - id: 'gpt-5.1', - type: ModelType.CHAT, - owned_by: 'openai', - max_context: 128000, - description: 'GPT 5.1' - }, - { - id: 'gpt-5', - type: ModelType.CHAT, - owned_by: 'openai', - max_context: 128000, - description: 'GPT 5' - }, - { - id: 'gpt-5-mini', - type: ModelType.CHAT, - owned_by: 'openai', - max_context: 128000, - description: 'GPT 5 Mini' - }, - { - id: 'gpt-5-nano', - type: ModelType.CHAT, - owned_by: 'openai', - max_context: 128000, - description: 'GPT 5 Nano' - }, - { - id: 'gpt-5-pro', - type: ModelType.CHAT, - owned_by: 'openai', - max_context: 128000, - description: 'GPT 5 Pro' - }, - { - id: 'gpt-5-chat', - type: ModelType.CHAT, - owned_by: 'openai', - max_context: 128000, - description: 'GPT 5 Chat' - }, - - // GPT-4 系列 - { - id: 'gpt-4o', - type: ModelType.CHAT, - owned_by: 'openai', - max_context: 128000, - description: 'GPT-4o - 最新多模态模型' - }, - { - id: 'gpt-4o-mini', - type: ModelType.CHAT, - owned_by: 'openai', - max_context: 128000, - description: 'GPT-4o Mini' - }, - { - id: 'gpt-4-turbo', - type: ModelType.CHAT, - owned_by: 'openai', - max_context: 128000, - description: 'GPT-4 Turbo' - }, - { - id: 'gpt-4', - type: ModelType.CHAT, - owned_by: 'openai', - max_context: 8192, - description: 'GPT-4' - }, - { - id: 'gpt-3.5-turbo', - type: ModelType.CHAT, - owned_by: 'openai', - max_context: 16385, - description: 'GPT-3.5 Turbo' - }, - - // 图像模型 - { id: 'gpt-image-1', type: ModelType.IMAGE, owned_by: 'openai', description: 'GPT Image 1' }, - { id: 'dall-e-3', type: ModelType.IMAGE, owned_by: 'openai', description: 'DALL-E 3' }, - { id: 'dall-e-2', type: ModelType.IMAGE, owned_by: 'openai', description: 'DALL-E 2' }, - - // Embedding 模型 - { - id: 'text-embedding-3-large', - type: ModelType.EMBEDDING, - owned_by: 'openai', - max_context: 8191, - description: 'Embedding v3 Large - 3072维' - }, - { - id: 'text-embedding-3-small', - type: ModelType.EMBEDDING, - owned_by: 'openai', - max_context: 8191, - description: 'Embedding v3 Small - 1536维' - }, - { - id: 'text-embedding-ada-002', - type: ModelType.EMBEDDING, - owned_by: 'openai', - max_context: 8191, - description: 'Embedding Ada 002' - }, - - // 音频模型 - { - id: 'whisper-1', - type: ModelType.AUDIO, - owned_by: 'openai', - description: 'Whisper - 语音转文字' - }, - { id: 'tts-1', type: ModelType.AUDIO, owned_by: 'openai', description: 'TTS v1' }, - { id: 'tts-1-hd', type: ModelType.AUDIO, owned_by: 'openai', description: 'TTS v1 HD' } - ] -} diff --git a/src/shared/config/models/qwen.ts b/src/shared/config/models/qwen.ts deleted file mode 100644 index 8847f75..0000000 --- a/src/shared/config/models/qwen.ts +++ /dev/null @@ -1,96 +0,0 @@ -import { ModelType } from '../../types' -import type { ProviderLocalModels } from './types' - -/** - * Qwen 内置模型列表 (DashScope) - * 基于 cherry-studio 和官方文档 - * 最后更新:2025-01-15 - */ -export const QWEN_BUILTIN_MODELS: ProviderLocalModels = { - providerName: 'qwen', - lastUpdated: '2025-01-15', - models: [ - // Chat 模型 - { - id: 'qwen-vl-plus', - type: ModelType.CHAT, - owned_by: 'system', - max_context: 32768, - description: 'Qwen VL Plus - 多模态' - }, - { - id: 'qwen-coder-plus', - type: ModelType.CHAT, - owned_by: 'system', - max_context: 32768, - description: 'Qwen Coder Plus' - }, - { - id: 'qwen-flash', - type: ModelType.CHAT, - owned_by: 'system', - max_context: 8192, - description: 'Qwen Flash' - }, - { - id: 'qwen-plus', - type: ModelType.CHAT, - owned_by: 'system', - max_context: 32768, - description: 'Qwen Plus' - }, - { - id: 'qwen-max', - type: ModelType.CHAT, - owned_by: 'system', - max_context: 32768, - description: 'Qwen Max' - }, - { - id: 'qwen3-max', - type: ModelType.CHAT, - owned_by: 'system', - max_context: 32768, - description: 'Qwen3 Max' - }, - - // 嵌入模型 - { - id: 'text-embedding-v4', - type: ModelType.EMBEDDING, - owned_by: 'system', - max_context: 8192, - description: 'Text Embedding v4' - }, - { - id: 'text-embedding-v3', - type: ModelType.EMBEDDING, - owned_by: 'system', - max_context: 8192, - description: 'Text Embedding v3' - }, - { - id: 'text-embedding-v2', - type: ModelType.EMBEDDING, - owned_by: 'system', - max_context: 2048, - description: 'Text Embedding v2' - }, - { - id: 'text-embedding-v1', - type: ModelType.EMBEDDING, - owned_by: 'system', - max_context: 2048, - description: 'Text Embedding v1' - }, - - // Reranker 模型 - { - id: 'qwen3-rerank', - type: ModelType.RERANKER, - owned_by: 'system', - max_context: 8192, - description: 'Qwen3 Rerank' - } - ] -} diff --git a/src/shared/config/models/siliconflow.ts b/src/shared/config/models/siliconflow.ts deleted file mode 100644 index ad441fd..0000000 --- a/src/shared/config/models/siliconflow.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { ModelType } from '../../types' -import type { ProviderLocalModels } from './types' - -/** - * SiliconFlow 内置模型列表 - * 基于 cherry-studio 和官方文档 - * 最后更新:2025-01-15 - */ -export const SILICONFLOW_BUILTIN_MODELS: ProviderLocalModels = { - providerName: 'siliconflow', - lastUpdated: '2025-01-15', - models: [ - // Chat 模型 - { - id: 'deepseek-ai/DeepSeek-V3.2', - type: ModelType.CHAT, - owned_by: 'deepseek-ai', - max_context: 64000, - description: 'DeepSeek V3.2' - }, - { - id: 'Qwen/Qwen3-8B', - type: ModelType.CHAT, - owned_by: 'Qwen', - max_context: 32768, - description: 'Qwen3 8B' - }, - - // 嵌入模型 - { - id: 'BAAI/bge-m3', - type: ModelType.EMBEDDING, - owned_by: 'BAAI', - max_context: 8191, - description: 'BGE M3 - 多语言' - } - ] -} diff --git a/src/shared/config/models/types.ts b/src/shared/config/models/types.ts deleted file mode 100644 index a7ff016..0000000 --- a/src/shared/config/models/types.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { ModelType } from '../../types' - -/** - * 本地模型定义(简化版,用于静态配置) - */ -export interface LocalModelDefinition { - id: string - type: ModelType - owned_by?: string - max_context?: number // 最大上下文长度 - description?: string // 模型描述(可选) -} - -/** - * Provider 本地模型列表配置 - */ -export interface ProviderLocalModels { - providerName: string - lastUpdated: string // ISO 日期字符串,便于追踪更新时间 - models: LocalModelDefinition[] -} diff --git a/src/shared/config/models/zhipu.ts b/src/shared/config/models/zhipu.ts deleted file mode 100644 index 1e15832..0000000 --- a/src/shared/config/models/zhipu.ts +++ /dev/null @@ -1,98 +0,0 @@ -import { ModelType } from '../../types' -import type { ProviderLocalModels } from './types' - -/** - * 智谱 AI 内置模型列表 - * 基于 cherry-studio 和官方文档 - * 最后更新:2025-01-15 - */ -export const ZHIPU_BUILTIN_MODELS: ProviderLocalModels = { - providerName: 'zhipu', - lastUpdated: '2025-01-15', - models: [ - // GLM-4.5 系列 - { - id: 'glm-4.5-flash', - type: ModelType.CHAT, - owned_by: 'zhipu', - max_context: 128000, - description: 'GLM-4.5-Flash' - }, - { - id: 'glm-4.5', - type: ModelType.CHAT, - owned_by: 'zhipu', - max_context: 128000, - description: 'GLM-4.5' - }, - { - id: 'glm-4.5-air', - type: ModelType.CHAT, - owned_by: 'zhipu', - max_context: 128000, - description: 'GLM-4.5-Air' - }, - { - id: 'glm-4.5-airx', - type: ModelType.CHAT, - owned_by: 'zhipu', - max_context: 128000, - description: 'GLM-4.5-AirX' - }, - { - id: 'glm-4.5v', - type: ModelType.CHAT, - owned_by: 'zhipu', - max_context: 8192, - description: 'GLM-4.5V - 多模态' - }, - - // GLM-4.6 系列 - { - id: 'glm-4.6', - type: ModelType.CHAT, - owned_by: 'zhipu', - max_context: 128000, - description: 'GLM-4.6' - }, - { - id: 'glm-4.6v', - type: ModelType.CHAT, - owned_by: 'zhipu', - max_context: 8192, - description: 'GLM-4.6V - 多模态' - }, - { - id: 'glm-4.6v-flash', - type: ModelType.CHAT, - owned_by: 'zhipu', - max_context: 8192, - description: 'GLM-4.6V-Flash - 多模态' - }, - { - id: 'glm-4.6v-flashx', - type: ModelType.CHAT, - owned_by: 'zhipu', - max_context: 8192, - description: 'GLM-4.6V-FlashX - 多模态' - }, - - // GLM-4.7 系列 - { - id: 'glm-4.7', - type: ModelType.CHAT, - owned_by: 'zhipu', - max_context: 128000, - description: 'GLM-4.7' - }, - - // 嵌入模型 - { - id: 'embedding-3', - type: ModelType.EMBEDDING, - owned_by: 'zhipu', - max_context: 2048, - description: 'Embedding-3' - } - ] -} diff --git a/src/shared/types/connection.ts b/src/shared/types/connection.ts new file mode 100644 index 0000000..6c4d983 --- /dev/null +++ b/src/shared/types/connection.ts @@ -0,0 +1,64 @@ +/** + * Model Connection 类型定义 + * + * 一个 Model Connection = API Protocol + Base URL + API Key + Model ID。 + * KnowNote 不认识任何厂商:兼容 OpenAI API 的服务一律只是 + * "OpenAI-compatible endpoint"。能力(capability)与连接解耦,由用户显式声明 + * 该连接用于 chat 还是 embedding,代码不再猜测模型类型。 + */ + +/** + * 支持的 API 协议。新增协议只需实现一个 protocol adapter。 + */ +export const API_PROTOCOLS = [ + 'openai-completions', + 'openai-responses', + 'anthropic-messages', + 'google-generative-ai' +] as const + +export type APIProtocol = (typeof API_PROTOCOLS)[number] + +/** + * 连接的能力标签。RAG 链路同时需要 chat 与 embedding, + * 之后接入 rerank 时在这里继续扩展。 + */ +export const MODEL_CAPABILITIES = ['chat', 'embedding'] as const + +export type ModelCapability = (typeof MODEL_CAPABILITIES)[number] + +/** + * 支持 embedding 的协议。Anthropic Messages 不提供 embedding 端点。 + */ +export const EMBEDDING_CAPABLE_PROTOCOLS: readonly APIProtocol[] = [ + 'openai-completions', + 'openai-responses', + 'google-generative-ai' +] + +export function protocolSupportsEmbedding(protocol: APIProtocol): boolean { + return EMBEDDING_CAPABLE_PROTOCOLS.includes(protocol) +} + +/** + * 单个 Model Connection + */ +export interface ModelConnection { + protocol: APIProtocol + baseUrl: string + apiKey: string + modelId: string +} + +/** + * 按 capability 索引的连接集合。缺失的能力表示未配置。 + */ +export type ConnectionMap = Partial> + +/** + * 连接测试结果 + */ +export interface ConnectionTestResult { + ok: boolean + error?: string +} diff --git a/src/shared/types/index.ts b/src/shared/types/index.ts index 388317c..0f8b8cd 100644 --- a/src/shared/types/index.ts +++ b/src/shared/types/index.ts @@ -18,6 +18,9 @@ export * from './quiz' // 导出 Anki 类型 export * from './anki' +// 导出 Model Connection 类型 +export * from './connection' + /** * 笔记本接口 * 与 Drizzle schema 推导的类型兼容 @@ -43,52 +46,6 @@ export interface Note { updatedAt: Date } -/** - * Provider 配置接口 - */ -export interface ProviderConfig { - providerName: string - config: any - enabled: boolean - updatedAt: number -} - -/** - * 模型类型枚举 - */ -export enum ModelType { - CHAT = 'chat', - EMBEDDING = 'embedding', - RERANKER = 'reranker', - IMAGE = 'image', - AUDIO = 'audio', - VIDEO = 'video', - UNKNOWN = 'unknown' -} - -/** - * 模型接口 - */ -export interface Model { - id: string - object: string - owned_by?: string - created?: number - type?: ModelType - max_context?: number // 最大上下文长度 (来自内置配置) - description?: string // 模型描述 (来自内置配置) -} - -/** - * 分类后的模型列表接口 - */ -export interface CategorizedModels { - chat: Model[] - embedding: Model[] - reranker: Model[] - other: Model[] -} - /** * 应用设置接口 */ @@ -97,8 +54,6 @@ export interface AppSettings { language: 'zh-CN' | 'en-US' autoLaunch: boolean hasCompletedOnboarding: boolean - defaultChatModel?: string // 默认对话模型 - defaultEmbeddingModel?: string // 默认嵌入模型 prompts?: { mindMap?: { 'zh-CN'?: string // 中文思维导图生成提示词 diff --git a/src/shared/utils/modelClassifier.ts b/src/shared/utils/modelClassifier.ts deleted file mode 100644 index b80064a..0000000 --- a/src/shared/utils/modelClassifier.ts +++ /dev/null @@ -1,277 +0,0 @@ -import { Model, ModelType, CategorizedModels } from '../types' -import { getAllBuiltinModels } from '../config/models' - -/** - * 内置模型类型缓存(用于快速查找) - */ -let builtinModelTypeCache: Map | null = null - -/** - * 初始化内置模型类型缓存 - */ -function initBuiltinModelTypeCache(): Map { - if (builtinModelTypeCache) { - return builtinModelTypeCache - } - - const cache = new Map() - const allBuiltinModels = getAllBuiltinModels() - - // 遍历所有 provider 的内置模型 - for (const models of Object.values(allBuiltinModels)) { - for (const model of models) { - // 内置模型保证有 type 字段 - if (model.type) { - cache.set(model.id, model.type) - } - } - } - - builtinModelTypeCache = cache - return cache -} - -/** - * 从内置配置中查找模型类型 - * @param modelId 模型ID - * @returns 模型类型,如果未找到返回 undefined - */ -function getBuiltinModelType(modelId: string): ModelType | undefined { - const cache = initBuiltinModelTypeCache() - return cache.get(modelId) -} - -/** - * 嵌入模型的关键词模式 - */ -const EMBEDDING_PATTERNS = [ - /embed/i, - /bge/i, - /bce-embedding/i, - /e5-/i, - /gte-/i, - /m3e/i, - /text-similarity/i, - /sentence-transformers/i -] - -/** - * 重排序模型的关键词模式 - */ -const RERANKER_PATTERNS = [/rerank/i, /cross-encoder/i] - -/** - * 对话模型的关键词模式(白名单) - */ -const CHAT_PATTERNS = [ - /gpt-/i, - /deepseek/i, - /qwen/i, - /claude/i, - /llama/i, - /mistral/i, - /gemini/i, - /glm/i, - /kimi/i, - /minimax/i, - /yi-/i, - /chatglm/i, - /baichuan/i, - /internlm/i, - /thinking/i, - /instruct/i, - /chat/i -] - -/** - * 多模态/视觉模型关键词 - */ -const VISION_PATTERNS = [/-vl/i, /-vision/i, /vision/i, /4v/i, /captioner/i, /omni/i] - -/** - * 图像生成模型关键词 - */ -const IMAGE_GEN_PATTERNS = [ - /stable-diffusion/i, - /sdxl/i, - /flux/i, - /dalle/i, - /midjourney/i, - /kolors/i -] - -/** - * 音频模型关键词 - */ -const AUDIO_PATTERNS = [/whisper/i, /tts/i, /speech/i, /audio/i, /cosyvoice/i, /fish-speech/i] - -/** - * 根据模型ID判断模型类型 - * @param modelId 模型ID - * @returns 模型类型 - */ -export function classifyModel(modelId: string): ModelType { - const lowerCaseId = modelId.toLowerCase() - - // 优先级1: 明确的特殊类型(避免误判) - // 重排序模型 - if (RERANKER_PATTERNS.some((pattern) => pattern.test(lowerCaseId))) { - return ModelType.RERANKER - } - - // 嵌入模型 - if (EMBEDDING_PATTERNS.some((pattern) => pattern.test(lowerCaseId))) { - return ModelType.EMBEDDING - } - - // 优先级2: 多媒体生成模型 - // 图像生成模型 - if (IMAGE_GEN_PATTERNS.some((pattern) => pattern.test(lowerCaseId))) { - return ModelType.IMAGE - } - - // 音频模型 - if (AUDIO_PATTERNS.some((pattern) => pattern.test(lowerCaseId))) { - return ModelType.AUDIO - } - - // 优先级3: 对话模型(包括多模态对话) - // 视觉/多模态对话模型也归类为对话模型(因为它们主要用于聊天) - if ( - CHAT_PATTERNS.some((pattern) => pattern.test(lowerCaseId)) || - VISION_PATTERNS.some((pattern) => pattern.test(lowerCaseId)) - ) { - return ModelType.CHAT - } - - // 默认返回未知 - return ModelType.UNKNOWN -} - -/** - * 为模型添加类型信息 - * 优先级: - * 1. 内置配置文件中定义的类型 - * 2. 模型对象自带的 type 字段(API 返回) - * 3. 基于模型名称的自动分类 - * @param model 原始模型对象 - * @returns 带类型信息的模型对象 - */ -export function enrichModelWithType(model: Model): Model { - // 1. 优先使用内置配置文件中定义的类型 - const builtinType = getBuiltinModelType(model.id) - if (builtinType) { - return { - ...model, - type: builtinType - } - } - - // 2. 使用模型对象自带的 type 字段,或使用自动分类 - return { - ...model, - type: model.type || classifyModel(model.id) - } -} - -/** - * 批量为模型添加类型信息 - * @param models 模型列表 - * @returns 带类型信息的模型列表 - */ -export function enrichModelsWithType(models: Model[]): Model[] { - return models.map(enrichModelWithType) -} - -/** - * 对模型列表进行分类 - * @param models 模型列表 - * @returns 分类后的模型对象 - */ -export function categorizeModels(models: Model[]): CategorizedModels { - const enrichedModels = enrichModelsWithType(models) - - return { - chat: enrichedModels.filter((m) => m.type === ModelType.CHAT), - embedding: enrichedModels.filter((m) => m.type === ModelType.EMBEDDING), - reranker: enrichedModels.filter((m) => m.type === ModelType.RERANKER), - other: enrichedModels.filter( - (m) => - m.type === ModelType.IMAGE || - m.type === ModelType.AUDIO || - m.type === ModelType.VIDEO || - m.type === ModelType.UNKNOWN - ) - } -} - -/** - * 过滤出对话模型 - * @param models 模型列表 - * @returns 对话模型列表 - */ -export function filterChatModels(models: Model[]): Model[] { - return enrichModelsWithType(models).filter((m) => m.type === ModelType.CHAT) -} - -/** - * 过滤出嵌入模型 - * @param models 模型列表 - * @returns 嵌入模型列表 - */ -export function filterEmbeddingModels(models: Model[]): Model[] { - return enrichModelsWithType(models).filter((m) => m.type === ModelType.EMBEDDING) -} - -/** - * 智能合并内置模型和远程模型 - * - * 合并策略: - * - 远程字段优先: id, owned_by, created, object (反映最新状态) - * - 内置字段优先: type, max_context, description (精心配置的元数据) - * - 远程新增模型: 自动分类 type 后添加 - * - 内置独有模型: 保留 (防止远程 API 遗漏) - * - * @param builtinModels 内置模型列表 - * @param remoteModels 远程 API 获取的模型列表 - * @returns 合并后的模型列表 - * - * @example - * // 内置模型: [{ id: 'gpt-4o', type: 'chat', max_context: 128000 }] - * // 远程模型: [{ id: 'gpt-4o', created: 1715367049, owned_by: 'openai' }] - * // 合并结果: [{ id: 'gpt-4o', type: 'chat', max_context: 128000, created: 1715367049, owned_by: 'openai' }] - */ -export function mergeModels(builtinModels: Model[], remoteModels: Model[]): Model[] { - const builtinMap = new Map(builtinModels.map((m) => [m.id, m])) - const remoteMap = new Map(remoteModels.map((m) => [m.id, m])) - - const merged: Model[] = [] - - // 1. 遍历所有远程模型 - for (const remote of remoteModels) { - const builtin = builtinMap.get(remote.id) - - if (builtin) { - // 同时存在:智能合并 - // 远程字段提供最新的基础信息,内置字段提供精确的元数据 - merged.push({ - ...remote, // 远程字段 (id, object, owned_by, created) - type: builtin.type, // 内置字段优先 - max_context: builtin.max_context, - description: builtin.description - }) - } else { - // 仅远程有:直接添加 (自动分类 type) - merged.push(enrichModelWithType(remote)) - } - } - - // 2. 添加仅内置有的模型 (防止远程 API 遗漏) - for (const builtin of builtinModels) { - if (!remoteMap.has(builtin.id)) { - merged.push(builtin) - } - } - - return merged -} diff --git a/test/connectionMigration.test.ts b/test/connectionMigration.test.ts new file mode 100644 index 0000000..edbc2dd --- /dev/null +++ b/test/connectionMigration.test.ts @@ -0,0 +1,172 @@ +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { + deriveConnections, + type LegacyProviderConfig, + type LegacyProviderConfigData +} from '../src/main/config/legacyConnectionMapping.ts' + +interface BuiltinCase { + provider: string + baseUrl: string + apiKey: string + chat: string + embedding?: string + /** 迁移后期望的 baseUrl(Ollama 的 /api 会转成 /v1) */ + expectedBaseUrl?: string + expectedApiKey?: string +} + +/** + * 7 个内置 provider 的典型配置。旧版本中它们全部是 OpenAI 兼容端点。 + */ +const BUILTIN_CASES: BuiltinCase[] = [ + { + provider: 'openai', + baseUrl: 'https://api.openai.com/v1', + apiKey: 'sk-openai', + chat: 'gpt-4o', + embedding: 'text-embedding-3-small' + }, + { + provider: 'deepseek', + baseUrl: 'https://api.deepseek.com', + apiKey: 'sk-deepseek', + chat: 'deepseek-chat', + embedding: 'deepseek-embedding' + }, + { + provider: 'qwen', + baseUrl: 'https://dashscope.aliyuncs.com/compatible-mode/v1', + apiKey: 'sk-qwen', + chat: 'qwen-max', + embedding: 'text-embedding-v2' + }, + { + provider: 'kimi', + baseUrl: 'https://api.moonshot.cn/v1', + apiKey: 'sk-kimi', + chat: 'kimi-k2-turbo-preview' + }, + { + provider: 'siliconflow', + baseUrl: 'https://api.siliconflow.cn/v1', + apiKey: 'sk-siliconflow', + chat: 'deepseek-ai/DeepSeek-V3', + embedding: 'BAAI/bge-m3' + }, + { + provider: 'ollama', + baseUrl: 'http://localhost:11434/api', + apiKey: '', + chat: 'gemma3:12b', + embedding: 'bge-m3', + expectedBaseUrl: 'http://localhost:11434/v1', + expectedApiKey: 'ollama' + }, + { + provider: 'zhipu', + baseUrl: 'https://open.bigmodel.cn/api/paas/v4', + apiKey: 'sk-zhipu', + chat: 'glm-4-plus', + embedding: 'embedding-3' + } +] + +function buildProvider(c: BuiltinCase): LegacyProviderConfig { + const models = [c.chat, ...(c.embedding ? [c.embedding] : [])] + const config: LegacyProviderConfigData = { + baseUrl: c.baseUrl, + apiKey: c.apiKey, + models, + modelDetails: [ + { id: c.chat, type: 'chat' }, + ...(c.embedding ? [{ id: c.embedding, type: 'embedding' }] : []) + ] + } + return { providerName: c.provider, config, enabled: true, updatedAt: Date.now() } +} + +for (const c of BUILTIN_CASES) { + test(`migrates builtin provider: ${c.provider}`, () => { + const providers = { [c.provider]: buildProvider(c) } + const { connections, warnings } = deriveConnections({ + providers, + settings: { + defaultChatModel: `${c.provider}:${c.chat}`, + defaultEmbeddingModel: c.embedding ? `${c.provider}:${c.embedding}` : undefined + } + }) + + assert.deepEqual(connections.chat, { + protocol: 'openai-completions', + baseUrl: c.expectedBaseUrl ?? c.baseUrl, + apiKey: c.expectedApiKey ?? c.apiKey, + modelId: c.chat + }) + + if (c.embedding) { + assert.deepEqual(connections.embedding, { + protocol: 'openai-completions', + baseUrl: c.expectedBaseUrl ?? c.baseUrl, + apiKey: c.expectedApiKey ?? c.apiKey, + modelId: c.embedding + }) + assert.deepEqual(warnings, []) + } else { + assert.equal(connections.embedding, undefined) + } + }) +} + +test('falls back to selected models when no default model is set', () => { + const c = BUILTIN_CASES[0] + const { connections } = deriveConnections({ + providers: { [c.provider]: buildProvider(c) }, + settings: {} + }) + + assert.equal(connections.chat?.modelId, c.chat) + assert.equal(connections.embedding?.modelId, c.embedding) +}) + +test('does not guess model types: user-declared chat model keeps its id', () => { + const c = BUILTIN_CASES.find((item) => item.provider === 'ollama')! + const { connections } = deriveConnections({ + providers: { ollama: buildProvider(c) }, + settings: { defaultChatModel: 'ollama:gemma3:12b' } + }) + + assert.equal(connections.chat?.modelId, 'gemma3:12b') +}) + +test('reports a warning instead of fabricating a connection', () => { + const { connections, warnings } = deriveConnections({ + providers: { + custom: { + providerName: 'custom', + config: { apiKey: 'sk', models: ['some-model'] } as LegacyProviderConfigData, + enabled: true, + updatedAt: 0 + } + }, + settings: { defaultChatModel: 'missing:some-model' } + }) + + assert.equal(connections.chat, undefined) + assert.ok(warnings.some((w) => w.includes('missing'))) +}) + +test('decrypts provider config through the injected decrypt function', () => { + const c = BUILTIN_CASES[0] + const provider = buildProvider(c) + provider.config.apiKey = '__encrypted__' + + const { connections } = deriveConnections({ + providers: { [c.provider]: provider }, + settings: { defaultChatModel: `${c.provider}:${c.chat}` }, + decrypt: (config) => ({ ...config, apiKey: 'sk-decrypted' }) + }) + + assert.equal(connections.chat?.apiKey, 'sk-decrypted') +}) diff --git a/tsconfig.test.json b/tsconfig.test.json new file mode 100644 index 0000000..78344c0 --- /dev/null +++ b/tsconfig.test.json @@ -0,0 +1,10 @@ +{ + "extends": "./tsconfig.node.json", + "include": ["test/**/*.ts"], + "compilerOptions": { + "composite": false, + "noEmit": true, + "allowImportingTsExtensions": true, + "types": ["node"] + } +}