本文使用「署名 4.0 国际 (CC BY 4.0)」许可协议,欢迎转载、或重新修改使用,但需要注明来源。 [署名 4.0 国际 (CC BY 4.0)](https://creativecommons.org/licenses/by/4.0/deed.zh) 本文作者: 苏洋 创建时间: 2026年06月03日 统计字数: 14502字 阅读时间: 29分钟阅读 本文链接: https://soulteary.com/2026/06/03/kakapo-a-local-translation-workbench-built-with-wails-v3-go-and-echo.html ----- # Kakapo:使用 Wails v3、Go 和 Echo 构建一个本地翻译工作台 翻译工具并不稀缺,但围绕翻译形成的工作流却各不相同。 Kakapo 是一个基于 Wails v3、Go 和 Echo 构建的本地桌面应用,它尝试把多个 OpenAI 兼容模型收拢到同一个界面里,并提供翻译、比较、回译和历史记录等能力。 本文记录这个项目的实现过程,以及这套技术栈在桌面工具场景下的一些实践和取舍。 ## 写在前面 翻译几乎是每天都会出现的动作,不论是在看国外项目文档、不同语言的技术资料,还是在处理邮件,或者是在给开源项目补 README 或整理技术文章。 原本一直在用 Mate,但慢慢发现自己的使用场景和它最擅长解决的问题有些偏离。我越来越少在网页里直接翻译内容,更多时候是在编辑器、终端、文档和 IM 工具里复制一段文本,然后希望同时看看几个模型的结果。 但其实,翻译本身并不复杂。真正麻烦的地方,往往出现在翻译之外。同样一段内容,需要反复复制和整理;几天之后想找回某个译文时,也不一定记得当时是怎么得到的。**这些问题都不算大,但会反复出现。** 于是,二月份春节期间抽空做了个小工具,名字叫 Kakapo。 ![Kakapo 主要功能 & 特性](https://attachment.soulteary.com/2026/06/03/kakapo-banner.jpg) 软件功能非常简单,这里就不过多介绍了,下载之后,填入模型的 API 就能够正常使用了。 ![Kakapo 界面预览](https://attachment.soulteary.com/2026/06/03/preview.jpg) 虽然这个工具软件本身并不复杂,但在实现过程中,顺便把 Wails v3、Go、Echo、系统 WebView、Keychain 和 OpenAI 兼容接口这套组合重新走了一遍,也还是踩了一些小坑。 ![二月一通 Vibe](https://attachment.soulteary.com/2026/06/03/git-history.jpg) 这篇文章记录一下这个项目,以及这套技术栈在桌面工具场景下的一些实践和思考。 ![soulteary/kakapo](https://attachment.soulteary.com/2026/06/03/kakapo-github.jpg) 开源项目地址: [https://github.com/soulteary/kakapo/](https://github.com/soulteary/kakapo/) 如果你觉得对你有帮助,欢迎“一键三连”。 ### 为什么没有继续用 Mate 在做 Kakapo 之前,我其实用过一段时间 Mate。 无论是浏览器插件,还是 Setapp 里的桌面版本,Mate 都是一个完成度比较高的翻译产品:网页翻译、选中文字翻译、PDF、字幕、词典、发音、短语本、跨设备同步,这些能力都已经比较成熟。 所以,Kakapo 不是因为“找不到翻译工具”才出现的。 恰恰相反,正是因为已经有成熟翻译工具,才更容易发现自己的需求和这些产品的设计目标并不完全重合。 **第一个问题是速度。** 这里说的速度,不只是接口响应时间,而是完成一次翻译动作的整体时间。 很多时候,我并不是在浏览网页。而是在编辑器、终端、邮件客户端、文档或者 IM 工具里工作。 这类场景下,最常见的动作其实是: ```text 复制一段文本 切换到翻译工具 粘贴 获得结果 复制回原来的工作区 ``` Mate 在网页内嵌翻译、选中文字翻译、查词和语言学习场景里很方便。但如果大部分内容本来就来自复制粘贴,那么我更需要的是一个处理文本片段的本地工作台,而不是一个围绕网页阅读展开的翻译产品。 **第二个问题是翻译风格。** 传统翻译工具解决的是: ```text 把内容翻译出来 ``` 但我更常遇到的是: ```text 这个表达是否自然 这个语气是否合适 这个术语是否符合上下文 这段话能不能更像人写的 ``` 随着大模型的发展,翻译越来越像一种语义转换,而不只是语言转换。有时候我并不需要逐字对应。**我需要的是保留原意,同时让目标语言里的表达更自然、更符合当前语境。** **第三个问题是决策。** 以前翻译工具通常给一个结果。现在我更习惯同时看看几个模型对同一段内容的处理: ```text Kimi DeepSeek OpenAI 其他 OpenAI 兼容服务 ``` 有时候它们给出的结果差不多。有时候差异会非常明显。 最终采用哪个版本,或者从几个版本里合并一个更合适的表达,本身已经变成工作流的一部分。 大多数成熟翻译产品并不把这个场景放在核心位置,它们更关心快速给出一个稳定结果。而我需要的是多个候选结果,以及基于这些结果做判断。 Kakapo 最初就是从这里开始的。它没有试图重新发明翻译能力,也不是要替代 Mate、DeepL、Google Translate 这类成熟产品。 它只是把多个模型放到同一个界面里。 **对于经常需要复制粘贴文本、比较不同模型表达方式的人来说,省掉一些重复动作,然后把选择权重新交还给使用者。** ### Kakapo 做了什么 Kakapo 的能力其实可以分成四部分:模型调用、桌面能力、本地数据和一些辅助功能。 **最核心的是模型调用。** 它通过 OpenAI 兼容接口 (OpenAI Compatible Chat Completions) 接口接入模型服务。目前预设支持 Kimi、DeepSeek 和 OpenAI,也支持接入其他兼容接口的模型服务。 翻译时,会自动展开多个模型和目标语言的组合请求,并将结果按目标语言分组展示,方便直接比较不同模型的表达差异。 **其次,是桌面能力。** 项目基于 Wails v3 构建,因此除了翻译功能之外,也包含一些桌面应用常见能力:系统托盘、应用菜单、启动页、Dock Badge、本地资源嵌入、前后端事件通信。 这些能力和翻译本身关系不大,但决定了它是否能够作为一个长期驻留在桌面上的工具使用,用户体验到底如何。 **另外,和很多脚本工具不同,Kakapo 会保存一些本地状态。** 例如,服务商配置、翻译历史以及 API Key。 其中普通配置保存在本地文件中(`settings.json`),API Key 使用 macOS Keychain 保存,历史记录(`history.json`)则保存在本地并支持简单搜索。 **最后,是一些辅助功能。** 比如:系统朗读、回译、历史记录、结果复制、错误透传。 这些功能单独看都不复杂。但组合起来之后,基本能够覆盖我日常使用翻译工具时的大部分需求。 ### 和翻译产品的不同取向 如果把 Kakapo 和 Mate、DeepL、Google Translate 这类产品放在一起比较,很容易陷入一个误区:比较谁的功能更多。 按这个标准,Kakapo 并没有优势:网页翻译、PDF、字幕、词典、短语本、同步、语言学习,这些能力都不是它的重点。 因为,它原本就不是按照翻译产品的思路设计的。 成熟翻译产品更关注:给用户一个稳定结果、围绕结果提供阅读和学习体验。 而 Kakapo 更关注:接入多个模型、比较多个结果、保留模型选择权、把翻译能力放进我自己的工作流。 两者解决的问题并不完全相同。 如果你的需求是阅读网页、学习语言或者跨设备同步,成熟翻译产品通常会是更好的选择。 所以严格来说,Kakapo 和这些成熟翻译产品并不完全在同一个方向上。前者更关注模型选择和结果比较,后者更关注翻译体验本身。 不同的工作流,对应的自然也是不同的工具设计。 ## 一些实践和取舍 让我们来聊聊制作项目过程中的实践、思考和取舍。 ### 项目是怎么组织起来的 Kakapo 的结构并不复杂。 它外面是一层 Wails v3 桌面应用,负责窗口、托盘、菜单、Dock Badge 和本地资源加载;里面是 Go 写的业务逻辑,负责配置、密钥、历史、模型请求和系统能力;前端则是一个普通 Web 页面,用来处理输入、设置、结果展示和历史查看。 简化之后,大致是这样: ```text Kakapo Desktop App ├── Wails v3 │ ├── 原生窗口 │ ├── 托盘 / 菜单 / Dock Badge │ ├── 嵌入 frontend/dist 静态资源 │ └── 注册多个 Service │ ├── Go 后端 │ ├── TranslateWebService: /translate │ ├── EchoService: /api │ ├── TranslateApp: 翻译业务 │ ├── config: settings.json │ ├── secrets: macOS Keychain / 非 macOS stub │ ├── history: history.json │ ├── speech: macOS say / 非 macOS unsupported │ └── translate: OpenAI 兼容客户端 │ └── 前端页面 ├── /app/index.html └── /translate/index.html ``` 主要功能都在 `/translate` 下面。 翻译页面并没有直接把所有逻辑写进前端,而是通过一组 HTTP 风格的接口调用 Go 侧服务: ```text POST /translate/api/translate GET /translate/api/settings PUT /translate/api/settings GET /translate/api/history POST /translate/api/history DELETE /translate/api/history POST /translate/api/speak POST /translate/api/splash ``` 这里有一个小取舍。 Wails 本身支持 Go 和前端之间的绑定调用,但 Kakapo 没有完全依赖这条路线,而是在 Wails Service 里挂了 Echo,把翻译功能写成了更接近普通 Web API 的形式。 这样写的好处是边界比较清楚。 前端就是 `fetch('/translate/api/...')`,后端就是路由、请求体、响应体和状态码。如果未来想把某些能力拆成独立服务,这种结构也比较容易迁移。 代价是类型约束弱一些。 前端需要自己维护请求封装,Go DTO 和 JavaScript 对象之间也需要保持一致。项目规模小的时候问题不大,接口多起来之后,就需要考虑 TypeScript 类型、OpenAPI 描述或者契约测试。 ### 为什么选择 OpenAI 兼容接口 Kakapo 的模型接入层选择了 OpenAI 兼容接口(OpenAI Compatible Chat Completions)。 这个选择并不复杂。 现在很多模型服务都提供了类似 OpenAI 的接口形态。请求体通常围绕 `model` 和 `messages` 展开: ```json { "model": "kimi-k2.6", "messages": [ { "role": "system", "content": "..." }, { "role": "user", "content": "..." } ] } ``` 这样做最大的好处是接入成本低。 无论是 Kimi、DeepSeek,还是其他兼容 OpenAI 接口的服务,都可以通过类似的方式接进来。前端和后端也不需要为每家服务商单独维护一套 SDK。 但实际使用之后会发现,“兼容 OpenAI”并不等于“完全一致”。 不同服务商之间还是会有很多细节差异: ```text Base URL 是否包含 /v1 是否支持 temperature 是否支持 max_tokens 是否支持 reasoning_effort 是否支持 thinking 错误结构是否一致 限流策略是否一致 流式输出格式是否一致 ``` Kakapo 当前已经对部分模型做了参数适配。 例如 `kimi-k2*` 系列不会发送 `temperature`,会关闭 `thinking`,并设置较大的 `max_tokens`。DeepSeek 的推理模型则会省略 `temperature`,设置 `reasoning_effort=high`,并开启 `thinking`。其他模型则尽量按标准形态发送。 这个处理方式足够实用,但也有明显边界。 目前它主要依赖模型名前缀判断。对于一个小工具来说,这样做简单直接;但如果要长期维护更多服务商,最好把这些规则抽出来,变成可配置的 Provider / Model Profile。 例如: ```yaml providers: moonshot: models: kimi-k2.6: endpoint_mode: openai_base send_temperature: false thinking: disabled max_tokens: 32768 deepseek: models: deepseek-reasoner: endpoint_mode: openai_base send_temperature: false reasoning_effort: high thinking: enabled ``` 这样新增模型时,不一定需要修改 Go 代码。这也是做多模型工具时很容易遇到的问题:统一接口可以降低接入门槛,但服务商差异依然需要一个可维护的描述层。 目前我用来翻译使用的模型比较固定,等这个场景使用的模型更多一些的时候,再更新吧。 ### 一次翻译请求会发生什么 以一次多模型翻译为例,Kakapo 大致会做这些事情: ```text 1. 前端收集输入文本、源语言和目标语言 2. POST /translate/api/translate 3. 后端加载 settings.json 4. 读取启用的 provider 和 model 5. 从 Keychain 获取每个 provider 的 API Key 6. 展开任务:provider × model × targetLanguage 7. 使用有限并发调用上游 Chat Completions 8. 收集每个任务的输出、耗时或错误 9. 返回 MultiTranslationResult 10. 前端按目标语言分组渲染结果卡片 11. 前端将结果写入历史 ``` 这里最关键的是任务展开。 如果启用了 3 个服务商,每个服务商 2 个模型,同时翻译成 3 种语言,一次翻译就会变成 18 个上游请求。 请求数量变多之后,不能直接一股脑全部发出去。一方面容易触发限流,另一方面失败集中出现时也很难判断原因。 所以 Kakapo 在 Go 后端里用了一个固定大小的信号量限制并发: ```go sem := make(chan struct{}, maxParallelTranslations) var wg sync.WaitGroup for i, tk := range tasks { wg.Add(1) go func() { defer wg.Done() sem <- struct{}{} defer func() { <-sem }() // call upstream model }() } wg.Wait() ``` 这个写法很朴素,但第一阶段够用。它不需要额外依赖,结果顺序也容易保持稳定。 当然,它也比较粗。 现在所有服务商共享同一个并发上限,但现实里不同服务商的速率限制和响应速度并不一样。继续往下做,可以拆成全局并发、服务商级并发、模型级超时、429 退避、失败任务单独重试等几层。 现在还没必要一上来就做成完整调度系统,但这个边界要明确。 另一个细节是“部分成功”。 多模型翻译里,某个模型失败很常见。可能是 Key 没配,模型名写错,参数不兼容,服务商限流,网络超时,或者上游返回格式变了。 如果一个模型失败就让整个翻译失败,体验会很差。 所以 Kakapo 的每条结果都有自己的 `output`、`latencyMs` 和 `error`。一个模型失败,不影响其他模型结果展示。 对对比工具来说,这比“一处失败,全部失败”更合适。 ### 接入不同模型服务时遇到的一个小问题 接入 OpenAI 兼容接口服务时,Base URL 是一个很容易被低估的配置项。 很多服务看起来都兼容 OpenAI,但地址规则并不完全一样。有的服务希望用户填写类似下面这样的 Base URL: ```Text https://api.moonshot.cn/v1 https://api.deepseek.com https://api.openai.com ``` 客户端再自动拼接 Chat Completions 路径。 也有一些自定义网关或内部服务,直接要求填写完整 endpoint。这个时候,如果客户端继续自动补 `/v1/chat/completions`,反而会把地址拼错。 所以,在这类工具里,模型服务地址最好不要完全依赖隐式拼接。 更稳妥的方式,是明确区分两种输入: ```text Base URL:填写服务根地址,由客户端补全 Chat Completions 路径 Full Endpoint:填写完整请求地址,客户端直接使用 ``` 这不是一个很大的功能,但对自定义服务商接入很有帮助。 尤其是在企业内部网关、模型代理、统一转发服务这些场景里,请求路径未必和公有云模型服务完全一致。把 endpoint 规则显式化之后,配置成本会稍微增加一点,但排错成本会低很多。 **尽量显式声明,不要依赖“直觉”。** ### 错误信息不能只写“失败” 多服务商工具里,有效展示错误信息非常重要。 第一次配置模型时,最常见的问题往往不是模型不会翻译,而是配置没配对。比如: ```text 401:API Key 错误或未设置 429:限流或额度问题 400:参数不兼容、模型名错误、请求体格式错误 5xx:服务商异常 网络错误:DNS、代理、TLS、连接超时 响应解析错误:服务商返回结构不符合预期 ``` 如果界面只显示“请求失败”,排查会很痛苦。 Kakapo 的客户端会尝试解析几种常见错误结构: ```json {"error": {"message": "..."}} {"error": "..."} {"message": "..."} ``` 如果解析不了,就截断原始响应返回。 这个实现同样不复杂,但很实用。它不能解决所有兼容性问题,但至少能让用户知道错误大概出在哪里。后面,其实还可以继续补设置页的“测试连接”、复制错误详情、按服务商记录最近错误、对 429 做退避重试、对 400 显示可能原因等能力。 这些功能不会提高翻译质量,但会明显降低排错成本,也能减少配置错误带来的中断感和工作效率下降。 ### 数据存储:API Key 和翻译内容其实是两件事 Kakapo 把普通配置和 API Key 分开保存。 普通配置进入 `settings.json`,例如服务商、模型、源语言、目标语言、超时、最大输入长度等;API Key 则放进 macOS Keychain。 前端读取设置时,绝对不会(也不应该)拿到明文 Key,只会看到: ```text apiKeySet: 是否已经设置 apiKeyMask: 脱敏后的尾号提示 ``` 这比把 API Key 写进普通 JSON 文件要好。 但这并不意味着整个工具就是“安全”的。因为翻译历史仍然会保存到本地 `history.json`。如果翻译的是敏感邮件、合同片段、内部文档,内容仍然可能落盘。 所以,这里要分开看: ```text API Key 不写普通配置文件 翻译内容是否落盘,是另一类隐私问题 ``` 当前 Kakapo 的边界很清楚: ```text API Key:macOS Keychain 普通配置:settings.json 翻译历史:history.json,未加密 非 macOS 密钥存储:当前是 stub ``` 这不是问题本身,而是当前阶段的取舍。如果要更适合敏感内容,至少需要补隐私模式、默认不保存历史、历史自动过期或者历史加密。 做这类本地工具时,不要把“密钥安全”和“内容隐私”混在一起。 API Key 进系统密钥链,只解决密钥保存问题。用户输入和模型输出是否保存、保存多久、怎么删除,是另一套设计。 ### 本地历史先简单做 Kakapo 的历史记录使用本地 JSON 文件。 目前它做的事情很克制: ```text 保存在用户配置目录下 最多保留最近 50 条 支持搜索 input 和 output 支持清空历史 用 mutex 保护读写 兼容旧格式字段名 ``` 这个方案适合当前阶段:不需要数据库,文件也方便排查。 但它不适合承担长期知识库的角色。因为它没有加密、标签、收藏、导出、按语言筛选、按模型筛选、全文索引和长期数据迁移策略。 所以当前历史更适合找回最近译文。如果将来希望把 Kakapo 做成长期写作和翻译辅助工具,历史模块应该单独设计,而不是继续在 JSON 里堆字段。就比如 SQLite 就是个很适合的选型。 ### TTS 先用系统能力 Kakapo 里有一个朗读功能,目前在 macOS 上直接调用系统的 `say` 命令。 这个选择主要是为了简单。 朗读只是翻译流程里的辅助能力,没有必要第一版就再接一个云端 TTS 服务。使用系统能力的好处是,不需要额外申请 API Key,也不会因为朗读再多一次云端请求。实现上还做了一个小处理:新的朗读请求会取消上一段还没播放完的内容,避免几个声音叠在一起。 当然,这个方案的边界也很清楚。 它当前主要面向 macOS,依赖系统自带语音。不同系统、不同语言下的语音质量和可用性都不一样。如果后续要做跨平台,就需要分别补 Windows 和 Linux 的 TTS 实现。 如果想要更自然的声音,也可以接入云端语音服务。但那又会带来新的问题:费用、API Key 管理、网络请求,以及翻译内容是否会被再次发送到第三方服务。 所以在当前阶段,系统 TTS 是一个够用的起点。 它不追求完整语音能力,只解决一个很具体的问题:翻译完成后,能顺手听一下结果,尤其是在翻译单个字、词的场景。 ### Wails v3 的好处和代价 Kakapo 用 Wails v3 做“桌面壳”,这个选择和项目本身的需求有关。 它需要一个本地窗口,需要系统托盘、菜单、Dock Badge,也需要访问本地文件、Keychain 和系统命令。与此同时,主要业务逻辑在 Go 侧会更自然一些:读取配置、保存历史、请求模型接口、处理并发和超时。 如果用纯原生写界面,成本会高很多;如果用 Electron,又会引入一整套 Chromium 运行时。对 Kakapo 这种小工具来说,有些重。 Wails 介于两者之间:它允许用 Go 写后端和本地能力,用 Web 技术写界面。界面跑在系统 WebView 里,不需要随应用捆绑完整浏览器。窗口、菜单、托盘、事件和打包这些桌面应用常见能力,也都能放在同一个工程里处理。 对这个项目来说,这是比较合适的组合。 它的好处不是“功能最多”,而是启动成本比较低。我可以把主要精力放在模型调用、配置管理、历史记录和界面交互上,而不是先花很多时间处理桌面应用的基础设施。 当然,Wails 也不是没有代价。 首先,Wails v3 本身还比较新,API、文档和工具链都有继续变化的可能,文档里很多内容都有点问题。不过基于开源生态做事情,阅读源代码是必须的。好在 AI 时代,这个事情的时间成本无限降低。官方文档指路,实际基于源码和 AI Vibe 下就好了。 其次,它使用的是系统 WebView。这让应用更轻,但也意味着不同系统、不同版本之间会有差异。Kakapo 当前前端比较简单,影响不大;如果以后界面变复杂,兼容性测试就会变成实际成本。 最后,Wails 不会自动抹平所有平台差异。Keychain、TTS、托盘、菜单、Dock Badge、打包签名,这些事情仍然需要分平台处理。目前 Kakapo 现在也明显更偏 macOS,很多能力如果要扩展到 Windows 和 Linux,还需要继续补实现。 所以 Wails 的价值不是“消灭桌面应用复杂度”,而是可以降低了小型桌面工具的启动成本。 总的来说,如果项目的主要逻辑在 Go 侧,界面复杂度中等,又需要一些本地系统能力,Wails 是一个值得考虑的选择。但如果项目需要长期稳定的桌面框架 API、非常复杂的前端生态,或者强跨平台一致性,Electron、Tauri,甚至原生方案,都应该一起评估。 ### Go 适合处理这些本地逻辑 Kakapo 后端的大部分事情,放在 Go 里处理比较自然。 比如读取配置文件、保存历史、访问 Keychain、发起 HTTP 请求、处理超时、嵌入静态资源,以及管理 Wails 应用生命周期。这些都不是特别复杂的逻辑,但它们更接近“本地工具”的后端能力,而不是纯前端界面逻辑。 多模型翻译也是类似。 一次翻译可能会同时请求几个模型、几个目标语言。用 goroutine 和 channel 做并发控制,代码会比较直接,也不需要引入额外的调度框架。当前实现里,上游请求已经有 timeout,能避免某个模型一直卡住。 不过,timeout 和“完整取消链路”不是一回事。如果用户关闭窗口、马上重新发起翻译,或者未来前端增加“取消”按钮,取消信号最好能一路传到每个上游 HTTP 请求。 比较理想的链路应该是: ```text 前端 AbortController ↓ Echo request context ↓ TranslateParallel context ↓ 每个上游 http.Request context ↓ 及时取消模型请求 ``` 不过,这不是第一版必须完成的事情。但如果后续支持长文本、流式输出,或者一次请求里包含更多模型,取消链路就会变得重要。否则用户已经不需要某次翻译结果了,后端还在继续请求模型,既浪费时间,也浪费上游额度。 ### Echo 作为一层 HTTP 边界 Kakapo 在 Wails Service 里挂了 Echo。 这不是唯一解。Wails 本身提供 Go 和前端之间的绑定调用,很多桌面应用直接用这套机制就够了。但 Kakapo 里,我还是保留了一层 HTTP 风格的接口。 主要原因是翻译这部分逻辑本身就很像一个小型 Web 服务:前端提交文本和目标语言,后端读取配置、拿 API Key、请求模型服务,然后把结果返回给前端。用 HTTP API 表达这件事很直观: ```Text /translate/api/translate /translate/api/settings /translate/api/history /translate/api/speak ``` 路径清楚,状态码也清楚。 比如配置错误、API Key 缺失、上游限流、模型返回异常,都可以通过比较普通的 HTTP 响应表达出来。前端调用时也只是 `fetch('/translate/api/...')`,调试起来和普通 Web 项目差不多。这层边界还有一个好处:如果以后想把某些能力拆出去,或者临时用浏览器访问调试,迁移成本会低一些。 当然,它也有代价。 使用 HTTP API 之后,前端和后端之间的类型约束会弱一些。请求体和响应体需要自己维护,Go 里的 DTO 和前端对象结构也要保持一致。 项目规模小的时候,这个问题不明显。但接口一多,就需要更认真地管理契约。比如引入 TypeScript 类型、补 OpenAPI 描述、增加契约测试,或者把一部分内部调用重新放回 Wails 绑定里。 所以这里没有绝对正确的选择。 如果项目更像一个 Web 服务搬进桌面应用里,Echo 这层边界会比较自然。 如果项目更看重桌面应用内部调用的类型安全,Wails 绑定会更直接。 Kakapo 当前选择 Echo,主要是因为它的翻译服务本身更接近 HTTP API:输入明确、输出明确、错误也适合用状态码和 JSON 表达。 ### 前端先保持简单 Kakapo 前端用了 Vite 和原生 JavaScript。 这个选择除了因为原生 JS 有性能优势外,对于高频场景收益更大之外,很重要的一点是当前界面还没复杂到必须引入框架。 翻译页主要做几件事:渲染语言列表、渲染服务商卡片、收集设置、调用翻译接口、按目标语言分组展示结果,以及处理复制、朗读、回译和历史搜索。 这些事情用原生 DOM API 都能完成。 这样做的好处是依赖少,构建简单,调试也直接。对于一个桌面小工具来说,第一版少引入一些前端复杂度,反而更容易把主流程跑通。 但这个选择也有边界。 现在页面里已经有不少状态:当前源语言、目标语言、最大输入长度、自动复制、最近一次翻译结果、服务商卡片、设置弹窗、历史弹窗、结果卡片等等。 功能少的时候,这些状态散在页面脚本里还可以接受。如果继续增加流式输出、卡片重试、收藏、标签、提示词模板、导入导出,继续依赖全局变量和 DOM 状态就会变得难维护。 当然,这也不是说后续要立刻引入 React 或 Vue。相比之下,更重要的是先把状态边界拆清楚: ```Text settingsState providersState translationState historyState uiState ``` 等这些状态关系真的开始变复杂,再考虑 TypeScript 或轻量前端框架会更合适。 ### Bun、Vite 和 Task Kakapo 的前端用 Vite 构建,用 Bun 安装依赖和运行前端工具和脚本,用 Taskfile 组织常用命令。 粗看,这只是几个工具选择。但桌面应用和普通 Web 项目不太一样,它不只是把前端 build 一下就结束了。 实际开发时,会反复遇到这些事情: ```text 安装前端依赖 生成 Wails bindings 构建多页面前端 把静态资源嵌入 Go 应用 生成图标 构建应用 运行应用 打包 macOS App 检查许可证头 ``` 这些命令如果都靠手敲,很快就会变得不好维护。所以,项目里用 Taskfile 把它们收拢起来。例如:开发、构建、运行、打包这些动作,都可以通过相对固定的任务入口完成。这样做不是为了追求工具链复杂,而是为了让项目在不同阶段都能按同一套流程重复执行。 代价也很明显。 从源码运行 Kakapo 时,开发者需要准备 Go、Wails CLI、Bun、Task,以及对应平台的打包环境。 好在,使用者只需要关心: ```text 下载应用 配置服务商 开始翻译 ``` 而开发者才需要关心: ```text 安装依赖 启动开发模式 构建前端 生成绑定 打包应用 ``` ### 架构取舍 我们将上面提到的内容放进一张表格里,大概是这个样子: | 选择 | 带来的好处 | 需要承担的成本 | | ----------------- | -------------------------------------- | ---------------------------------- | | Wails v3 | 可以用 Go 写本地逻辑,用 Web 技术写界面,应用也不需要捆绑完整浏览器 | v3 仍然比较新,系统 WebView 和平台能力差异需要自己处理 | | Go 后端 | 处理文件、HTTP 请求、并发、超时和本地系统能力都比较自然 | 如果团队主要是前端背景,上手和调试成本会高一些 | | Echo API | 翻译能力可以按 HTTP API 来组织,路径、状态码和错误表达都比较清楚 | 类型约束弱于 Wails 绑定,需要维护前后端请求结构 | | 原生 JS | 依赖少,第一版启动快,不需要过早引入框架复杂度 | 页面状态变多之后,需要更早整理状态边界 | | OpenAI Compatible | 可以比较容易接入不同模型服务 | “兼容”不等于完全一致,模型参数和 endpoint 规则仍然要处理 | | Keychain | API Key 不落在普通配置文件里 | 当前实现更偏 macOS,跨平台密钥存储还需要补齐 | | JSON 历史 | 简单、可排查,不需要引入数据库 | 不适合长期历史、复杂筛选和敏感内容存储 | 这张表不是为了说明哪种技术更好。 很多时候,技术选择没有脱离场景的绝对答案。Kakapo 当前更像一个本地小工具,所以更看重启动成本、实现清晰度和可维护性。 如果它以后要变成面向更多用户的跨平台应用,取舍就会不一样。比如,历史记录可能需要从 JSON 迁移到 SQLite;模型参数适配可能需要从硬编码变成 Profile;前端也可能需要更明确的状态管理方式。 所以这里真正值得记录的不是“用了什么技术”,而是这些技术在当前阶段解决了哪些问题,又把哪些问题留到了后面。 ## 当前项目边界 Kakapo 目前更接近个人工具和工程样例。 它解决的是文本片段翻译、多模型比较和本地模型接入的问题,不是网页翻译、语言学习工具,也不是完整的翻译项目管理系统。 做任何项目,边界设置其实非常重要。 因为一旦把目标放大,后面要补的东西会很多:跨平台密钥存储、Windows / Linux TTS、历史加密、流式输出、自动更新、签名、公证、崩溃上报、模型能力描述、服务商配置校验。 这些都不是不能做。只是它们会把项目从“一个本地翻译工作台”慢慢推向“完整桌面产品”。精力的投入会无限放大。 当前阶段,项目能够满足文章开头提到的需求,其实就足够啦。 ## 如果你也想采用这套架构 如果你也想用 Wails v3、Go 和 WebView 做类似的小工具,最先要看的不是框架本身,而是项目形态。 这套组合比较适合这样的场景: ```text 需要一个本地窗口 需要访问本地文件、系统命令或系统密钥链 主要逻辑更适合放在 Go 侧 界面复杂度中等 希望应用比 Electron 更轻 可以接受系统 WebView 的平台差异 可以接受 Wails v3 当前阶段的不稳定性 ``` 如果项目大致符合这些条件,Wails 会比较顺手。 尤其是那种“本地能力 + Web 界面 + 少量系统集成”的工具,Go 和 Wails 的组合启动成本不高,也容易把项目结构收住。 但如果项目面向大量普通用户公开分发,或者非常依赖跨平台一致性、复杂前端生态、自动更新、签名、公证、崩溃上报和长期稳定 API,那么就需要更谨慎。 这不是说 Wails 做不了。 而是这些事情不会因为用了 Wails 就自动消失。 做这类项目时,我觉得有几件事最好一开始就想清楚。 **第一,平台能力要尽早列出来。** 比如:密钥存储、TTS、托盘、菜单、通知、文件路径、打包、签名、自动更新。 这些能力在 macOS、Windows 和 Linux 上往往不是一套实现。越早列出来,越不容易后面才发现某个平台缺一块。 **第二,模型服务最好抽象成能力描述。** OpenAI 兼容接口可以降低接入门槛,但不同服务商之间仍然会有参数、endpoint、错误结构和流式输出格式的差异。长期依赖模型名前缀判断,会让后续维护越来越被动。 **第三,历史记录要提前考虑隐私边界。** 默认保存历史很方便,但如果用户可能翻译敏感内容,最好一开始就支持“不保存历史”或者“隐私模式”。 **第四,前端可以先轻,但状态边界不要太晚整理。** 第一版用原生 JS 没问题。真正容易出问题的不是有没有框架,而是状态散在全局变量、DOM 和事件回调里。等到流式输出、卡片重试、收藏、标签这些功能加进来之后,状态边界会变得越来越重要。 **第五,错误信息要尽量可排查。** 多服务商工具里,配置阶段经常比翻译本身更容易出问题。API Key、Base URL、模型名、参数兼容性、限流、网络代理,都可能导致失败。 如果界面只显示“请求失败”,用户会很难判断下一步该改什么。 这些建议并不只针对 Kakapo。 只要是把本地桌面工具、模型服务和多平台能力放在一起,类似的问题基本都会遇到。 ## 最后 Kakapo 解决的是一个很具体的问题:在本地桌面环境里,把多个 OpenAI 兼容模型组织成一个可以比较结果的翻译工作台。它不是为了替代 Mate、DeepL、Google Translate 这类成熟翻译产品,也不是完整的翻译管理系统。 它更像是一个从日常使用里长出来的小工具。当翻译内容主要来自复制粘贴,当结果不再只是“有没有译文”,而是“哪个模型的表达更合适”,这类工具就有了存在空间。 从实现上看,Kakapo 已经跑通了主链路:配置模型服务、保存 API Key、输入文本、并行请求多个模型、展示结果、回译、朗读和保存历史。 这些能力单独看都不复杂。真正有价值的是把它们放进一个本地桌面应用里,并且让这个应用可以继续被修改、被接入新的模型服务、被调整成更贴近自己习惯的工具。 当然,它当前还有不少边界问题。跨平台能力还不完整,历史记录没有加密,模型参数适配还比较依赖代码里的规则,流式输出和请求取消链路也还可以继续补。 但对当前阶段来说,Kakapo 已经完成了最初想解决的事情。它把散落在多个页面、多个模型和多个服务商之间的翻译动作收拢到一个地方,让我能更快地比较结果,并把选择权留在自己手里。 顺便也借这个项目验证了一遍 Wails v3、Go、Echo 和系统 WebView 这套组合。从这点看,它既是一个翻译工具,也是一篇桌面应用技术栈实践的代码注脚。 下一篇文章再见。 --EOF