diff --git a/src/renderer/dev-mock.js b/src/renderer/dev-mock.js
new file mode 100644
index 0000000..a3f40a4
--- /dev/null
+++ b/src/renderer/dev-mock.js
@@ -0,0 +1,54 @@
+'use strict';
+// 仅在浏览器里直接打开页面预览时生效;Electron 里 preload 已提供 window.xt。
+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 },
+ livePreview: true, previewDelayMs: 500, restoreClipboard: true, launchAtLogin: false,
+ };
+ const canned = { '我先撤了哈,明天见': "I'm heading out, see you tomorrow!", 'No worries, take your time!': '没事没事,你慢慢来!' };
+ const timers = {};
+ const merge = (a, b) => { for (const k in b) a[k] = b[k] && typeof b[k] === 'object' && !Array.isArray(b[k]) ? merge({ ...(a[k] || {}) }, b[k]) : b[k]; return a; };
+ window.xt = {
+ translate({ id, text, engine }) {
+ const full = canned[text.trim()] || (/[一-鿿]/.test(text) ? `(mock EN) ${text}` : `(模拟中文)${text}`);
+ if ((engine || cfg.engine) === 'llm' && !cfg.llm.apiKeySet) return Promise.reject(new Error('还没填大模型的 API Key,去设置里填一下'));
+ return new Promise((resolve) => {
+ let i = 0;
+ timers[id] = setInterval(() => {
+ i += 3;
+ listeners.partial.forEach((cb) => cb({ id, text: full.slice(0, i) }));
+ if (i >= full.length) { clearInterval(timers[id]); resolve({ id, text: full, engine: engine || cfg.engine, direction: 'auto', ms: 300 }); }
+ }, 40);
+ });
+ },
+ onPartial: (cb) => listeners.partial.push(cb),
+ cancel: (id) => clearInterval(timers[id]),
+ commit: async (text) => console.log('[mock] commit:', text),
+ hide: () => console.log('[mock] hide'),
+ resize: () => {},
+ onShow: (cb) => listeners.show.push(cb),
+ getConfig: async () => structuredClone(cfg),
+ setConfig: async (p) => {
+ if (p.hotkey === 'Ctrl+C') throw new Error('这个组合键被占用了');
+ const { apiKey, ...llm } = p.llm || {};
+ cfg = merge(cfg, { ...p, ...(p.llm ? { llm } : {}) });
+ if (apiKey) cfg.llm.apiKeySet = true;
+ listeners.config.forEach((cb) => cb(structuredClone(cfg)));
+ 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: 'ollama', name: 'Ollama(本地)', baseUrl: 'http://localhost:11434/v1', model: 'qwen2.5:7b', models: [], keyOptional: true },
+ { id: 'custom', name: '自定义(OpenAI 兼容)', baseUrl: '', model: '', models: [] },
+ ],
+ 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 },
+ openSettings: () => console.log('[mock] openSettings'),
+ onConfigChanged: (cb) => listeners.config.push(cb),
+ };
+ document.documentElement.style.background = '#8a93a3';
+}
diff --git a/src/renderer/popup.css b/src/renderer/popup.css
new file mode 100644
index 0000000..ebfcae0
--- /dev/null
+++ b/src/renderer/popup.css
@@ -0,0 +1,87 @@
+html, body { background: transparent; overflow: hidden; }
+body { padding: 10px; }
+
+.card {
+ background: var(--bg);
+ border: 1px solid var(--line);
+ border-radius: var(--radius);
+ box-shadow: var(--shadow);
+ overflow: hidden;
+}
+
+#src {
+ display: block;
+ width: 100%;
+ min-height: 52px;
+ max-height: 180px;
+ padding: 15px 18px 8px;
+ border: 0;
+ outline: 0;
+ resize: none;
+ background: transparent;
+ color: var(--fg);
+ font: 17px/1.5 var(--font);
+}
+#src::placeholder { color: var(--fg-faint); }
+
+.out {
+ min-height: 0;
+ max-height: 220px;
+ overflow-y: auto;
+ padding: 0 18px;
+ font-size: 17px;
+ line-height: 1.5;
+ white-space: pre-wrap;
+ word-break: break-word;
+ color: var(--accent);
+}
+.out:not(:empty) { padding: 2px 18px 12px; }
+.out.pending { color: var(--fg-muted); }
+.out.pending::after {
+ content: "";
+ display: inline-block;
+ width: 7px; height: 1.05em;
+ margin-left: 2px;
+ vertical-align: -2px;
+ background: currentColor;
+ animation: blink 1s steps(2) infinite;
+}
+.out.error { color: var(--danger); font-size: 14px; }
+.out.error a { color: inherit; text-decoration: underline; cursor: pointer; }
+.out.waiting::before { content: "翻译完就上屏… "; color: var(--fg-faint); font-size: 13px; }
+@keyframes blink { 50% { opacity: 0; } }
+
+footer {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ padding: 7px 10px 7px 14px;
+ border-top: 1px solid var(--line);
+ background: var(--bg-soft);
+ font-size: 12px;
+ color: var(--fg-muted);
+}
+.chip {
+ padding: 3px 9px;
+ border: 1px solid var(--line);
+ border-radius: 999px;
+ background: var(--bg);
+ font-size: 12px;
+ cursor: pointer;
+}
+.chip:hover { border-color: var(--accent); color: var(--accent); }
+.chip.llm { background: var(--accent-soft); border-color: transparent; color: var(--accent); }
+.hints { margin-left: auto; display: flex; align-items: center; gap: 4px; white-space: nowrap; }
+.hints kbd + kbd, .hints kbd { margin-left: 6px; }
+.icon {
+ display: grid; place-items: center;
+ width: 26px; height: 26px;
+ margin-left: 4px;
+ border: 0; border-radius: 6px;
+ background: transparent;
+ color: var(--fg-muted);
+ cursor: pointer;
+}
+.icon:hover { background: var(--line); color: var(--fg); }
+
+@media (max-width: 480px) { .hints { display: none; } .icon { margin-left: auto; } }
diff --git a/src/renderer/popup.html b/src/renderer/popup.html
new file mode 100644
index 0000000..5a1565a
--- /dev/null
+++ b/src/renderer/popup.html
@@ -0,0 +1,26 @@
+
+
+
+
+
+ Xtranslate
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/renderer/popup.js b/src/renderer/popup.js
new file mode 100644
index 0000000..7b5ccf0
--- /dev/null
+++ b/src/renderer/popup.js
@@ -0,0 +1,197 @@
+'use strict';
+
+const $ = (id) => document.getElementById(id);
+const src = $('src');
+const out = $('out');
+const card = $('card');
+const dirBtn = $('dir');
+const engineBtn = $('engine');
+
+const DIRS = ['auto', 'zh2en', 'en2zh'];
+const state = {
+ dir: 'auto',
+ engine: 'free',
+ livePreview: true,
+ delay: 500,
+ seq: 0,
+ cur: null, // {id, text, dir, engine, done, result, error, promise}
+ wantCommit: false,
+ timer: 0,
+};
+
+// 和主进程 detectDirection 同一规则,只用于显示"自动"时的实际方向
+function guessDir(text) {
+ const cjk = (text.match(/[㐀-鿿豈-]/g) || []).length;
+ const latin = (text.match(/[A-Za-z]/g) || []).length;
+ if (!cjk && !latin) return 'zh2en';
+ return cjk * 5 >= latin ? 'zh2en' : 'en2zh';
+}
+
+function renderChips() {
+ const arrow = { zh2en: '中 → 英', en2zh: '英 → 中' };
+ dirBtn.textContent = state.dir === 'auto'
+ ? (src.value.trim() ? `自动 · ${arrow[guessDir(src.value)]}` : '自动识别')
+ : arrow[state.dir];
+ engineBtn.textContent = state.engine === 'llm' ? '大模型 · 口语' : '免费引擎';
+ engineBtn.classList.toggle('llm', state.engine === 'llm');
+}
+
+function renderOut(text, mode) {
+ out.className = 'out' + (mode ? ' ' + mode : '') + (state.wantCommit ? ' waiting' : '');
+ out.textContent = text || '';
+ if (mode === 'error') {
+ const a = document.createElement('a');
+ a.textContent = '去设置';
+ a.onclick = () => window.xt.openSettings();
+ out.append(' ', a);
+ }
+ out.scrollTop = out.scrollHeight;
+}
+
+function matchesCurrent(c) {
+ return c && c.text === src.value && c.dir === state.dir && c.engine === state.engine;
+}
+
+function cancelCurrent() {
+ const c = state.cur;
+ if (c && !c.done && !c.error) window.xt.cancel(c.id);
+}
+
+function translateNow() {
+ clearTimeout(state.timer);
+ const text = src.value;
+ if (!text.trim()) {
+ cancelCurrent();
+ state.cur = null;
+ state.wantCommit = false;
+ renderOut('');
+ return;
+ }
+ if (matchesCurrent(state.cur) && !state.cur.error) return;
+
+ cancelCurrent();
+ const id = ++state.seq;
+ const prev = state.cur && state.cur.result;
+ const c = { id, text, dir: state.dir, engine: state.engine, done: false, result: '', error: null };
+ state.cur = c;
+ // 新请求出结果前,淡显上一次的译文,避免闪烁
+ renderOut(prev || '', 'pending');
+
+ c.promise = window.xt.translate({ id, text, direction: state.dir, engine: state.engine })
+ .then((r) => {
+ if (state.cur !== c) return;
+ c.done = true;
+ c.result = r.text || '';
+ if (state.wantCommit) return commit();
+ renderOut(c.result);
+ })
+ .catch((e) => {
+ if (state.cur !== c) return;
+ c.error = (e && e.message ? e.message : String(e)).replace(/^Error invoking remote method '[^']+': (Error: )?/, '');
+ state.wantCommit = false;
+ renderOut(c.error, 'error');
+ });
+}
+
+function schedule() {
+ renderChips();
+ clearTimeout(state.timer);
+ if (!src.value.trim()) return translateNow();
+ if (state.livePreview) state.timer = setTimeout(translateNow, state.delay);
+}
+
+function commit() {
+ const text = src.value;
+ if (!text.trim()) return hide();
+ const c = state.cur;
+ if (matchesCurrent(c) && c.done) {
+ if (!c.result) return;
+ state.wantCommit = false;
+ window.xt.commit(c.result);
+ reset();
+ return;
+ }
+ state.wantCommit = true;
+ if (matchesCurrent(c) && !c.error) renderOut(c.result || out.textContent, 'pending');
+ else translateNow();
+}
+
+function hide() {
+ cancelCurrent();
+ reset();
+ window.xt.hide();
+}
+
+function reset() {
+ clearTimeout(state.timer);
+ state.cur = null;
+ state.wantCommit = false;
+ src.value = '';
+ autoGrow();
+ renderOut('');
+ renderChips();
+}
+
+function cycleDir() {
+ state.dir = DIRS[(DIRS.indexOf(state.dir) + 1) % DIRS.length];
+ renderChips();
+ if (src.value.trim()) translateNow();
+}
+
+async function toggleEngine() {
+ state.engine = state.engine === 'llm' ? 'free' : 'llm';
+ renderChips();
+ if (src.value.trim()) translateNow();
+ try { await window.xt.setConfig({ engine: state.engine }); } catch (_) { /* 只影响下次默认值 */ }
+}
+
+function autoGrow() {
+ src.style.height = 'auto';
+ src.style.height = Math.min(src.scrollHeight, 180) + 'px';
+}
+
+src.addEventListener('input', () => { autoGrow(); state.wantCommit = false; schedule(); });
+
+src.addEventListener('keydown', (e) => {
+ // 中文输入法选词时的回车/Esc 交给输入法
+ if (e.isComposing || e.keyCode === 229) return;
+ if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); commit(); }
+ else if (e.key === 'Escape') { e.preventDefault(); hide(); }
+ else if (e.key === 'Tab') { e.preventDefault(); cycleDir(); }
+ else if ((e.key === 'e' || e.key === 'E') && (e.ctrlKey || e.metaKey)) { e.preventDefault(); toggleEngine(); }
+});
+
+dirBtn.onclick = () => { cycleDir(); src.focus(); };
+engineBtn.onclick = () => { toggleEngine(); src.focus(); };
+$('settings').onclick = () => window.xt.openSettings();
+
+window.xt.onPartial(({ id, text }) => {
+ const c = state.cur;
+ if (c && c.id === id && !c.done) renderOut(text, 'pending');
+});
+
+window.xt.onShow(({ engine, direction } = {}) => {
+ reset();
+ if (engine) state.engine = engine;
+ state.dir = direction && DIRS.includes(direction) ? direction : 'auto';
+ renderChips();
+ src.focus();
+});
+
+function applyConfig(cfg) {
+ if (!cfg) return;
+ state.engine = cfg.engine || state.engine;
+ state.livePreview = cfg.livePreview !== false;
+ state.delay = Number(cfg.previewDelayMs) || 500;
+ renderChips();
+}
+window.xt.onConfigChanged(applyConfig);
+window.xt.getConfig().then(applyConfig).catch(() => {});
+
+// 窗口高度跟随内容(外层 body 有 10px 阴影留白)
+new ResizeObserver(() => {
+ window.xt.resize(Math.ceil(card.getBoundingClientRect().height) + 20);
+}).observe(card);
+
+renderChips();
+src.focus();
diff --git a/src/renderer/settings.css b/src/renderer/settings.css
new file mode 100644
index 0000000..b375a8a
--- /dev/null
+++ b/src/renderer/settings.css
@@ -0,0 +1,79 @@
+body { background: var(--bg-soft); font-size: 14px; }
+.wrap { max-width: 560px; margin: 0 auto; padding: 24px 20px 40px; }
+header h1 { margin: 0; font-size: 22px; letter-spacing: .2px; }
+header p { margin: 4px 0 18px; color: var(--fg-muted); }
+
+section {
+ background: var(--bg);
+ border: 1px solid var(--line);
+ border-radius: 12px;
+ padding: 16px 18px;
+ margin-bottom: 14px;
+}
+section.off { display: none; }
+h2 { margin: 0 0 12px; font-size: 13px; font-weight: 600; color: var(--fg-muted); text-transform: uppercase; letter-spacing: .5px; }
+
+.seg { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
+.seg label { cursor: pointer; }
+.seg input { position: absolute; opacity: 0; pointer-events: none; }
+.seg span {
+ display: block; height: 100%;
+ padding: 11px 13px;
+ border: 1.5px solid var(--line);
+ border-radius: 10px;
+}
+.seg b { display: block; font-weight: 600; }
+.seg small { display: block; margin-top: 3px; color: var(--fg-muted); font-size: 12px; }
+.seg input:checked + span { border-color: var(--accent); background: var(--accent-soft); }
+.seg input:focus-visible + span { outline: 2px solid var(--accent); outline-offset: 2px; }
+
+.row { display: flex; align-items: center; gap: 10px; margin: 10px 0; }
+.row > label { flex: 0 0 82px; color: var(--fg-muted); }
+.row.indent > label { flex-basis: auto; padding-left: 26px; }
+.row.end { justify-content: flex-start; margin-top: 14px; }
+input[type=url], input[type=password], input:not([type]), select {
+ flex: 1; min-width: 0;
+ height: 32px; padding: 0 10px;
+ border: 1px solid var(--line); border-radius: 7px;
+ background: var(--bg); color: var(--fg);
+ font: 13px var(--font);
+}
+.row.indent select { flex: 0 0 110px; }
+input:focus, select:focus { outline: 2px solid var(--accent); outline-offset: -1px; border-color: transparent; }
+
+.btn {
+ height: 32px; padding: 0 14px;
+ border: 1px solid var(--line); border-radius: 7px;
+ background: var(--bg); cursor: pointer;
+}
+.btn:hover { border-color: var(--accent); }
+.btn.primary { background: var(--accent); border-color: var(--accent); color: #fff; }
+.btn.primary:hover { filter: brightness(1.08); }
+.btn:disabled { opacity: .6; cursor: progress; }
+
+.result { font-size: 13px; color: var(--fg-muted); min-width: 0; overflow-wrap: anywhere; }
+.result.ok { color: var(--ok); }
+.result.err { color: var(--danger); }
+
+.tip { margin: 6px 0 0; font-size: 12px; color: var(--fg-muted); }
+.tip a { color: var(--accent); }
+
+.hotkey {
+ min-width: 120px; height: 32px; padding: 0 12px;
+ border: 1px dashed var(--line); border-radius: 7px;
+ background: var(--bg-soft); cursor: pointer;
+ font: 600 13px var(--font);
+}
+.hotkey.rec { border-color: var(--accent); color: var(--accent); }
+
+.switch { display: flex; align-items: center; gap: 10px; margin: 12px 0; cursor: pointer; }
+.switch input { appearance: none; position: relative; width: 34px; height: 20px; margin: 0; border-radius: 999px; background: var(--line); cursor: pointer; transition: background .15s; flex: none; }
+.switch input::after { content: ""; position: absolute; top: 2px; left: 2px; width: 16px; height: 16px; border-radius: 50%; background: #fff; box-shadow: 0 1px 2px rgba(0,0,0,.25); transition: transform .15s; }
+.switch input:checked { background: var(--accent); }
+.switch input:checked::after { transform: translateX(14px); }
+
+@media (max-width: 420px) {
+ .seg { grid-template-columns: 1fr; }
+ .row { flex-wrap: wrap; }
+ .row > label { flex-basis: 100%; }
+}
diff --git a/src/renderer/settings.html b/src/renderer/settings.html
new file mode 100644
index 0000000..ec8ab87
--- /dev/null
+++ b/src/renderer/settings.html
@@ -0,0 +1,85 @@
+
+
+
+
+
+ Xtranslate 设置
+
+
+
+
+
+
+ Xtranslate
+ 中英互译输入法 · 按 Alt+Q 随处呼出
+
+
+
+
+
+ 免费引擎
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/renderer/settings.js b/src/renderer/settings.js
new file mode 100644
index 0000000..0eac8bb
--- /dev/null
+++ b/src/renderer/settings.js
@@ -0,0 +1,170 @@
+'use strict';
+
+const $ = (id) => document.getElementById(id);
+const xt = window.xt;
+let providers = [];
+let cfg = null;
+
+const errMsg = (e) => (e && e.message ? e.message : String(e)).replace(/^Error invoking remote method '[^']+': (Error: )?/, '');
+
+function setResult(el, text, kind) {
+ el.textContent = text || '';
+ el.className = 'result' + (kind ? ' ' + kind : '');
+}
+
+async function save(partial) {
+ try {
+ cfg = await xt.setConfig(partial);
+ render();
+ return true;
+ } catch (e) {
+ return Promise.reject(e);
+ }
+}
+
+function render() {
+ if (!cfg) return;
+ for (const r of document.querySelectorAll('input[name=engine]')) r.checked = r.value === cfg.engine;
+ $('free-box').classList.toggle('off', cfg.engine !== 'free');
+ $('llm-box').classList.toggle('off', cfg.engine !== 'llm');
+ $('free-provider').value = cfg.free?.provider || 'microsoft';
+
+ const llm = cfg.llm || {};
+ $('llm-provider').value = llm.provider || 'deepseek';
+ 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 })));
+ $('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 只加密保存在本机,只发给你选的服务商。');
+ } else if (p.id === 'ollama') {
+ tip.textContent = '需先在本机运行 Ollama 并拉取模型,例如 ollama pull qwen2.5:7b';
+ }
+
+ $('hotkey').textContent = cfg.hotkey || '未设置';
+ $('hk-hint').textContent = cfg.hotkey || '热键';
+ $('live').checked = cfg.livePreview !== false;
+ $('delay').value = String(cfg.previewDelayMs || 500);
+ $('delay').disabled = !$('live').checked;
+ $('restore').checked = cfg.restoreClipboard !== false;
+ $('login').checked = !!cfg.launchAtLogin;
+}
+
+// —— 引擎 ——
+for (const r of document.querySelectorAll('input[name=engine]')) {
+ r.addEventListener('change', () => save({ engine: r.value }));
+}
+$('free-provider').addEventListener('change', (e) => save({ free: { provider: e.target.value } }));
+
+$('llm-provider').addEventListener('change', (e) => {
+ const p = providers.find((x) => x.id === e.target.value);
+ if (!p) return;
+ const llm = { provider: p.id };
+ if (p.id !== 'custom') Object.assign(llm, { baseUrl: p.baseUrl, model: p.model });
+ setResult($('llm-result'), '');
+ save({ llm });
+});
+$('llm-base').addEventListener('change', (e) => save({ llm: { baseUrl: e.target.value.trim() } }));
+$('llm-model').addEventListener('change', (e) => save({ llm: { model: e.target.value.trim() } }));
+$('llm-key').addEventListener('change', async (e) => {
+ const key = e.target.value.trim();
+ if (!key) return;
+ await save({ llm: { apiKey: key } });
+ e.target.value = '';
+});
+
+for (const btn of document.querySelectorAll('[data-test]')) {
+ btn.addEventListener('click', async () => {
+ const engine = btn.dataset.test;
+ const out = $(engine + '-result');
+ btn.disabled = true;
+ setResult(out, '测试中…');
+ try {
+ if (engine === 'llm') {
+ const partial = { llm: { baseUrl: $('llm-base').value.trim(), model: $('llm-model').value.trim() } };
+ const key = $('llm-key').value.trim();
+ if (key) partial.llm.apiKey = key;
+ await save(partial);
+ $('llm-key').value = '';
+ }
+ const r = await xt.testEngine({ engine });
+ if (r.ok) setResult(out, `✓ ${r.sample}(${r.ms} ms)`, 'ok');
+ else setResult(out, r.error || '失败', 'err');
+ } catch (e) {
+ setResult(out, errMsg(e), 'err');
+ } finally {
+ btn.disabled = false;
+ }
+ });
+}
+
+// —— 常规 ——
+$('live').addEventListener('change', (e) => save({ livePreview: e.target.checked }));
+$('delay').addEventListener('change', (e) => save({ previewDelayMs: Number(e.target.value) }));
+$('restore').addEventListener('change', (e) => save({ restoreClipboard: e.target.checked }));
+$('login').addEventListener('change', (e) => save({ launchAtLogin: e.target.checked }));
+
+// —— 热键录制 ——
+const KEY_MAP = { ' ': 'Space', ArrowUp: 'Up', ArrowDown: 'Down', ArrowLeft: 'Left', ArrowRight: 'Right', Enter: 'Enter', Backspace: 'Backspace', Delete: 'Delete', Insert: 'Insert', Home: 'Home', End: 'End', PageUp: 'PageUp', PageDown: 'PageDown', '`': '`', '-': '-', '=': '=', '[': '[', ']': ']', ';': ';', "'": "'", ',': ',', '.': '.', '/': '/', '\\': '\\' };
+
+function toAccelerator(e) {
+ let key = null;
+ if (/^Key[A-Z]$/.test(e.code)) key = e.code.slice(3);
+ else if (/^Digit\d$/.test(e.code)) key = e.code.slice(5);
+ else if (/^F\d{1,2}$/.test(e.key)) key = e.key;
+ else if (KEY_MAP[e.key]) key = KEY_MAP[e.key];
+ if (!key) return null;
+ const mods = [];
+ if (e.ctrlKey) mods.push('Ctrl');
+ if (e.altKey) mods.push('Alt');
+ if (e.shiftKey) mods.push('Shift');
+ if (e.metaKey) mods.push('Super');
+ if (!mods.length && !/^F\d+$/.test(key)) return null; // 单个普通键会吃掉正常打字
+ return [...mods, key].join('+');
+}
+
+const hk = $('hotkey');
+let recording = false;
+hk.addEventListener('click', () => {
+ recording = true;
+ hk.classList.add('rec');
+ hk.textContent = '请按下组合键…';
+ setResult($('hotkey-result'), 'Esc 取消', '');
+});
+hk.addEventListener('blur', stopRec);
+function stopRec() {
+ if (!recording) return;
+ recording = false;
+ hk.classList.remove('rec');
+ render();
+}
+hk.addEventListener('keydown', async (e) => {
+ if (!recording) return;
+ e.preventDefault();
+ if (e.key === 'Escape') { stopRec(); setResult($('hotkey-result'), ''); return; }
+ const acc = toAccelerator(e);
+ if (!acc) return; // 还在按修饰键
+ recording = false;
+ hk.classList.remove('rec');
+ try {
+ await save({ hotkey: acc });
+ setResult($('hotkey-result'), '✓ 已生效', 'ok');
+ } catch (err) {
+ render();
+ setResult($('hotkey-result'), errMsg(err) || '这个组合键被别的软件占用了', 'err');
+ }
+});
+
+xt.onConfigChanged((c) => { cfg = c; render(); });
+
+(async () => {
+ providers = await xt.getProviders();
+ $('llm-provider').replaceChildren(...providers.map((p) => Object.assign(document.createElement('option'), { value: p.id, textContent: p.name })));
+ cfg = await xt.getConfig();
+ render();
+})();
diff --git a/src/renderer/theme.css b/src/renderer/theme.css
new file mode 100644
index 0000000..49d06c3
--- /dev/null
+++ b/src/renderer/theme.css
@@ -0,0 +1,44 @@
+:root {
+ --bg: #ffffff;
+ --bg-soft: #f4f5f7;
+ --fg: #1d1f23;
+ --fg-muted: #6b7079;
+ --fg-faint: #a3a8b0;
+ --line: #e4e6ea;
+ --accent: #3b6cf6;
+ --accent-soft: #e8eefe;
+ --danger: #d0453a;
+ --ok: #2f9e5b;
+ --shadow: 0 12px 40px rgba(20, 24, 33, .18), 0 2px 6px rgba(20, 24, 33, .08);
+ --radius: 14px;
+ --font: "Segoe UI Variable Text", "Segoe UI", "Microsoft YaHei UI", "PingFang SC", system-ui, sans-serif;
+ color-scheme: light;
+}
+@media (prefers-color-scheme: dark) {
+ :root {
+ --bg: #1f2125;
+ --bg-soft: #2a2d32;
+ --fg: #eceef1;
+ --fg-muted: #9aa0a9;
+ --fg-faint: #676c75;
+ --line: #34373d;
+ --accent: #7b9cff;
+ --accent-soft: #2b3350;
+ --danger: #ff7b70;
+ --ok: #5fcf8d;
+ --shadow: 0 12px 40px rgba(0, 0, 0, .5), 0 2px 6px rgba(0, 0, 0, .3);
+ color-scheme: dark;
+ }
+}
+* { box-sizing: border-box; }
+html, body { margin: 0; font-family: var(--font); color: var(--fg); }
+button { font: inherit; color: inherit; }
+kbd {
+ font: 11px/1 var(--font);
+ padding: 2px 5px;
+ border: 1px solid var(--line);
+ border-bottom-width: 2px;
+ border-radius: 4px;
+ color: var(--fg-muted);
+ background: var(--bg-soft);
+}