diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ea8c1db8f..f54b81db2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -71,6 +71,9 @@ importers: '@tauri-apps/plugin-shell': specifier: ^2.0.0 version: 2.3.3 + '@tonejs/midi': + specifier: ^2.0.28 + version: 2.0.28 '@types/lodash-es': specifier: ^4.17.12 version: 4.17.12 @@ -164,6 +167,9 @@ importers: tailwind-merge: specifier: ^2.6.0 version: 2.6.0 + tone: + specifier: ^15.1.22 + version: 15.1.22 devDependencies: '@babel/plugin-transform-react-jsx-source': specifier: ^7.27.1 @@ -1320,56 +1326,67 @@ packages: resolution: {integrity: sha512-EHMUcDwhtdRGlXZsGSIuXSYwD5kOT9NVnx9sqzYiwAc91wfYOE1g1djOEDseZJKKqtHAHGwnGPQu3kytmfaXLQ==} cpu: [arm] os: [linux] + libc: [glibc] '@rollup/rollup-linux-arm-musleabihf@4.54.0': resolution: {integrity: sha512-+pBrqEjaakN2ySv5RVrj/qLytYhPKEUwk+e3SFU5jTLHIcAtqh2rLrd/OkbNuHJpsBgxsD8ccJt5ga/SeG0JmA==} cpu: [arm] os: [linux] + libc: [musl] '@rollup/rollup-linux-arm64-gnu@4.54.0': resolution: {integrity: sha512-NSqc7rE9wuUaRBsBp5ckQ5CVz5aIRKCwsoa6WMF7G01sX3/qHUw/z4pv+D+ahL1EIKy6Enpcnz1RY8pf7bjwng==} cpu: [arm64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-arm64-musl@4.54.0': resolution: {integrity: sha512-gr5vDbg3Bakga5kbdpqx81m2n9IX8M6gIMlQQIXiLTNeQW6CucvuInJ91EuCJ/JYvc+rcLLsDFcfAD1K7fMofg==} cpu: [arm64] os: [linux] + libc: [musl] '@rollup/rollup-linux-loong64-gnu@4.54.0': resolution: {integrity: sha512-gsrtB1NA3ZYj2vq0Rzkylo9ylCtW/PhpLEivlgWe0bpgtX5+9j9EZa0wtZiCjgu6zmSeZWyI/e2YRX1URozpIw==} cpu: [loong64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-ppc64-gnu@4.54.0': resolution: {integrity: sha512-y3qNOfTBStmFNq+t4s7Tmc9hW2ENtPg8FeUD/VShI7rKxNW7O4fFeaYbMsd3tpFlIg1Q8IapFgy7Q9i2BqeBvA==} cpu: [ppc64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-riscv64-gnu@4.54.0': resolution: {integrity: sha512-89sepv7h2lIVPsFma8iwmccN7Yjjtgz0Rj/Ou6fEqg3HDhpCa+Et+YSufy27i6b0Wav69Qv4WBNl3Rs6pwhebQ==} cpu: [riscv64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-riscv64-musl@4.54.0': resolution: {integrity: sha512-ZcU77ieh0M2Q8Ur7D5X7KvK+UxbXeDHwiOt/CPSBTI1fBmeDMivW0dPkdqkT4rOgDjrDDBUed9x4EgraIKoR2A==} cpu: [riscv64] os: [linux] + libc: [musl] '@rollup/rollup-linux-s390x-gnu@4.54.0': resolution: {integrity: sha512-2AdWy5RdDF5+4YfG/YesGDDtbyJlC9LHmL6rZw6FurBJ5n4vFGupsOBGfwMRjBYH7qRQowT8D/U4LoSvVwOhSQ==} cpu: [s390x] os: [linux] + libc: [glibc] '@rollup/rollup-linux-x64-gnu@4.54.0': resolution: {integrity: sha512-WGt5J8Ij/rvyqpFexxk3ffKqqbLf9AqrTBbWDk7ApGUzaIs6V+s2s84kAxklFwmMF/vBNGrVdYgbblCOFFezMQ==} cpu: [x64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-x64-musl@4.54.0': resolution: {integrity: sha512-JzQmb38ATzHjxlPHuTH6tE7ojnMKM2kYNzt44LO/jJi8BpceEC8QuXYA908n8r3CNuG/B3BV8VR3Hi1rYtmPiw==} cpu: [x64] os: [linux] + libc: [musl] '@rollup/rollup-openharmony-arm64@4.54.0': resolution: {integrity: sha512-huT3fd0iC7jigGh7n3q/+lfPcXxBi+om/Rs3yiFxjvSxbSB6aohDFXbWvlspaqjeOh+hx7DDHS+5Es5qRkWkZg==} @@ -1493,30 +1510,35 @@ packages: engines: {node: '>= 10'} cpu: [arm64] os: [linux] + libc: [glibc] '@tauri-apps/cli-linux-arm64-musl@2.9.6': resolution: {integrity: sha512-02TKUndpodXBCR0oP//6dZWGYcc22Upf2eP27NvC6z0DIqvkBBFziQUcvi2n6SrwTRL0yGgQjkm9K5NIn8s6jw==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] + libc: [musl] '@tauri-apps/cli-linux-riscv64-gnu@2.9.6': resolution: {integrity: sha512-fmp1hnulbqzl1GkXl4aTX9fV+ubHw2LqlLH1PE3BxZ11EQk+l/TmiEongjnxF0ie4kV8DQfDNJ1KGiIdWe1GvQ==} engines: {node: '>= 10'} cpu: [riscv64] os: [linux] + libc: [glibc] '@tauri-apps/cli-linux-x64-gnu@2.9.6': resolution: {integrity: sha512-vY0le8ad2KaV1PJr+jCd8fUF9VOjwwQP/uBuTJvhvKTloEwxYA/kAjKK9OpIslGA9m/zcnSo74czI6bBrm2sYA==} engines: {node: '>= 10'} cpu: [x64] os: [linux] + libc: [glibc] '@tauri-apps/cli-linux-x64-musl@2.9.6': resolution: {integrity: sha512-TOEuB8YCFZTWVDzsO2yW0+zGcoMiPPwcUgdnW1ODnmgfwccpnihDRoks+ABT1e3fHb1ol8QQWsHSCovb3o2ENQ==} engines: {node: '>= 10'} cpu: [x64] os: [linux] + libc: [musl] '@tauri-apps/cli-win32-arm64-msvc@2.9.6': resolution: {integrity: sha512-ujmDGMRc4qRLAnj8nNG26Rlz9klJ0I0jmZs2BPpmNNf0gM/rcVHhqbEkAaHPTBVIrtUdf7bGvQAD2pyIiUrBHQ==} @@ -1553,6 +1575,9 @@ packages: '@tauri-apps/plugin-shell@2.3.3': resolution: {integrity: sha512-Xod+pRcFxmOWFWEnqH5yZcA7qwAMuaaDkMR1Sply+F8VfBj++CGnj2xf5UoialmjZ2Cvd8qrvSCbU+7GgNVsKQ==} + '@tonejs/midi@2.0.28': + resolution: {integrity: sha512-RII6YpInPsOZ5t3Si/20QKpNqB1lZ2OCFJSOzJxz38YdY/3zqDr3uaml4JuCWkdixuPqP1/TBnXzhQ39csyoVg==} + '@tootallnate/once@2.0.0': resolution: {integrity: sha512-XCuKFP5PS55gnMVu3dty8KPatLqUoy/ZYzDzAGCQ8JNFCkLXzmI7vNHCR+XpbZaMWQK/vQubr7PkYq8g470J/A==} engines: {node: '>= 10'} @@ -1833,6 +1858,9 @@ packages: resolution: {integrity: sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==} engines: {node: '>=10'} + array-flatten@3.0.0: + resolution: {integrity: sha512-zPMVc3ZYlGLNk4mpK1NzP2wg0ml9t7fUgDsayR5Y5rSzxQilzR9FGu/EH2jQOcKSAeAfWeylyW8juy3OkWRvNA==} + assertion-error@2.0.1: resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} engines: {node: '>=12'} @@ -1840,6 +1868,10 @@ packages: asynckit@0.4.0: resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} + automation-events@7.1.14: + resolution: {integrity: sha512-33doW0iTYXR2gSNBEKosQfcUZw1j3PCo3le41Wp3LRKStYXjWejTSjkd38Tm6b5AF0k+0IHmgDO0hfVROyDoUQ==} + engines: {node: '>=18.2.0'} + autoprefixer@10.4.23: resolution: {integrity: sha512-YYTXSFulfwytnjAPlw8QHncHJmlvFKtczb8InXaAx9Q0LbfDnfEYDE55omerIJKihhmU61Ft+cAOSzQVaBUmeA==} engines: {node: ^10 || ^12 || >=14} @@ -2993,6 +3025,9 @@ packages: resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} engines: {node: '>=8.6'} + midi-file@1.2.4: + resolution: {integrity: sha512-B5SnBC6i2bwJIXTY9MElIydJwAmnKx+r5eJ1jknTLetzLflEl0GWveuBB6ACrQpecSRkOB6fhTx1PwXk2BVxnA==} + mime-db@1.52.0: resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==} engines: {node: '>= 0.6'} @@ -3499,6 +3534,9 @@ packages: stackback@0.0.2: resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + standardized-audio-context@25.3.77: + resolution: {integrity: sha512-Ki9zNz6pKcC5Pi+QPjPyVsD9GwJIJWgryji0XL9cAJXMGyn+dPOf6Qik1AHei0+UNVcc4BOCa0hWLBzlwqsW/A==} + std-env@3.10.0: resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==} @@ -3603,6 +3641,9 @@ packages: resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} engines: {node: '>=8.0'} + tone@15.1.22: + resolution: {integrity: sha512-TCScAGD4sLsama5DjvTUXlLDXSqPealhL64nsdV1hhr6frPWve0DeSo63AKnSJwgfg55fhvxj0iPPRwPN5o0ag==} + tough-cookie@4.1.4: resolution: {integrity: sha512-Loo5UUvLD9ScZ6jh8beX1T6sO1w2/MpCRpEP7V280GKMVUQ0Jzar2U3UJPsrdbziLEMMhu3Ujnq//rhiFuIeag==} engines: {node: '>=6'} @@ -5111,6 +5152,11 @@ snapshots: dependencies: '@tauri-apps/api': 2.9.1 + '@tonejs/midi@2.0.28': + dependencies: + array-flatten: 3.0.0 + midi-file: 1.2.4 + '@tootallnate/once@2.0.0': optional: true @@ -5439,11 +5485,18 @@ snapshots: dependencies: tslib: 2.8.1 + array-flatten@3.0.0: {} + assertion-error@2.0.1: {} asynckit@0.4.0: optional: true + automation-events@7.1.14: + dependencies: + '@babel/runtime': 7.28.4 + tslib: 2.8.1 + autoprefixer@10.4.23(postcss@8.5.6): dependencies: browserslist: 4.28.1 @@ -7006,6 +7059,8 @@ snapshots: braces: 3.0.3 picomatch: 2.3.1 + midi-file@1.2.4: {} + mime-db@1.52.0: optional: true @@ -7543,6 +7598,12 @@ snapshots: stackback@0.0.2: {} + standardized-audio-context@25.3.77: + dependencies: + '@babel/runtime': 7.28.4 + automation-events: 7.1.14 + tslib: 2.8.1 + std-env@3.10.0: {} string-width@4.2.3: @@ -7684,6 +7745,11 @@ snapshots: dependencies: is-number: 7.0.0 + tone@15.1.22: + dependencies: + standardized-audio-context: 25.3.77 + tslib: 2.8.1 + tough-cookie@4.1.4: dependencies: psl: 1.15.0 diff --git a/src-tauri/src/commands/api_key_provider_cmd.rs b/src-tauri/src/commands/api_key_provider_cmd.rs index 7dc76c124..ced6faba7 100644 --- a/src-tauri/src/commands/api_key_provider_cmd.rs +++ b/src-tauri/src/commands/api_key_provider_cmd.rs @@ -45,6 +45,8 @@ pub struct UpdateProviderRequest { pub project: Option, pub location: Option, pub region: Option, + /// 自定义模型列表 + pub custom_models: Option>, } /// 添加 API Key 请求 @@ -71,6 +73,8 @@ pub struct ProviderDisplay { pub project: Option, pub location: Option, pub region: Option, + /// 自定义模型列表 + pub custom_models: Vec, pub api_key_count: usize, pub created_at: String, pub updated_at: String, @@ -130,6 +134,7 @@ fn provider_to_display(provider: &ApiKeyProvider, api_key_count: usize) -> Provi project: provider.project.clone(), location: provider.location.clone(), region: provider.region.clone(), + custom_models: provider.custom_models.clone(), api_key_count, created_at: provider.created_at.to_rfc3339(), updated_at: provider.updated_at.to_rfc3339(), @@ -247,6 +252,7 @@ pub fn update_api_key_provider( request.project, request.location, request.region, + request.custom_models, )?; // 获取 API Key 数量 diff --git a/src-tauri/src/database/dao/api_key_provider.rs b/src-tauri/src/database/dao/api_key_provider.rs index 2b673fddc..c2d476db0 100644 --- a/src-tauri/src/database/dao/api_key_provider.rs +++ b/src-tauri/src/database/dao/api_key_provider.rs @@ -126,6 +126,10 @@ pub struct ApiKeyProvider { pub project: Option, pub location: Option, pub region: Option, + /// 自定义模型列表(JSON 数组格式存储) + /// 用于不支持 /models 接口的 Provider(如智谱) + #[serde(default)] + pub custom_models: Vec, pub created_at: DateTime, pub updated_at: DateTime, } @@ -166,7 +170,7 @@ impl ApiKeyProviderDao { pub fn get_all_providers(conn: &Connection) -> Result, rusqlite::Error> { let mut stmt = conn.prepare( "SELECT id, name, type, api_host, is_system, group_name, enabled, sort_order, - api_version, project, location, region, created_at, updated_at + api_version, project, location, region, custom_models, created_at, updated_at FROM api_key_providers ORDER BY sort_order ASC, created_at ASC", )?; @@ -186,7 +190,7 @@ impl ApiKeyProviderDao { ) -> Result, rusqlite::Error> { let mut stmt = conn.prepare( "SELECT id, name, type, api_host, is_system, group_name, enabled, sort_order, - api_version, project, location, region, created_at, updated_at + api_version, project, location, region, custom_models, created_at, updated_at FROM api_key_providers WHERE id = ?1", )?; @@ -206,7 +210,7 @@ impl ApiKeyProviderDao { ) -> Result, rusqlite::Error> { let mut stmt = conn.prepare( "SELECT id, name, type, api_host, is_system, group_name, enabled, sort_order, - api_version, project, location, region, created_at, updated_at + api_version, project, location, region, custom_models, created_at, updated_at FROM api_key_providers WHERE group_name = ?1 ORDER BY sort_order ASC, created_at ASC", @@ -225,11 +229,17 @@ impl ApiKeyProviderDao { conn: &Connection, provider: &ApiKeyProvider, ) -> Result<(), rusqlite::Error> { + let custom_models_json = if provider.custom_models.is_empty() { + None + } else { + Some(serde_json::to_string(&provider.custom_models).unwrap_or_default()) + }; + conn.execute( "INSERT INTO api_key_providers (id, name, type, api_host, is_system, group_name, enabled, sort_order, - api_version, project, location, region, created_at, updated_at) - VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14)", + api_version, project, location, region, custom_models, created_at, updated_at) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15)", params![ provider.id, provider.name, @@ -243,6 +253,7 @@ impl ApiKeyProviderDao { provider.project, provider.location, provider.region, + custom_models_json, provider.created_at.to_rfc3339(), provider.updated_at.to_rfc3339(), ], @@ -255,11 +266,17 @@ impl ApiKeyProviderDao { conn: &Connection, provider: &ApiKeyProvider, ) -> Result<(), rusqlite::Error> { + let custom_models_json = if provider.custom_models.is_empty() { + None + } else { + Some(serde_json::to_string(&provider.custom_models).unwrap_or_default()) + }; + conn.execute( "UPDATE api_key_providers SET name = ?2, type = ?3, api_host = ?4, is_system = ?5, group_name = ?6, enabled = ?7, sort_order = ?8, api_version = ?9, project = ?10, - location = ?11, region = ?12, updated_at = ?13 + location = ?11, region = ?12, custom_models = ?13, updated_at = ?14 WHERE id = ?1", params![ provider.id, @@ -274,6 +291,7 @@ impl ApiKeyProviderDao { provider.project, provider.location, provider.region, + custom_models_json, provider.updated_at.to_rfc3339(), ], )?; @@ -311,8 +329,9 @@ impl ApiKeyProviderDao { let project: Option = row.get(9)?; let location: Option = row.get(10)?; let region: Option = row.get(11)?; - let created_at_str: String = row.get(12)?; - let updated_at_str: String = row.get(13)?; + let custom_models_json: Option = row.get(12)?; + let created_at_str: String = row.get(13)?; + let updated_at_str: String = row.get(14)?; let provider_type: ApiProviderType = type_str.parse().unwrap_or(ApiProviderType::Openai); let group: ProviderGroup = group_str.parse().unwrap_or(ProviderGroup::Custom); @@ -324,6 +343,11 @@ impl ApiKeyProviderDao { .map(|dt| dt.with_timezone(&Utc)) .unwrap_or_else(|_| Utc::now()); + // 解析自定义模型列表 + let custom_models: Vec = custom_models_json + .and_then(|json| serde_json::from_str(&json).ok()) + .unwrap_or_default(); + Ok(ApiKeyProvider { id, name, @@ -337,6 +361,7 @@ impl ApiKeyProviderDao { project, location, region, + custom_models, created_at, updated_at, }) @@ -398,7 +423,7 @@ impl ApiKeyProviderDao { k.usage_count, k.error_count, k.last_used_at, k.created_at, p.id, p.name, p.type, p.api_host, p.is_system, p.group_name, p.enabled, p.sort_order, p.api_version, p.project, p.location, p.region, - p.created_at, p.updated_at + p.custom_models, p.created_at, p.updated_at FROM api_keys k JOIN api_key_providers p ON k.provider_id = p.id WHERE p.type = ?1 AND k.enabled = 1 AND p.enabled = 1 @@ -431,8 +456,9 @@ impl ApiKeyProviderDao { }; // 解析 Provider - let provider_created_at_str: String = row.get(21)?; - let provider_updated_at_str: String = row.get(22)?; + let custom_models_json: Option = row.get(21)?; + let provider_created_at_str: String = row.get(22)?; + let provider_updated_at_str: String = row.get(23)?; let provider_created_at = DateTime::parse_from_rfc3339(&provider_created_at_str) .map(|dt| dt.with_timezone(&Utc)) .unwrap_or_else(|_| Utc::now()); @@ -440,6 +466,11 @@ impl ApiKeyProviderDao { .map(|dt| dt.with_timezone(&Utc)) .unwrap_or_else(|_| Utc::now()); + // 解析自定义模型列表 + let custom_models: Vec = custom_models_json + .and_then(|json| serde_json::from_str(&json).ok()) + .unwrap_or_default(); + let provider = ApiKeyProvider { id: row.get(9)?, name: row.get(10)?, @@ -459,6 +490,7 @@ impl ApiKeyProviderDao { project: row.get(18)?, location: row.get(19)?, region: row.get(20)?, + custom_models, created_at: provider_created_at, updated_at: provider_updated_at, }; @@ -666,7 +698,7 @@ impl ApiKeyProviderDao { ) -> Result, rusqlite::Error> { let mut stmt = conn.prepare( "SELECT id, name, type, api_host, is_system, group_name, enabled, sort_order, - api_version, project, location, region, created_at, updated_at + api_version, project, location, region, custom_models, created_at, updated_at FROM api_key_providers WHERE enabled = 1 ORDER BY sort_order ASC, created_at ASC", diff --git a/src-tauri/src/database/schema.rs b/src-tauri/src/database/schema.rs index 4a6a0b216..2b4ca1f96 100644 --- a/src-tauri/src/database/schema.rs +++ b/src-tauri/src/database/schema.rs @@ -17,12 +17,19 @@ pub fn create_tables(conn: &Connection) -> Result<(), rusqlite::Error> { project TEXT, location TEXT, region TEXT, + custom_models TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL )", [], )?; + // Migration: 添加 custom_models 列(如果不存在) + let _ = conn.execute( + "ALTER TABLE api_key_providers ADD COLUMN custom_models TEXT", + [], + ); + // 创建 api_key_providers 索引 conn.execute( "CREATE INDEX IF NOT EXISTS idx_api_key_providers_group ON api_key_providers(group_name)", diff --git a/src-tauri/src/database/system_providers.rs b/src-tauri/src/database/system_providers.rs index 3606a82d5..646b89233 100644 --- a/src-tauri/src/database/system_providers.rs +++ b/src-tauri/src/database/system_providers.rs @@ -626,6 +626,7 @@ pub fn to_api_key_provider(def: &SystemProviderDef) -> ApiKeyProvider { project: None, location: None, region: None, + custom_models: Vec::new(), created_at: now, updated_at: now, } diff --git a/src-tauri/src/providers/README.md b/src-tauri/src/providers/README.md index 496ffae21..c0a78a2a2 100644 --- a/src-tauri/src/providers/README.md +++ b/src-tauri/src/providers/README.md @@ -1,11 +1,3 @@ - # providers diff --git a/src-tauri/src/providers/openai_custom.rs b/src-tauri/src/providers/openai_custom.rs index 461bc51e4..99b8f4e54 100644 --- a/src-tauri/src/providers/openai_custom.rs +++ b/src-tauri/src/providers/openai_custom.rs @@ -54,16 +54,34 @@ impl OpenAICustomProvider { } /// 构建完整的 API URL - /// 智能处理用户输入的 base_url,无论是否带 /v1 都能正确工作 + /// 智能处理用户输入的 base_url,支持多种 API 版本格式 + /// + /// 支持的格式: + /// - `https://api.openai.com` -> `https://api.openai.com/v1/chat/completions` + /// - `https://api.openai.com/v1` -> `https://api.openai.com/v1/chat/completions` + /// - `https://open.bigmodel.cn/api/paas/v4` -> `https://open.bigmodel.cn/api/paas/v4/chat/completions` + /// - `https://api.deepseek.com/v1` -> `https://api.deepseek.com/v1/chat/completions` fn build_url(&self, endpoint: &str) -> String { let base = self.get_base_url(); let base = base.trim_end_matches('/'); - // 如果用户输入了带 /v1 的 URL,直接拼接 endpoint - // 否则拼接 /v1/endpoint - if base.ends_with("/v1") { + // 检查是否已经包含版本号路径(/v1, /v2, /v3, /v4 等) + // 使用正则匹配 /v 后跟数字的模式 + let has_version = base + .rsplit('/') + .next() + .map(|last_segment| { + last_segment.starts_with('v') + && last_segment.len() >= 2 + && last_segment[1..].chars().all(|c| c.is_ascii_digit()) + }) + .unwrap_or(false); + + if has_version { + // 已有版本号,直接拼接 endpoint format!("{}/{}", base, endpoint) } else { + // 没有版本号,添加 /v1 format!("{}/v1/{}", base, endpoint) } } @@ -104,6 +122,9 @@ impl OpenAICustomProvider { .ok_or("OpenAI API key not configured")?; let url = self.build_url("chat/completions"); + + eprintln!("[OPENAI_CUSTOM] chat_completions URL: {}", url); + eprintln!("[OPENAI_CUSTOM] chat_completions base_url: {}", self.get_base_url()); let resp = self .client @@ -125,6 +146,8 @@ impl OpenAICustomProvider { .ok_or("OpenAI API key not configured")?; let url = self.build_url("models"); + + eprintln!("[OPENAI_CUSTOM] list_models URL: {}", url); let resp = self .client @@ -136,6 +159,7 @@ impl OpenAICustomProvider { if !resp.status().is_success() { let status = resp.status(); let body = resp.text().await.unwrap_or_default(); + eprintln!("[OPENAI_CUSTOM] list_models 失败: {} - {}", status, body); return Err(format!("Failed to list models: {status} - {body}").into()); } diff --git a/src-tauri/src/services/api_key_provider_service.rs b/src-tauri/src/services/api_key_provider_service.rs index d67834b71..5465d4580 100644 --- a/src-tauri/src/services/api_key_provider_service.rs +++ b/src-tauri/src/services/api_key_provider_service.rs @@ -242,6 +242,7 @@ impl ApiKeyProviderService { project, location, region, + custom_models: Vec::new(), created_at: now, updated_at: now, }; @@ -265,6 +266,7 @@ impl ApiKeyProviderService { project: Option, location: Option, region: Option, + custom_models: Option>, ) -> Result { let conn = db.lock().map_err(|e| e.to_string())?; let mut provider = ApiKeyProviderDao::get_provider_by_id(&conn, id) @@ -296,6 +298,9 @@ impl ApiKeyProviderService { if let Some(r) = region { provider.region = if r.is_empty() { None } else { Some(r) }; } + if let Some(models) = custom_models { + provider.custom_models = models; + } provider.updated_at = Utc::now(); ApiKeyProviderDao::update_provider(&conn, &provider).map_err(|e| e.to_string())?; diff --git a/src/components/api-server/ApiServerPage.tsx b/src/components/api-server/ApiServerPage.tsx index e351f2bf4..d40242945 100644 --- a/src/components/api-server/ApiServerPage.tsx +++ b/src/components/api-server/ApiServerPage.tsx @@ -527,8 +527,27 @@ export function ApiServerPage() { const serverUrl = getTestUrl(currentHost, currentPort); const apiKey = config?.server.api_key ?? ""; + // 获取当前选中 Provider 的自定义模型列表 + const getCurrentProviderCustomModels = (): string[] => { + // 先从 API Key Provider 中查找 + const apiKeyProvider = apiKeyProviders.find( + (p) => p.id === defaultProvider && p.enabled + ); + if (apiKeyProvider?.custom_models && apiKeyProvider.custom_models.length > 0) { + return apiKeyProvider.custom_models; + } + return []; + }; + // 根据 Provider 类型获取测试模型 const getTestModel = (provider: string): string => { + // 优先使用自定义模型列表中的第一个模型 + const customModels = getCurrentProviderCustomModels(); + if (customModels.length > 0) { + return customModels[0]; + } + + // 否则使用默认模型 switch (provider) { case "antigravity": return "gemini-3-pro-preview"; @@ -542,6 +561,8 @@ export function ApiServerPage() { return "claude-sonnet-4-20250514"; case "deepseek": return "deepseek-chat"; + case "zhipu": + return "glm-4"; case "kiro": default: return "claude-opus-4-5-20251101"; @@ -549,6 +570,7 @@ export function ApiServerPage() { }; const testModel = getTestModel(defaultProvider); + const customModels = getCurrentProviderCustomModels(); // 根据 Provider 类型获取 Gemini 测试模型列表 const getGeminiTestModels = (provider: string): string[] => { @@ -593,7 +615,7 @@ export function ApiServerPage() { }, { id: "chat", - name: "OpenAI Chat", + name: `OpenAI Chat (${testModel})`, method: "POST", path: "/v1/chat/completions", needsAuth: true, @@ -602,9 +624,23 @@ export function ApiServerPage() { messages: [{ role: "user", content: "Say hi in one word" }], }), }, + // 为自定义模型列表中的其他模型生成测试端点 + ...(customModels.length > 1 + ? customModels.slice(1).map((model, index) => ({ + id: `custom-model-${index}`, + name: `OpenAI Chat (${model})`, + method: "POST", + path: "/v1/chat/completions", + needsAuth: true, + body: JSON.stringify({ + model: model, + messages: [{ role: "user", content: "Say hi in one word" }], + }), + })) + : []), { id: "anthropic", - name: "Anthropic Messages", + name: `Anthropic Messages (${testModel})`, method: "POST", path: "/v1/messages", needsAuth: true, diff --git a/src/components/provider-pool/api-key/ProviderConfigForm.tsx b/src/components/provider-pool/api-key/ProviderConfigForm.tsx index 22c46b4fa..44a96de65 100644 --- a/src/components/provider-pool/api-key/ProviderConfigForm.tsx +++ b/src/components/provider-pool/api-key/ProviderConfigForm.tsx @@ -86,6 +86,7 @@ interface FormState { project: string; location: string; region: string; + customModels: string; } // ============================================================================ @@ -125,6 +126,7 @@ export const ProviderConfigForm: React.FC = ({ project: provider.project || "", location: provider.location || "", region: provider.region || "", + customModels: (provider.custom_models || []).join(", "), }); // 保存状态 @@ -143,6 +145,7 @@ export const ProviderConfigForm: React.FC = ({ project: provider.project || "", location: provider.location || "", region: provider.region || "", + customModels: (provider.custom_models || []).join(", "), }); setSaveError(null); }, [ @@ -152,6 +155,7 @@ export const ProviderConfigForm: React.FC = ({ provider.project, provider.location, provider.region, + provider.custom_models, ]); // 保存配置 @@ -163,12 +167,19 @@ export const ProviderConfigForm: React.FC = ({ setSaveError(null); try { + // 解析自定义模型列表(逗号分隔) + const customModels = state.customModels + .split(",") + .map((m) => m.trim()) + .filter((m) => m.length > 0); + const request: UpdateProviderRequest = { api_host: state.apiHost || undefined, api_version: state.apiVersion || undefined, project: state.project || undefined, location: state.location || undefined, region: state.region || undefined, + custom_models: customModels.length > 0 ? customModels : undefined, }; await onUpdate(provider.id, request); @@ -330,6 +341,25 @@ export const ProviderConfigForm: React.FC = ({ )} + {/* 自定义模型列表 */} +
+ + handleFieldChange("customModels", e.target.value)} + placeholder="glm-4, glm-4-flash, glm-4.7" + disabled={loading || isSaving} + data-testid="custom-models-input" + /> +

+ 该 Provider 支持的模型列表,用逗号分隔。用于不支持 /models 接口的 Provider(如智谱) +

+
+ {/* 保存状态指示 */}
{isSaving ? ( diff --git a/src/lib/api/apiKeyProvider.ts b/src/lib/api/apiKeyProvider.ts index 3cea6a91f..7622f0d80 100644 --- a/src/lib/api/apiKeyProvider.ts +++ b/src/lib/api/apiKeyProvider.ts @@ -38,6 +38,8 @@ export interface UpdateProviderRequest { project?: string; location?: string; region?: string; + /** 自定义模型列表 */ + custom_models?: string[]; } /** @@ -69,6 +71,8 @@ export interface ProviderDisplay { project?: string; location?: string; region?: string; + /** 自定义模型列表 */ + custom_models?: string[]; api_key_count: number; created_at: string; updated_at: string;