diff --git a/CHANGELOG.md b/CHANGELOG.md index e513732..d0f8ec5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ All notable changes are documented here. Versions follow Semantic Versioning. ### Added - macOS desktop shell with global selection translation shortcut. +- A desktop setting to disable or customize the selection translation shortcut, defaulting to `Option + K`. - GitHub pull request, CI, security scanning, dependency updates, and beta release workflows. - Automated API and browser smoke coverage. diff --git a/README.md b/README.md index 75f0e05..ed0cef3 100644 --- a/README.md +++ b/README.md @@ -13,8 +13,8 @@ ## 功能 -- macOS 中选中文字后按 `⌥ + T`,打开客户端并立即翻译 -- 自动、Ollama 本地模型、OpenAI 兼容 API、极速词典四种引擎 +- macOS 中选中文字后按默认快捷键 `⌥ + K`,打开客户端并立即翻译;可在设置中关闭或修改 +- 自动、Ollama、OpenAI 兼容 API、极速词典四种引擎 - 整段中文译文与逐词、数字、标点对齐结果 - TXT、Markdown、DOCX、PDF 文本提取 - 桌面与手机响应式 Web 界面 @@ -41,7 +41,7 @@ npm ci npm run tauri dev ``` -全局划词需要在“系统设置 → 隐私与安全性 → 辅助功能”中允许逐词。客户端会优先通过 macOS Accessibility API 读取选区;不支持该属性的应用会使用保留剪贴板内容的复制回退。 +全局划词需要在“系统设置 → 隐私与安全性 → 辅助功能”中允许逐词。客户端会优先通过 macOS Accessibility API 读取选区;不支持该属性的应用会使用复制回退,尝试恢复原来的文本剪贴板,并可能请求“自动化 → System Events”权限。关闭选区快捷键只会取消全局快捷键注册,不会撤销已经授予的系统权限。 生成本地 `.app`: @@ -52,7 +52,7 @@ npm run desktop:build:app ## 翻译引擎 - `自动选择`:优先使用可用的 Ollama;未检测到本地模型时使用已配置的云端 API。两者都不可用时会提示配置,不会把基础词典冒充成语境翻译。 -- `Ollama 本地`:内容不离开电脑,建议使用 `qwen3:4b` 或更大的 Qwen 模型。 +- `Ollama`:默认连接本机服务,建议使用 `qwen3:4b` 或更大的 Qwen 模型;改为远程地址时,内容会发送到该服务。 - `云端模型`:支持 OpenAI 及兼容 `/chat/completions` 的 API。 - `极速词典`:完全离线、即时返回,只用于逐词查义,不提供可靠的整句语境翻译。 @@ -71,7 +71,7 @@ OPENAI_BASE_URL=https://api.openai.com/v1 \ OPENAI_API_KEY=... OPENAI_MODEL=gpt-5-mini npm run dev ``` -API Key 不会写入浏览器存储。公共云端凭据不得编译到前端或桌面安装包中。 +API Key 不会写入浏览器存储。使用云端模型或远程 Ollama 地址时,选中文字、手动输入内容或提取出的文档文字会发送给用户配置的服务;使用默认本机 Ollama 地址或极速词典时不会发送到云端。公共云端凭据不得编译到前端或桌面安装包中。 Web 服务只会请求上述服务端环境变量配置的模型地址,页面中地址为只读;这可防止浏览器借用本地 API 访问其他内网服务。macOS 客户端直接在本机发起模型请求,仍允许用户在设置中修改兼容 API 地址。 ## 质量门禁 diff --git a/docs/building-en.md b/docs/building-en.md index a6713dc..b5d672a 100644 --- a/docs/building-en.md +++ b/docs/building-en.md @@ -49,9 +49,9 @@ Start the desktop app: npm run tauri dev ``` -Wordwise opens as a native desktop window. Select English in another macOS app and press `Option + T` to capture it. +Wordwise opens as a native desktop window. Select English in another macOS app and press the default `Option + K` shortcut to capture it. You can disable selection translation or record another key combination in Settings. -The first selection capture requires permission in **System Settings -> Privacy & Security -> Accessibility**. Wordwise first tries the macOS Accessibility API. When an app does not expose its selection there, Wordwise uses a copy fallback and restores the previous clipboard contents. +The first selection capture requires permission in **System Settings -> Privacy & Security -> Accessibility**. Wordwise first tries the macOS Accessibility API. When an app does not expose its selection there, Wordwise uses a copy fallback, attempts to restore the previous text clipboard, and may also request permission under **Automation -> System Events**. Disabling the shortcut only unregisters the global shortcut; remove Wordwise in macOS System Settings to revoke the permissions themselves. ## Configure a translation engine @@ -66,15 +66,16 @@ Local mode needs no API key. Install and start Ollama from the [macOS download p ollama pull qwen3:4b ``` -Return to Wordwise and keep **Automatic** selected, or choose **Local Ollama**. Select `What's under the hood?` in any app and press `Option + T`. A successful first run shows a full Chinese translation, token-aligned results, and the `Ollama · qwen3:4b` engine label. +Return to Wordwise and keep `自动选择` selected, or choose `Ollama`. Select `What's under the hood?` in an app that supports text selection or copying, then press the default `Option + K` shortcut. A successful first run shows a full Chinese translation, token-aligned results, and the `Ollama · qwen3:4b` engine label. Automatic mode uses an available Ollama model first, then a configured cloud API. It reports a setup error when neither is available. The offline dictionary is an explicit word-lookup mode and is not presented as contextual sentence translation. -API keys remain in application memory and are not written to browser storage or committed to the repository. Never include keys, selected text, or private documents in a public GitHub issue. +API keys remain in application memory and are not written to browser storage or committed to the repository. With a cloud model or remote Ollama URL, selected text, manual input, and extracted document text are sent to the configured service; the default localhost Ollama URL and dictionary mode do not send that content to the cloud. Never include keys, selected text, or private documents in a public GitHub issue. ## Common blockers -- The shortcut cannot read the selection: check Wordwise under **System Settings -> Privacy & Security -> Accessibility**, then restart the development app. +- The shortcut does not respond: confirm that selection translation is enabled in Settings and that another app has not reserved the key combination. +- The shortcut cannot read the selection: check Wordwise under **System Settings -> Privacy & Security -> Accessibility**. The copy fallback also needs Wordwise to control System Events under **Automation**. Restart the development app after changing either permission. - Automatic mode reports that no engine is configured: make sure Ollama is running and use `ollama list` to confirm that the model was downloaded. - A development port is already in use: stop the existing Wordwise development process and try again. diff --git a/docs/building.md b/docs/building.md index 9c5f3b6..a104123 100644 --- a/docs/building.md +++ b/docs/building.md @@ -49,9 +49,9 @@ npm ci npm run tauri dev ``` -逐词会以原生桌面窗口打开。在其他 macOS 应用中选中英文,按下 `Option + T` 即可读取选区。 +逐词会以原生桌面窗口打开。在其他 macOS 应用中选中英文,按下默认快捷键 `Option + K` 即可读取选区。可以在设置中关闭选区快捷键,或录入新的组合键。 -第一次划词时,需要在“系统设置 -> 隐私与安全性 -> 辅助功能”中允许逐词。客户端会优先使用 macOS Accessibility API;应用没有暴露选区时,才会使用复制回退,并在读取后恢复原来的剪贴板内容。 +第一次划词时,需要在“系统设置 -> 隐私与安全性 -> 辅助功能”中允许逐词。客户端会优先使用 macOS Accessibility API;应用没有暴露选区时,才会使用复制回退,尝试恢复原来的文本剪贴板,并可能要求在“自动化”中允许逐词控制 System Events。关闭选区快捷键只会取消全局快捷键注册;如需撤销读取权限,请在 macOS 系统设置中移除逐词。 ## 配置翻译引擎 @@ -66,15 +66,16 @@ npm run tauri dev ollama pull qwen3:4b ``` -回到逐词,保留“自动选择”或选择“Ollama 本地”。在任意应用中选中 `What's under the hood?`,按下 `Option + T`。首次成功时应同时看到完整中文译文、逐词结果,以及 `Ollama · qwen3:4b` 引擎标签。 +回到逐词,保留“自动选择”或选择“Ollama”。在支持文本选择或复制的应用中选中 `What's under the hood?`,按下默认快捷键 `Option + K`。首次成功时应同时看到完整中文译文、逐词结果,以及 `Ollama · qwen3:4b` 引擎标签。 自动模式会先使用可用的 Ollama,再使用已配置的云端 API。两者都不可用时,客户端会提示配置。离线词典是单独的查词模式,不会被冒充成语境整句翻译。 -API Key 只保存在应用运行内存中,不会写入浏览器存储或提交到仓库。不要在公开 GitHub Issue 中提交 API Key、选中文字或私有文档。 +API Key 只保存在应用运行内存中,不会写入浏览器存储或提交到仓库。选择云端模型或远程 Ollama 地址时,选中文字、手动输入内容或提取出的文档文字会发送给所配置的服务;使用默认本机 Ollama 地址或极速词典时不会发送到云端。不要在公开 GitHub Issue 中提交 API Key、选中文字或私有文档。 ## 常见阻塞 -- 快捷键没有读到选区:检查“系统设置 -> 隐私与安全性 -> 辅助功能”中的逐词权限,然后重新启动开发客户端。 +- 快捷键没有响应:确认设置中的选区快捷键已启用且没有被其他应用占用。 +- 快捷键没有读到选区:检查“系统设置 -> 隐私与安全性”中逐词的“辅助功能”权限;复制回退还需要在“自动化”中允许逐词控制 System Events。修改后重新启动开发客户端。 - 自动模式提示没有引擎:确认 Ollama 应用正在运行,并用 `ollama list` 检查模型是否已经下载。 - 端口被占用:退出已有的逐词开发进程后重试。 diff --git a/site/en/index.html b/site/en/index.html index f964550..3017f55 100644 --- a/site/en/index.html +++ b/site/en/index.html @@ -40,7 +40,7 @@

