diff --git a/docs/PLAN.md b/docs/PLAN.md index 57cb86a..1b75cb3 100644 --- a/docs/PLAN.md +++ b/docs/PLAN.md @@ -41,7 +41,7 @@ "hotkey": "Alt+Q", "engine": "free", // "free" | "llm" "free": { "provider": "microsoft" }, // "microsoft" | "google" - "llm": { "provider": "deepseek", "baseUrl": "https://api.deepseek.com", "model": "deepseek-chat", "apiKey": "<加密存储>" }, + "llm": { "provider": "zhipu", "baseUrl": "https://open.bigmodel.cn/api/paas/v4", "model": "glm-4.7-flash", "apiKey": "<加密存储>" }, "livePreview": true, "previewDelayMs": 500, "restoreClipboard": true, @@ -62,7 +62,8 @@ API Key 用 Electron `safeStorage` 加密后存盘;永远不把明文 Key 发 | `onShow(cb)` | 浮窗被热键呼出时:`cb({engine, direction})` | | `getConfig()` → `Promise` | apiKey 被替换为 `apiKeySet: boolean` | | `setConfig(partial)` → `Promise` | 深合并;`llm.apiKey` 为空字符串/缺省表示不改;热键变更立即重新注册,失败则 reject 并保持旧热键 | -| `getProviders()` → `Promise` | 大模型预设列表 | +| `getProviders()` → `Promise` | 大模型预设列表;`models[]` 带 `free`(free / quota)和 `note`,请求参数 `extra` 只留在主进程 | +| `listModels(providerId)` → `Promise` | 设置页模型下拉框:预设精选 + OpenRouter 实时免费模型(`live: true`),拉取失败只返回预设 | | `testEngine(partialConfig?)` → `Promise<{ok, sample, ms, error?}>` | 用当前或传入(未保存)配置翻译一句测试文本 | | `openSettings()` | 打开设置窗口 | | `onConfigChanged(cb)` | 配置变更广播给所有窗口 | diff --git a/src/main/config.js b/src/main/config.js index 2f63c4e..3153a35 100644 --- a/src/main/config.js +++ b/src/main/config.js @@ -8,9 +8,9 @@ const DEFAULT_CONFIG = { engine: 'free', free: { provider: 'microsoft' }, llm: { - provider: 'deepseek', - baseUrl: 'https://api.deepseek.com', - model: 'deepseek-chat', + provider: 'zhipu', + baseUrl: 'https://open.bigmodel.cn/api/paas/v4', + model: 'glm-4.7-flash', apiKey: '', // 当前服务商的 Key(仅内存,由 apiKeys 推出) apiKeys: {}, // 每个服务商各存一份,切换服务商时不会把 A 的 Key 发给 B }, diff --git a/src/main/engines/llm.js b/src/main/engines/llm.js index 730bd90..a797b70 100644 --- a/src/main/engines/llm.js +++ b/src/main/engines/llm.js @@ -1,6 +1,6 @@ 'use strict'; -const { getProvider } = require('../providers'); +const { getProvider, modelExtra, isFreeModel } = require('../providers'); const { buildPrompt, cleanOutput } = require('../prompt'); const { fetchWithTimeout, httpFail } = require('./http'); const { fail } = require('../errors'); @@ -19,6 +19,13 @@ function maxTokensFor(text) { return Math.min(8192, Math.max(2048, String(text).length * 4 + 256)); } +function sleep(ms, signal) { + return new Promise((resolve, reject) => { + const timer = setTimeout(resolve, ms); + if (signal) signal.addEventListener('abort', () => { clearTimeout(timer); reject(fail('CANCEL', {})); }, { once: true }); + }); +} + function hostOf(baseUrl) { try { return new URL(baseUrl).host || '大模型接口'; @@ -71,6 +78,7 @@ async function translateLLM(opts) { headers = { 'content-type': 'application/json' }; if (apiKey) headers.Authorization = `Bearer ${apiKey}`; body = { + ...modelExtra(llm.provider, model), model, temperature: prompt.temperature, max_tokens: maxTokens, @@ -82,13 +90,21 @@ async function translateLLM(opts) { }; } - const res = await fetchWithTimeout(url, { + const send = () => fetchWithTimeout(url, { method: 'POST', headers, body: JSON.stringify(body), signal: opts.signal, }, { timeoutMs, service, fetchImpl: opts.fetchImpl }); + let res = await send(); + // 免费模型被限流往往一两秒就恢复,自动再试一次 + const free = isFreeModel(model); + if (res.status === 429 && free) { + await sleep(opts.retryDelayMs ?? 1000, opts.signal); + res = await send(); + } + if (res.status === 429 && free) throw fail('FREE_BUSY', { service, kind: 'llm' }); if (!res.ok) throw httpFail(res.status, service); let accumulated = ''; diff --git a/src/main/errors.js b/src/main/errors.js index 4c92e45..0f35fbb 100644 --- a/src/main/errors.js +++ b/src/main/errors.js @@ -40,8 +40,9 @@ function userMessage(err, ctx = {}) { if (err.code === 'NO_KEY') return '还没填大模型的 API Key,去设置里填一下'; if (err.code === 'NO_BASE_URL') return '还没填写大模型的接口地址'; if (err.code === 'NO_MODEL') return '还没填写模型名'; - if (err.code === 'REASONING_ONLY') return '这是推理模型,只顾着思考没给出译文。换成非推理模型(如 deepseek-chat)又快又稳'; + if (err.code === 'REASONING_ONLY') return '这是推理模型,只顾着思考没给出译文。换成下拉框里标“推荐”的模型,已自动关闭思考,又快又稳'; if (err.code === 'EMPTY') return '模型没有返回译文,换个模型试试'; + if (err.code === 'FREE_BUSY') return '免费模型这会儿太忙(被上游限流),过一会儿再试,或在设置里换一个模型'; const status = Number(err.status || err.statusCode || 0); const kind = ctx.kind || err.kind; diff --git a/src/main/main.js b/src/main/main.js index 565d09a..a35ea8c 100644 --- a/src/main/main.js +++ b/src/main/main.js @@ -24,6 +24,7 @@ app.disableHardwareAcceleration(); app.commandLine.appendSwitch('disable-features', 'CalculateNativeWinOcclusion'); const { PROVIDERS } = require('./providers'); +const { listModels } = require('./models'); const { createConfigStore, mergeConfig } = require('./config'); const { translate, warmup } = require('./engines'); const { captureForeground, commitPaste } = require('./paste'); @@ -439,7 +440,10 @@ function registerIpc() { ipcMain.handle('xt:setConfig', (_event, partial) => setConfig(partial || {})); - ipcMain.handle('xt:getProviders', () => PROVIDERS); + // 渲染进程只需要展示用的字段,请求参数(extra)留在主进程 + ipcMain.handle('xt:getProviders', () => PROVIDERS.map((p) => ({ ...p, models: p.models.map(({ extra, ...m }) => m) }))); + + ipcMain.handle('xt:listModels', (_event, providerId) => listModels(String(providerId || ''), { warn })); ipcMain.handle('xt:testEngine', async (_event, partial) => { const cfg = mergeConfig(store.get(), partial || {}); diff --git a/src/main/models.js b/src/main/models.js new file mode 100644 index 0000000..32b4498 --- /dev/null +++ b/src/main/models.js @@ -0,0 +1,49 @@ +'use strict'; + +// 设置页"模型"下拉框的选项:预设里精选的在前;OpenRouter 免费模型更新很快,再联网补上最新的。 +const { getProvider, NO_THINK_OPENROUTER } = require('./providers'); +const { fetchWithTimeout } = require('./engines/http'); + +const OPENROUTER_MODELS = 'https://openrouter.ai/api/v1/models'; +const CACHE_MS = 60 * 60 * 1000; +const MAX_EXTRA = 8; +// 审核、写代码、多模态这类专用模型不适合拿来翻译;4B 以下的小模型译文质量不行 +const UNSUITABLE = /safety|guard|code|coder|embed|vision|-vl\b|omni|ocr|audio|image|poolside|lfm-|[-_][0-3](\.\d+)?b\b/i; + +let cache = null; // { at, models } + +function pickFreeModels(data, exclude, limit = MAX_EXTRA) { + const skip = new Set(exclude); + return (Array.isArray(data) ? data : []) + .filter((m) => m && typeof m.id === 'string' && m.id.endsWith(':free')) + .filter((m) => !skip.has(m.id) && !UNSUITABLE.test(m.id)) + .sort((a, b) => (b.context_length || 0) - (a.context_length || 0)) + .slice(0, limit) + .map((m) => ({ id: m.id, free: 'free', extra: NO_THINK_OPENROUTER, live: true })); +} + +async function fetchOpenRouterFree(exclude, fetchImpl) { + if (cache && Date.now() - cache.at < CACHE_MS) return cache.models; + const res = await fetchWithTimeout(OPENROUTER_MODELS, {}, { timeoutMs: 6000, service: 'openrouter.ai', fetchImpl }); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + const json = await res.json(); + const models = pickFreeModels(json && json.data, exclude); + cache = { at: Date.now(), models }; + return models; +} + +/** @returns {Promise>} */ +async function listModels(providerId, { fetchImpl, warn } = {}) { + const provider = getProvider(providerId); + const curated = (provider.models || []).map(({ extra, ...rest }) => rest); + if (provider.id !== 'openrouter') return curated; + try { + const live = await fetchOpenRouterFree(curated.map((m) => m.id), fetchImpl); + return [...curated, ...live.map(({ extra, ...rest }) => rest)]; + } catch (err) { + if (warn) warn(`获取 OpenRouter 免费模型失败:${err && err.message}`); + return curated; + } +} + +module.exports = { listModels, pickFreeModels }; diff --git a/src/main/providers.js b/src/main/providers.js index 9ba2ed2..16d5baf 100644 --- a/src/main/providers.js +++ b/src/main/providers.js @@ -2,20 +2,117 @@ // 大模型预设。用户选好预设后 baseUrl / model 仍可自由修改。 // kind: 'openai' = OpenAI 兼容 /chat/completions;'anthropic' = Anthropic /v1/messages +// models[].free: 'free' = 长期免费;'quota' = 新用户赠送额度,用完收费 +// models[].extra: 合并进请求体的额外参数,主要用来关掉思考——翻译不需要推理,开着只会慢 +// 资料核对于 2026-10,免费政策变动快,以各家官网为准。 +const NO_THINK_GLM = { thinking: { type: 'disabled' } }; +const NO_THINK_OPENROUTER = { reasoning: { enabled: false } }; + const PROVIDERS = [ - { id: 'deepseek', name: 'DeepSeek', kind: 'openai', baseUrl: 'https://api.deepseek.com', model: 'deepseek-chat', models: ['deepseek-chat'], keyUrl: 'https://platform.deepseek.com/api_keys' }, - { id: 'qwen', name: '通义千问(阿里云百炼)', kind: 'openai', baseUrl: 'https://dashscope.aliyuncs.com/compatible-mode/v1', model: 'qwen-plus', models: ['qwen-plus', 'qwen-turbo', 'qwen-max'], keyUrl: 'https://bailian.console.aliyun.com/' }, - { id: 'moonshot', name: 'Kimi(月之暗面)', kind: 'openai', baseUrl: 'https://api.moonshot.cn/v1', model: 'moonshot-v1-8k', models: ['moonshot-v1-8k'], keyUrl: 'https://platform.moonshot.cn/console/api-keys' }, - { id: 'zhipu', name: '智谱 GLM', kind: 'openai', baseUrl: 'https://open.bigmodel.cn/api/paas/v4', model: 'glm-4-flash', models: ['glm-4-flash', 'glm-4-plus'], keyUrl: 'https://open.bigmodel.cn/usercenter/apikeys' }, - { id: 'anthropic', name: 'Claude(Anthropic)', kind: 'anthropic', baseUrl: 'https://api.anthropic.com', model: 'claude-haiku-4-5', models: ['claude-haiku-4-5', 'claude-sonnet-5-5'], keyUrl: 'https://console.anthropic.com/settings/keys' }, - { id: 'openai', name: 'OpenAI', kind: 'openai', baseUrl: 'https://api.openai.com/v1', model: 'gpt-4.1-mini', models: ['gpt-4.1-mini', 'gpt-4.1'], keyUrl: 'https://platform.openai.com/api-keys' }, - { id: 'openrouter', name: 'OpenRouter', kind: 'openai', baseUrl: 'https://openrouter.ai/api/v1', model: 'deepseek/deepseek-chat', models: [], keyUrl: 'https://openrouter.ai/keys' }, + { + id: 'zhipu', name: '智谱 GLM · 免费', kind: 'openai', baseUrl: 'https://open.bigmodel.cn/api/paas/v4', model: 'glm-4.7-flash', + keyUrl: 'https://open.bigmodel.cn/usercenter/apikeys', note: '国内直连,长期免费', + models: [ + { id: 'glm-4.7-flash', free: 'free', note: '推荐', extra: NO_THINK_GLM }, + { id: 'glm-4-flash-250414', free: 'free' }, + ], + }, + { + id: 'siliconflow', name: '硅基流动 · 免费', kind: 'openai', baseUrl: 'https://api.siliconflow.cn/v1', model: 'THUDM/GLM-4-9B-0414', + keyUrl: 'https://cloud.siliconflow.cn/account/ak', note: '国内直连,免费模型需实名认证', + models: [ + { id: 'THUDM/GLM-4-9B-0414', free: 'free', note: '推荐' }, + { id: 'Qwen/Qwen3-8B', free: 'free', extra: { enable_thinking: false } }, + { id: 'Qwen/Qwen2.5-7B-Instruct', free: 'free' }, + ], + }, + { + id: 'gemini', name: 'Google Gemini · 免费', kind: 'openai', baseUrl: 'https://generativelanguage.googleapis.com/v1beta/openai', model: 'gemini-2.5-flash-lite', + keyUrl: 'https://aistudio.google.com/apikey', note: '国内需代理;免费层的内容可能被用于改进模型', + models: [ + { id: 'gemini-2.5-flash-lite', free: 'free', note: '推荐' }, + { id: 'gemini-3.5-flash-lite', free: 'free' }, + ], + }, + { + id: 'groq', name: 'Groq · 免费', kind: 'openai', baseUrl: 'https://api.groq.com/openai/v1', model: 'qwen/qwen3.8-27b', + keyUrl: 'https://console.groq.com/keys', note: '国内需代理,速度极快,免费层有每日限额', + models: [ + { id: 'qwen/qwen3.8-27b', free: 'free', note: '推荐', extra: { reasoning_effort: 'none' } }, + { id: 'openai/gpt-oss-20b', free: 'free', extra: { reasoning_effort: 'low' } }, + ], + }, + { + id: 'cerebras', name: 'Cerebras · 免费', kind: 'openai', baseUrl: 'https://api.cerebras.ai/v1', model: 'qwen-3.8-27b', + keyUrl: 'https://cloud.cerebras.ai/', note: '国内需代理,速度极快,免费层每分钟 5 次', + models: [ + { id: 'qwen-3.8-27b', free: 'free', note: '推荐', extra: { reasoning_effort: 'none' } }, + { id: 'gpt-oss-120b', free: 'free', extra: { reasoning_effort: 'low' } }, + ], + }, + { + id: 'openrouter', name: 'OpenRouter · 有免费模型', kind: 'openai', baseUrl: 'https://openrouter.ai/api/v1', model: 'google/gemma-4-31b-it:free', + keyUrl: 'https://openrouter.ai/keys', note: '免费模型每天有次数限制,高峰期可能排队', + models: [ + // 顺序即备用顺序(取前面的当备用):前两个来自不同上游,一家限流时换另一家; + // nemotron 快但会译错词,放最后,不给别的模型当备用 + { id: 'google/gemma-4-31b-it:free', free: 'free', note: '推荐', extra: NO_THINK_OPENROUTER }, + { id: 'qwen/qwen3.8-27b:free', free: 'free', note: '推荐', extra: NO_THINK_OPENROUTER }, + { id: 'google/gemma-4-26b-a4b-it:free', free: 'free', extra: NO_THINK_OPENROUTER }, + { id: 'nvidia/nemotron-3.5-lightning:free', free: 'free', note: '最快', extra: NO_THINK_OPENROUTER }, + { id: 'deepseek/deepseek-chat' }, + ], + }, + { + id: 'qwen', name: '通义千问(阿里云百炼)', kind: 'openai', baseUrl: 'https://dashscope.aliyuncs.com/compatible-mode/v1', model: 'qwen-turbo', + keyUrl: 'https://bailian.console.aliyun.com/', note: '新用户每个模型送 100 万 token(90 天)', + models: [ + { id: 'qwen-turbo', free: 'quota', note: '推荐' }, + { id: 'qwen-flash' }, + { id: 'qwen-plus', free: 'quota' }, + ], + }, + { + id: 'deepseek', name: 'DeepSeek', kind: 'openai', baseUrl: 'https://api.deepseek.com', model: 'deepseek-flash', + keyUrl: 'https://platform.deepseek.com/api_keys', note: '按量付费,很便宜', + models: [ + { id: 'deepseek-flash', note: '已关闭思考', extra: NO_THINK_GLM }, + ], + }, + { + id: 'moonshot', name: 'Kimi(月之暗面)', kind: 'openai', baseUrl: 'https://api.moonshot.cn/v1', model: 'moonshot-v1-8k', keyUrl: 'https://platform.moonshot.cn/console/api-keys', + models: [{ id: 'moonshot-v1-8k' }], + }, + { + id: 'anthropic', name: 'Claude(Anthropic)', kind: 'anthropic', baseUrl: 'https://api.anthropic.com', model: 'claude-haiku-4-5', keyUrl: 'https://console.anthropic.com/settings/keys', + models: [{ id: 'claude-haiku-4-5' }, { id: 'claude-sonnet-5-5' }], + }, + { + id: 'openai', name: 'OpenAI', kind: 'openai', baseUrl: 'https://api.openai.com/v1', model: 'gpt-4.1-mini', keyUrl: 'https://platform.openai.com/api-keys', + models: [{ id: 'gpt-4.1-mini' }, { id: 'gpt-4.1' }], + }, { id: 'ollama', name: 'Ollama(本地)', kind: 'openai', baseUrl: 'http://localhost:11434/v1', model: 'qwen2.5:7b', models: [], keyOptional: true }, { id: 'custom', name: '自定义(OpenAI 兼容)', kind: 'openai', baseUrl: '', model: '', models: [] }, ]; +function isFreeModel(model) { + return /:free$/.test(String(model)); +} + function getProvider(id) { return PROVIDERS.find((p) => p.id === id) || PROVIDERS.find((p) => p.id === 'custom'); } -module.exports = { PROVIDERS, getProvider }; +// 当前服务商预设里这个模型要附带的请求参数;自定义模型名没有 +function modelExtra(providerId, model) { + const provider = getProvider(providerId); + const entry = (provider.models || []).find((m) => m.id === model); + if (provider.id === 'openrouter' && isFreeModel(model)) { + // 免费模型经常被上游限流:带上另外两个免费模型,OpenRouter 会自动换着试(最多 3 个) + const backups = provider.models.filter((m) => m.free === 'free' && m.id !== model).map((m) => m.id); + return { ...((entry && entry.extra) || NO_THINK_OPENROUTER), models: [model, ...backups].slice(0, 3) }; + } + return (entry && entry.extra) || {}; +} + +module.exports = { PROVIDERS, getProvider, modelExtra, isFreeModel, NO_THINK_OPENROUTER }; diff --git a/src/preload.js b/src/preload.js index 257e44e..6758020 100644 --- a/src/preload.js +++ b/src/preload.js @@ -19,6 +19,7 @@ contextBridge.exposeInMainWorld('xt', { getConfig: () => ipcRenderer.invoke('xt:getConfig'), setConfig: (partial) => ipcRenderer.invoke('xt:setConfig', partial), getProviders: () => ipcRenderer.invoke('xt:getProviders'), + listModels: (providerId) => ipcRenderer.invoke('xt:listModels', providerId), testEngine: (partial) => ipcRenderer.invoke('xt:testEngine', partial), openSettings: () => ipcRenderer.invoke('xt:openSettings'), onConfigChanged: (cb) => listen('xt:config', cb), diff --git a/src/renderer/dev-mock.js b/src/renderer/dev-mock.js index db3411c..d4bf5ae 100644 --- a/src/renderer/dev-mock.js +++ b/src/renderer/dev-mock.js @@ -4,7 +4,7 @@ if (!window.xt) { const listeners = { partial: [], show: [], config: [] }; let cfg = { hotkey: 'Alt+Q', engine: 'free', free: { provider: 'microsoft' }, - llm: { provider: 'deepseek', baseUrl: 'https://api.deepseek.com', model: 'deepseek-chat', apiKeySet: false }, + llm: { provider: 'zhipu', baseUrl: 'https://open.bigmodel.cn/api/paas/v4', model: 'glm-4.7-flash', apiKeySet: false }, livePreview: true, previewDelayMs: 500, restoreClipboard: true, launchAtLogin: false, }; const canned = { '我先撤了哈,明天见': "I'm heading out, see you tomorrow!", 'No worries, take your time!': '没事没事,你慢慢来!' }; @@ -40,11 +40,14 @@ if (!window.xt) { return structuredClone(cfg); }, getProviders: async () => [ - { id: 'deepseek', name: 'DeepSeek', baseUrl: 'https://api.deepseek.com', model: 'deepseek-chat', models: ['deepseek-chat'], keyUrl: 'https://platform.deepseek.com/api_keys' }, - { id: 'anthropic', name: 'Claude(Anthropic)', baseUrl: 'https://api.anthropic.com', model: 'claude-haiku-4-5', models: ['claude-haiku-4-5', 'claude-sonnet-5-5'], keyUrl: 'https://console.anthropic.com/settings/keys' }, + { id: 'zhipu', name: '智谱 GLM · 免费', baseUrl: 'https://open.bigmodel.cn/api/paas/v4', model: 'glm-4.7-flash', note: '国内直连,长期免费', keyUrl: 'https://open.bigmodel.cn/usercenter/apikeys', models: [{ id: 'glm-4.7-flash', free: 'free', note: '推荐' }, { id: 'glm-4-flash-250414', free: 'free' }] }, + { id: 'openrouter', name: 'OpenRouter · 有免费模型', baseUrl: 'https://openrouter.ai/api/v1', model: 'google/gemma-4-31b-it:free', keyUrl: 'https://openrouter.ai/keys', models: [{ id: 'google/gemma-4-31b-it:free', free: 'free', note: '推荐' }, { id: 'deepseek/deepseek-chat' }] }, { id: 'ollama', name: 'Ollama(本地)', baseUrl: 'http://localhost:11434/v1', model: 'qwen2.5:7b', models: [], keyOptional: true }, { id: 'custom', name: '自定义(OpenAI 兼容)', baseUrl: '', model: '', models: [] }, ], + listModels: async (id) => id === 'openrouter' + ? [{ id: 'google/gemma-4-31b-it:free', free: 'free', note: '推荐' }, { id: 'deepseek/deepseek-chat' }, { id: 'qwen/qwen3.8-27b:free', free: 'free', live: true }] + : [], testEngine: async ({ engine }) => engine === 'llm' && !cfg.llm.apiKeySet ? { ok: false, error: '还没填大模型的 API Key,去设置里填一下' } : { ok: true, sample: "I'm heading out, see you tomorrow!", ms: 420 }, diff --git a/src/renderer/settings.css b/src/renderer/settings.css index b375a8a..1b9f3a6 100644 --- a/src/renderer/settings.css +++ b/src/renderer/settings.css @@ -77,3 +77,4 @@ input:focus, select:focus { outline: 2px solid var(--accent); outline-offset: -1 .row { flex-wrap: wrap; } .row > label { flex-basis: 100%; } } +[hidden] { display: none !important; } diff --git a/src/renderer/settings.html b/src/renderer/settings.html index d390e4d..554c409 100644 --- a/src/renderer/settings.html +++ b/src/renderer/settings.html @@ -47,9 +47,12 @@
- - - + + +
+
+ +
diff --git a/src/renderer/settings.js b/src/renderer/settings.js index 5e06d95..172e1a4 100644 --- a/src/renderer/settings.js +++ b/src/renderer/settings.js @@ -4,6 +4,9 @@ const $ = (id) => document.getElementById(id); const api = window.xt; // 不能叫 xt:contextBridge 已把 xt 定义为不可重声明的全局 let providers = []; let cfg = null; +const liveModels = {}; // 服务商 id → 联网拿到的完整模型列表(目前只有 OpenRouter 会多出实时免费模型) +const CUSTOM = '__custom__'; +let customModel = false; // 用户在下拉框里选了"自定义模型…" const errMsg = (e) => (e && e.message ? e.message : String(e)).replace(/^Error invoking remote method '[^']+': (Error: )?/, ''); @@ -34,15 +37,15 @@ function render() { if (document.activeElement !== $('llm-base')) $('llm-base').value = llm.baseUrl || ''; if (document.activeElement !== $('llm-model')) $('llm-model').value = llm.model || ''; const p = providers.find((x) => x.id === llm.provider) || {}; - $('llm-models').replaceChildren(...(p.models || []).map((m) => Object.assign(document.createElement('option'), { value: m }))); + renderModels(p, llm.model || ''); $('llm-key').placeholder = llm.apiKeySet ? '已保存(留空则不修改)' : (p.keyOptional ? '本地模型可不填' : 'sk-…'); const tip = $('key-tip'); tip.replaceChildren(); if (p.keyUrl) { - const a = Object.assign(document.createElement('a'), { href: p.keyUrl, target: '_blank', textContent: `去 ${p.name} 申请 Key` }); - tip.append(a, ' Key 只加密保存在本机,只发给你选的服务商。', document.createElement('br'), - '建议用非推理模型(如 deepseek-chat、qwen-plus、glm-4-flash),推理模型要先思考,出译文慢很多。'); + const a = Object.assign(document.createElement('a'), { href: p.keyUrl, target: '_blank', textContent: `去 ${p.name.split(' · ')[0]} 申请 Key` }); + tip.append(a, ' Key 只加密保存在本机,只发给你选的服务商。'); + if (p.note) tip.append(document.createElement('br'), p.note + '。'); } else if (p.id === 'ollama') { tip.textContent = '需先在本机运行 Ollama 并拉取模型,例如 ollama pull qwen2.5:7b'; } @@ -56,6 +59,49 @@ function render() { $('login').checked = !!cfg.launchAtLogin; } +function modelLabel(m) { + const tags = []; + if (m.free === 'free') tags.push('免费'); + if (m.free === 'quota') tags.push('新用户免费额度'); + if (m.note) tags.push(m.note); + return tags.length ? `${m.id}(${tags.join(' · ')})` : m.id; +} + +function option(value, text) { + return Object.assign(document.createElement('option'), { value, textContent: text }); +} + +// 下拉框:精选模型 → 实时免费模型 → 自定义;当前模型不在列表里就落到"自定义" +function renderModels(p, model) { + const list = liveModels[p.id] || p.models || []; + const pick = $('llm-model-pick'); + const curated = list.filter((m) => !m.live); + const live = list.filter((m) => m.live); + const children = curated.map((m) => option(m.id, modelLabel(m))); + if (live.length) { + const group = Object.assign(document.createElement('optgroup'), { label: '更多免费模型(实时)' }); + group.append(...live.map((m) => option(m.id, modelLabel(m)))); + children.push(group); + } + children.push(option(CUSTOM, '自定义模型…')); + pick.replaceChildren(...children); + + const known = list.some((m) => m.id === model); + const custom = !list.length || customModel || !known; + pick.value = custom ? CUSTOM : model; + $('llm-model-pick').closest('.row').hidden = !list.length; + $('llm-model-row').hidden = !custom; + $('llm-model-label').textContent = list.length ? '模型名' : '模型'; +} + +async function loadModels(id) { + try { + const list = await api.listModels(id); + if (Array.isArray(list) && list.length) liveModels[id] = list; + render(); + } catch { /* 拉不到就只用预设 */ } +} + // —— 引擎 —— for (const r of document.querySelectorAll('input[name=engine]')) { r.addEventListener('change', () => save({ engine: r.value })); @@ -68,7 +114,21 @@ $('llm-provider').addEventListener('change', (e) => { const llm = { provider: p.id }; if (p.id !== 'custom') Object.assign(llm, { baseUrl: p.baseUrl, model: p.model }); setResult($('llm-result'), ''); + customModel = false; save({ llm }); + if (!liveModels[p.id]) loadModels(p.id); +}); +$('llm-model-pick').addEventListener('change', (e) => { + if (e.target.value === CUSTOM) { + customModel = true; + render(); + $('llm-model').focus(); + $('llm-model').select(); + return; + } + customModel = false; + setResult($('llm-result'), ''); + save({ llm: { model: e.target.value } }); }); $('llm-base').addEventListener('change', (e) => save({ llm: { baseUrl: e.target.value.trim() } })); $('llm-model').addEventListener('change', (e) => save({ llm: { model: e.target.value.trim() } })); @@ -168,4 +228,5 @@ api.onConfigChanged((c) => { cfg = c; render(); }); $('llm-provider').replaceChildren(...providers.map((p) => Object.assign(document.createElement('option'), { value: p.id, textContent: p.name }))); cfg = await api.getConfig(); render(); + loadModels(cfg.llm?.provider || 'zhipu'); })(); diff --git a/test/config.test.js b/test/config.test.js index 01a16a7..e209bff 100644 --- a/test/config.test.js +++ b/test/config.test.js @@ -62,7 +62,7 @@ test('加密可用时落盘不含明文,读回后内存里有 Key,公开接 const pub = store.set({ llm: { apiKey: SECRET } }); const disk = fs.readFileSync(file, 'utf8'); assert.equal(disk.includes(SECRET), false); - assert.match(disk, /"deepseek": "enc:/); + assert.match(disk, /"zhipu": "enc:/); assert.equal(pub.llm.apiKeySet, true); assert.equal(JSON.stringify(pub).includes(SECRET), false); assert.equal(store.get().llm.apiKey, SECRET); diff --git a/test/providers.test.js b/test/providers.test.js new file mode 100644 index 0000000..ea81486 --- /dev/null +++ b/test/providers.test.js @@ -0,0 +1,95 @@ +'use strict'; + +const test = require('node:test'); +const assert = require('node:assert'); +const { PROVIDERS, getProvider, modelExtra } = require('../src/main/providers'); +const { translateLLM } = require('../src/main/engines/llm'); +const { pickFreeModels } = require('../src/main/models'); + +test('每个预设的默认模型都在自己的模型列表里,免费标记只用 free / quota', () => { + for (const p of PROVIDERS) { + if (!p.models.length) continue; + assert.ok(p.models.some((m) => m.id === p.model), p.id); + for (const m of p.models) assert.ok(m.free === undefined || m.free === 'free' || m.free === 'quota', m.id); + } + assert.equal(getProvider('nope').id, 'custom'); +}); + +test('预设模型的额外参数会进请求体,自定义模型名不带', async () => { + assert.deepEqual(modelExtra('zhipu', 'glm-4.7-flash'), { thinking: { type: 'disabled' } }); + assert.deepEqual(modelExtra('zhipu', 'my-own-model'), {}); + let body; + await translateLLM({ + text: '你好', + direction: 'zh2en', + llm: { provider: 'deepseek', baseUrl: 'https://example.com', model: 'deepseek-flash', apiKey: 'k' }, + fetchImpl: async (_url, options) => { + body = JSON.parse(options.body); + return { + ok: true, + status: 200, + body: new ReadableStream({ start(c) { c.enqueue(Buffer.from('data: {"choices":[{"delta":{"content":"Hi"}}]}\n\ndata: [DONE]\n\n')); c.close(); } }), + }; + }, + }); + assert.deepEqual(body.thinking, { type: 'disabled' }); + assert.equal(body.model, 'deepseek-flash'); + assert.equal(body.stream, true); +}); + +test('OpenRouter 免费模型:只留 :free、去掉已推荐的和不适合翻译的,按上下文排序截断', () => { + const data = [ + { id: 'google/gemma-4-31b-it:free', name: 'Gemma 4 31B (free)', context_length: 262144 }, + { id: 'foo/bar:free', name: 'Bar (free)', context_length: 8000 }, + { id: 'foo/big:free', name: 'Big (free)', context_length: 128000 }, + { id: 'nvidia/nemotron-3.5-content-safety:free', name: 'Safety', context_length: 128000 }, + { id: 'cohere/north-mini-code:free', name: 'Code', context_length: 256000 }, + { id: 'paid/model', name: 'Paid', context_length: 999999 }, + ]; + const out = pickFreeModels(data, ['google/gemma-4-31b-it:free'], 10); + assert.deepEqual(out.map((m) => m.id), ['foo/big:free', 'foo/bar:free']); + assert.equal(out[0].free, 'free'); + assert.equal(out[0].extra.reasoning.enabled, false); + assert.equal(pickFreeModels(data, [], 1).length, 1); +}); + +function okStream(text) { + return { + ok: true, + status: 200, + body: new ReadableStream({ start(c) { c.enqueue(Buffer.from(`data: {"choices":[{"delta":{"content":"${text}"}}]}\n\ndata: [DONE]\n\n`)); c.close(); } }), + }; +} +const busy = () => ({ ok: false, status: 429, text: async () => '' }); +const orLLM = { provider: 'openrouter', baseUrl: 'https://openrouter.ai/api/v1', model: 'google/gemma-4-31b-it:free', apiKey: 'k' }; + +test('OpenRouter 免费模型带上最多 3 个备用模型并关闭思考;付费模型不带', () => { + const extra = modelExtra('openrouter', 'google/gemma-4-31b-it:free'); + assert.equal(extra.models.length, 3); + assert.equal(extra.models[0], 'google/gemma-4-31b-it:free'); + assert.equal(new Set(extra.models).size, 3); + assert.deepEqual(extra.reasoning, { enabled: false }); + assert.equal(modelExtra('openrouter', 'some/new-model:free').models[0], 'some/new-model:free'); + assert.deepEqual(modelExtra('openrouter', 'deepseek/deepseek-chat'), {}); +}); + +test('免费模型 429 时自动重试一次,成功就返回', async () => { + let calls = 0; + const text = await translateLLM({ + text: '你好', direction: 'zh2en', llm: orLLM, retryDelayMs: 1, + fetchImpl: async () => (++calls === 1 ? busy() : okStream('Hi')), + }); + assert.equal(text, 'Hi'); + assert.equal(calls, 2); +}); + +test('免费模型重试后仍 429,报"免费模型太忙"', async () => { + let calls = 0; + await assert.rejects( + translateLLM({ text: '你好', direction: 'zh2en', llm: orLLM, retryDelayMs: 1, fetchImpl: async () => { calls++; return busy(); } }), + (err) => err.code === 'FREE_BUSY', + ); + assert.equal(calls, 2); + const { userMessage } = require('../src/main/errors'); + assert.match(userMessage({ code: 'FREE_BUSY' }), /免费模型这会儿太忙/); +});