feat: 大模型模型改为下拉框,加入各家免费模型并自动关闭思考

- 新增硅基流动、Gemini、Groq、Cerebras 预设,智谱改用 glm-4.7-flash,默认服务商改为智谱(免费、国内直连)
- 模型下拉框:精选模型标注免费/推荐,OpenRouter 再联网补充最新免费模型,末项可自定义
- 按模型附带关闭思考的参数(智谱、DeepSeek、Qwen3、Groq/Cerebras、OpenRouter),deepseek-flash 从约 7 秒降到 1 秒内
- OpenRouter 免费模型带两个备用模型,被限流时自动重试一次,仍失败给出明确提示
- 移除已停用的 deepseek-chat 预设

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
hopechen067andClaude Opus 5.5 committed 2026-10-02 05:31:20 -04:00
1 parent fdefc030d5
commit 4c436830be
14 files changed
+360 -28

No files matched your search

+3 -2
View File
@@ -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<config>` | apiKey 被替换为 `apiKeySet: boolean` |
| `setConfig(partial)` → `Promise<config>` | 深合并;`llm.apiKey` 为空字符串/缺省表示不改;热键变更立即重新注册,失败则 reject 并保持旧热键 |
| `getProviders()` → `Promise<Provider[]>` | 大模型预设列表 |
| `getProviders()` → `Promise<Provider[]>` | 大模型预设列表;`models[]` 带 `free`(free / quota)和 `note`,请求参数 `extra` 只留在主进程 |
| `listModels(providerId)` → `Promise<Model[]>` | 设置页模型下拉框:预设精选 + OpenRouter 实时免费模型(`live: true`),拉取失败只返回预设 |
| `testEngine(partialConfig?)` → `Promise<{ok, sample, ms, error?}>` | 用当前或传入(未保存)配置翻译一句测试文本 |
| `openSettings()` | 打开设置窗口 |
| `onConfigChanged(cb)` | 配置变更广播给所有窗口 |
+3 -3
View File
@@ -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
},
+18 -2
View File
@@ -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 = '';
+2 -1
View File
@@ -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;
+5 -1
View File
@@ -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 || {});
+49
View File
@@ -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<Array<{id: string, free?: string, note?: string, live?: boolean}>>} */
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 };
+105 -8
View File
@@ -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 };
+1
View File
@@ -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),
+6 -3
View File
@@ -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 },
+1
View File
@@ -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; }
+6 -3
View File
@@ -47,9 +47,12 @@
<input id="llm-base" type="url" spellcheck="false" placeholder="https://…">
</div>
<div class="row">
<label for="llm-model">模型</label>
<input id="llm-model" list="llm-models" spellcheck="false" placeholder="模型名">
<datalist id="llm-models"></datalist>
<label for="llm-model-pick">模型</label>
<select id="llm-model-pick"></select>
</div>
<div class="row" id="llm-model-row">
<label for="llm-model" id="llm-model-label">模型名</label>
<input id="llm-model" spellcheck="false" placeholder="填写服务商提供的模型 ID">
</div>
<div class="row">
<label for="llm-key">API Key</label>
+65 -4
View File
@@ -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');
})();
+1 -1
View File
@@ -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);
+95
View File
@@ -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' }), /免费模型这会儿太忙/);
});