Source build available · Up to 15 signed-beta waitlist spots

Wordwise

Read technical English without losing the words.

-

Select text in any macOS app and press ⌥ T to see a full Chinese translation and context-aware explanations for every word, number, and punctuation mark.

+

Select text in most macOS apps and press the default ⌥ K shortcut to see a full Chinese translation and context-aware explanations. The shortcut can be disabled or changed.

Build from source now Join the signed-beta waitlist @@ -106,7 +106,7 @@

The signed build is not ready. Validate the source build first.

diff --git a/site/index.html b/site/index.html index f005bb9..df06a03 100644 --- a/site/index.html +++ b/site/index.html @@ -40,7 +40,7 @@

源码版可用 · 最多 15 个签名版候补名额

逐词 Wordwise

读技术英文,不只得到一句中文。

-

在 macOS 里选中文字,按下 ⌥ T,同时查看完整译文与逐词语境解释。固定短语、冠词和标点都保持对齐。

+

在 macOS 里选中文字,按下默认快捷键 ⌥ K,同时查看完整译文与逐词语境解释。快捷键可关闭或修改。

立即从源码试用 加入签名版候补 @@ -106,7 +106,7 @@

签名版未就绪,先从源码验证价值。

diff --git a/src-tauri/src/selection.rs b/src-tauri/src/selection.rs index 41a7f29..629caa3 100644 --- a/src-tauri/src/selection.rs +++ b/src-tauri/src/selection.rs @@ -102,7 +102,7 @@ end try if !output.status.success() { return Err( - "无法读取选中文字。请在“系统设置 → 隐私与安全性 → 辅助功能”中允许逐词。".into(), + "无法通过复制读取选中文字。请在“系统设置 → 隐私与安全性”中检查逐词的“辅助功能”和“自动化 → System Events”权限。".into(), ); } diff --git a/src/App.tsx b/src/App.tsx index 435378c..c5f5f22 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -13,14 +13,23 @@ import { X, Zap, } from "lucide-react"; -import { useEffect, useMemo, useRef, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { extractFileText } from "./documents"; import { getEngineStatus, isDesktopApp, - registerSelectionShortcut, + configureSelectionShortcut, requestTranslation, + SelectionShortcutConfigurationError, } from "./platform"; +import { + DEFAULT_SELECTION_SHORTCUT, + formatSelectionShortcut, + readSelectionShortcutSettings, + saveSelectionShortcutSettings, + shortcutFromKeyboardEvent, +} from "./shortcut"; +import type { SelectionShortcutSettings } from "./shortcut"; import type { EngineSettings, Provider, @@ -41,11 +50,13 @@ const DEFAULT_SETTINGS: EngineSettings = { const PROVIDER_LABELS: Record = { auto: "自动选择", - ollama: "Ollama 本地", + ollama: "Ollama", openai: "云端模型", dictionary: "极速词典", }; +type ShortcutRegistrationStatus = "registering" | "registered" | "disabled" | "failed"; + function readSavedSettings(): EngineSettings { try { const saved = JSON.parse(localStorage.getItem("wordwise-settings") ?? "{}") as Partial; @@ -76,6 +87,9 @@ export default function App() { const [text, setText] = useState(SAMPLE); const [result, setResult] = useState(null); const [settings, setSettings] = useState(readSavedSettings); + const [selectionShortcut, setSelectionShortcut] = useState( + readSelectionShortcutSettings, + ); const [status, setStatus] = useState(null); const [settingsOpen, setSettingsOpen] = useState(false); const [loading, setLoading] = useState(false); @@ -84,8 +98,17 @@ export default function App() { const [fileName, setFileName] = useState(""); const [error, setError] = useState(""); const [copied, setCopied] = useState(false); + const [shortcutBusy, setShortcutBusy] = useState(false); + const [shortcutError, setShortcutError] = useState(""); + const [shortcutRegistrationStatus, setShortcutRegistrationStatus] = useState( + selectionShortcut.enabled ? "registering" : "disabled", + ); const fileInputRef = useRef(null); const settingsRef = useRef(settings); + const statusRef = useRef(status); + const selectionRequestInFlight = useRef(false); + const shortcutOperationInFlight = useRef(false); + const shortcutGlobalError = useRef(""); const tokenCount = useMemo( () => text.match(/\p{L}+(?:['’]\p{L}+)*|\p{N}+(?:[.,]\p{N}+)*|[^\s]/gu)?.length ?? 0, @@ -116,28 +139,63 @@ export default function App() { }, [settings]); useEffect(() => { - let removeShortcut: () => void = () => undefined; + statusRef.current = status; + }, [status]); + + const handleSelectedText = useCallback(async (selectedText: string) => { + if (selectionRequestInFlight.current) return; + selectionRequestInFlight.current = true; + setText(selectedText); + setFileName(""); + setResult(null); + + try { + const activeSettings = settingsRef.current; + if (needsModelConfiguration(activeSettings, statusRef.current)) { + setSettingsOpen(true); + return; + } + await translateValue(selectedText, activeSettings); + } finally { + selectionRequestInFlight.current = false; + } + }, []); + + const handleSelectionError = useCallback((message: string) => { + shortcutGlobalError.current = ""; + setError(message); + }, []); + + useEffect(() => { let cancelled = false; - void registerSelectionShortcut( - async (selectedText) => { - if (cancelled) return; - setText(selectedText); - setFileName(""); - setResult(null); - await translateValue(selectedText, settingsRef.current); - }, - (message) => setError(message), - ).then((remove) => { - if (cancelled) remove(); - else removeShortcut = remove; - }).catch((caught) => { - setError(caught instanceof Error ? caught.message : "全局快捷键注册失败"); + void configureSelectionShortcut( + selectionShortcut.enabled ? selectionShortcut.accelerator : null, + handleSelectedText, + handleSelectionError, + ).then(() => { + if (!cancelled) { + setShortcutRegistrationStatus(selectionShortcut.enabled ? "registered" : "disabled"); + } + }).catch(() => { + if (!cancelled) { + setShortcutRegistrationStatus("failed"); + const message = selectionShortcut.enabled + ? `无法注册 ${formatSelectionShortcut(selectionShortcut.accelerator)},可能已被其他应用占用` + : "无法关闭选区快捷键"; + shortcutGlobalError.current = message; + setShortcutError(message); + setError(message); + } }); return () => { cancelled = true; - removeShortcut(); + void configureSelectionShortcut( + null, + handleSelectedText, + handleSelectionError, + ).catch(() => undefined); }; }, []); @@ -214,6 +272,69 @@ export default function App() { setSettings((current) => ({ ...current, [key]: value })); } + async function applySelectionShortcut(next: SelectionShortcutSettings) { + if (shortcutOperationInFlight.current) return; + shortcutOperationInFlight.current = true; + setShortcutBusy(true); + setShortcutError(""); + setShortcutRegistrationStatus("registering"); + try { + await configureSelectionShortcut( + next.enabled ? next.accelerator : null, + handleSelectedText, + handleSelectionError, + ); + setSelectionShortcut(next); + setShortcutRegistrationStatus(next.enabled ? "registered" : "disabled"); + + const previousGlobalError = shortcutGlobalError.current; + shortcutGlobalError.current = ""; + if (previousGlobalError) { + setError((current) => current === previousGlobalError ? "" : current); + } + + try { + saveSelectionShortcutSettings(next); + } catch { + setShortcutError("快捷键已应用,但无法保存;下次启动将恢复原设置"); + } + } catch (caught) { + const activeAccelerator = caught instanceof SelectionShortcutConfigurationError + ? caught.activeAccelerator + : selectionShortcut.enabled + ? selectionShortcut.accelerator + : null; + const previousStillActive = selectionShortcut.enabled + && activeAccelerator === selectionShortcut.accelerator; + + setShortcutRegistrationStatus( + previousStillActive ? "registered" : activeAccelerator ? "failed" : selectionShortcut.enabled ? "failed" : "disabled", + ); + + let message: string; + if (previousStillActive) { + message = next.enabled + ? `无法注册 ${formatSelectionShortcut(next.accelerator)},已保留 ${formatSelectionShortcut(selectionShortcut.accelerator)}` + : `无法关闭 ${formatSelectionShortcut(selectionShortcut.accelerator)},原快捷键仍有效`; + } else if (!activeAccelerator) { + message = selectionShortcut.enabled + ? `无法注册 ${formatSelectionShortcut(next.accelerator)},原快捷键也未能恢复;请重新启用或更换` + : `无法注册 ${formatSelectionShortcut(next.accelerator)},选区快捷键仍为关闭状态`; + } else { + message = `快捷键配置未完成;当前仍为 ${formatSelectionShortcut(activeAccelerator)}`; + } + setShortcutError(message); + + if (!previousStillActive && selectionShortcut.enabled) { + shortcutGlobalError.current = message; + setError(message); + } + } finally { + shortcutOperationInFlight.current = false; + setShortcutBusy(false); + } + } + function resetText() { setText(""); setResult(null); @@ -225,20 +346,23 @@ export default function App() { const currentProviderStatus = (() => { if (settings.provider === "dictionary") return "仅逐词查义"; - if (settings.provider === "ollama") return status?.ollama.available ? "本地已连接" : "等待本地模型"; + if (settings.provider === "ollama") return status?.ollama.available ? "Ollama 可用" : "等待 Ollama"; if (settings.provider === "openai") { return hasEnteredApiKey || status?.openaiConfigured ? "API 已配置" : "需要 API Key"; } - if (status?.ollama.available) return "将使用本地模型"; + if (status?.ollama.available) return "将使用 Ollama"; if (hasEnteredApiKey || status?.openaiConfigured) return "将使用云端模型"; return "需要配置引擎"; })(); - const needsModelSetup = status !== null - && settings.provider === "auto" - && !status.ollama.available - && !hasEnteredApiKey - && !status.openaiConfigured; + const needsModelSetup = needsModelConfiguration(settings, status); + const shortcutStatusLabel = shortcutRegistrationStatus === "registered" + ? "已注册" + : shortcutRegistrationStatus === "disabled" + ? "已关闭" + : shortcutRegistrationStatus === "registering" + ? "配置中" + : "注册失败"; return (
@@ -250,7 +374,7 @@ export default function App() {
{currentProviderStatus} -
@@ -282,7 +406,7 @@ export default function App() {
先连接一个语境翻译引擎 - Ollama 内容留在本机;云端 API 适合立即开始。极速词典只做逐词查义,不会冒充整句翻译。 + 本机 Ollama 内容留在电脑;远程服务适合立即开始。极速词典只做逐词查义,不会冒充整句翻译。
+ {isDesktopApp && ( +
+
+ 选区快捷键 + + {shortcutStatusLabel} + +
+ +
+ + +
+ {shortcutError &&

{shortcutError}

} +
+ )} +
运行方式 {(Object.keys(PROVIDER_LABELS) as Provider[]).map((provider) => ( @@ -470,8 +672,8 @@ export default function App() { {(settings.provider === "auto" || settings.provider === "ollama") && (
- Ollama 本地模型 - {status?.ollama.available ? "已连接" : "未连接"} + Ollama 模型 + {status?.ollama.available ? "默认地址可用" : "默认地址未连接"}