翻译工具并不稀缺,但围绕翻译形成的工作流却各不相同。

Kakapo 是一个基于 Wails v3、Go 和 Echo 构建的本地桌面应用,它尝试把多个 OpenAI 兼容模型收拢到同一个界面里,并提供翻译、比较、回译和历史记录等能力。

本文记录这个项目的实现过程,以及这套技术栈在桌面工具场景下的一些实践和取舍。

写在前面

翻译几乎是每天都会出现的动作,不论是在看国外项目文档、不同语言的技术资料,还是在处理邮件,或者是在给开源项目补 README 或整理技术文章。

原本一直在用 Mate,但慢慢发现自己的使用场景和它最擅长解决的问题有些偏离。我越来越少在网页里直接翻译内容,更多时候是在编辑器、终端、文档和 IM 工具里复制一段文本,然后希望同时看看几个模型的结果。

但其实,翻译本身并不复杂。真正麻烦的地方,往往出现在翻译之外。同样一段内容,需要反复复制和整理;几天之后想找回某个译文时,也不一定记得当时是怎么得到的。这些问题都不算大,但会反复出现。

于是,二月份春节期间抽空做了个小工具,名字叫 Kakapo。

Kakapo 主要功能 & 特性

软件功能非常简单,这里就不过多介绍了,下载之后,填入模型的 API 就能够正常使用了。

Kakapo 界面预览

虽然这个工具软件本身并不复杂,但在实现过程中,顺便把 Wails v3、Go、Echo、系统 WebView、Keychain 和 OpenAI 兼容接口这套组合重新走了一遍,也还是踩了一些小坑。

二月一通 Vibe

这篇文章记录一下这个项目,以及这套技术栈在桌面工具场景下的一些实践和思考。

soulteary/kakapo

开源项目地址: https://github.com/soulteary/kakapo/

如果你觉得对你有帮助,欢迎“一键三连”。

为什么没有继续用 Mate

在做 Kakapo 之前,我其实用过一段时间 Mate。

无论是浏览器插件,还是 Setapp 里的桌面版本,Mate 都是一个完成度比较高的翻译产品:网页翻译、选中文字翻译、PDF、字幕、词典、发音、短语本、跨设备同步,这些能力都已经比较成熟。

所以,Kakapo 不是因为“找不到翻译工具”才出现的。

恰恰相反,正是因为已经有成熟翻译工具,才更容易发现自己的需求和这些产品的设计目标并不完全重合。

第一个问题是速度。

这里说的速度,不只是接口响应时间,而是完成一次翻译动作的整体时间。

很多时候,我并不是在浏览网页。而是在编辑器、终端、邮件客户端、文档或者 IM 工具里工作。

这类场景下,最常见的动作其实是:

复制一段文本
切换到翻译工具
粘贴
获得结果
复制回原来的工作区

Mate 在网页内嵌翻译、选中文字翻译、查词和语言学习场景里很方便。但如果大部分内容本来就来自复制粘贴,那么我更需要的是一个处理文本片段的本地工作台,而不是一个围绕网页阅读展开的翻译产品。

第二个问题是翻译风格。

传统翻译工具解决的是:

把内容翻译出来

但我更常遇到的是:

这个表达是否自然
这个语气是否合适
这个术语是否符合上下文
这段话能不能更像人写的

随着大模型的发展,翻译越来越像一种语义转换,而不只是语言转换。有时候我并不需要逐字对应。我需要的是保留原意,同时让目标语言里的表达更自然、更符合当前语境。

第三个问题是决策。

以前翻译工具通常给一个结果。现在我更习惯同时看看几个模型对同一段内容的处理:

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 页面,用来处理输入、设置、结果展示和历史查看。

简化之后,大致是这样:

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 侧服务:

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 的接口形态。请求体通常围绕 modelmessages 展开:

{
  "model": "kimi-k2.6",
  "messages": [
    { "role": "system", "content": "..." },
    { "role": "user", "content": "..." }
  ]
}

这样做最大的好处是接入成本低。

无论是 Kimi、DeepSeek,还是其他兼容 OpenAI 接口的服务,都可以通过类似的方式接进来。前端和后端也不需要为每家服务商单独维护一套 SDK。

但实际使用之后会发现,“兼容 OpenAI”并不等于“完全一致”。

不同服务商之间还是会有很多细节差异:

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。

例如:

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 大致会做这些事情:

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 后端里用了一个固定大小的信号量限制并发:

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 的每条结果都有自己的 outputlatencyMserror。一个模型失败,不影响其他模型结果展示。

对对比工具来说,这比“一处失败,全部失败”更合适。

接入不同模型服务时遇到的一个小问题

接入 OpenAI 兼容接口服务时,Base URL 是一个很容易被低估的配置项。

很多服务看起来都兼容 OpenAI,但地址规则并不完全一样。有的服务希望用户填写类似下面这样的 Base URL:

https://api.moonshot.cn/v1
https://api.deepseek.com
https://api.openai.com

客户端再自动拼接 Chat Completions 路径。

也有一些自定义网关或内部服务,直接要求填写完整 endpoint。这个时候,如果客户端继续自动补 /v1/chat/completions,反而会把地址拼错。

所以,在这类工具里,模型服务地址最好不要完全依赖隐式拼接。

更稳妥的方式,是明确区分两种输入:

Base URL:填写服务根地址,由客户端补全 Chat Completions 路径
Full Endpoint:填写完整请求地址,客户端直接使用

这不是一个很大的功能,但对自定义服务商接入很有帮助。

尤其是在企业内部网关、模型代理、统一转发服务这些场景里,请求路径未必和公有云模型服务完全一致。把 endpoint 规则显式化之后,配置成本会稍微增加一点,但排错成本会低很多。

尽量显式声明,不要依赖“直觉”。

错误信息不能只写“失败”

多服务商工具里,有效展示错误信息非常重要。

第一次配置模型时,最常见的问题往往不是模型不会翻译,而是配置没配对。比如:

401:API Key 错误或未设置
429:限流或额度问题
400:参数不兼容、模型名错误、请求体格式错误
5xx:服务商异常
网络错误:DNS、代理、TLS、连接超时
响应解析错误:服务商返回结构不符合预期

如果界面只显示“请求失败”,排查会很痛苦。

Kakapo 的客户端会尝试解析几种常见错误结构:

{"error": {"message": "..."}}
{"error": "..."}
{"message": "..."}

如果解析不了,就截断原始响应返回。

这个实现同样不复杂,但很实用。它不能解决所有兼容性问题,但至少能让用户知道错误大概出在哪里。后面,其实还可以继续补设置页的“测试连接”、复制错误详情、按服务商记录最近错误、对 429 做退避重试、对 400 显示可能原因等能力。

这些功能不会提高翻译质量,但会明显降低排错成本,也能减少配置错误带来的中断感和工作效率下降。

数据存储:API Key 和翻译内容其实是两件事

Kakapo 把普通配置和 API Key 分开保存。

普通配置进入 settings.json,例如服务商、模型、源语言、目标语言、超时、最大输入长度等;API Key 则放进 macOS Keychain。

前端读取设置时,绝对不会(也不应该)拿到明文 Key,只会看到:

apiKeySet: 是否已经设置
apiKeyMask: 脱敏后的尾号提示

这比把 API Key 写进普通 JSON 文件要好。

但这并不意味着整个工具就是“安全”的。因为翻译历史仍然会保存到本地 history.json。如果翻译的是敏感邮件、合同片段、内部文档,内容仍然可能落盘。

所以,这里要分开看:

API Key 不写普通配置文件
翻译内容是否落盘,是另一类隐私问题

当前 Kakapo 的边界很清楚:

API Key:macOS Keychain
普通配置:settings.json
翻译历史:history.json,未加密
非 macOS 密钥存储:当前是 stub

这不是问题本身,而是当前阶段的取舍。如果要更适合敏感内容,至少需要补隐私模式、默认不保存历史、历史自动过期或者历史加密。

做这类本地工具时,不要把“密钥安全”和“内容隐私”混在一起。

API Key 进系统密钥链,只解决密钥保存问题。用户输入和模型输出是否保存、保存多久、怎么删除,是另一套设计。

本地历史先简单做

Kakapo 的历史记录使用本地 JSON 文件。

目前它做的事情很克制:

保存在用户配置目录下
最多保留最近 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 请求。

比较理想的链路应该是:

前端 AbortController
Echo request context
TranslateParallel context
每个上游 http.Request context
及时取消模型请求

不过,这不是第一版必须完成的事情。但如果后续支持长文本、流式输出,或者一次请求里包含更多模型,取消链路就会变得重要。否则用户已经不需要某次翻译结果了,后端还在继续请求模型,既浪费时间,也浪费上游额度。

Echo 作为一层 HTTP 边界

Kakapo 在 Wails Service 里挂了 Echo。

这不是唯一解。Wails 本身提供 Go 和前端之间的绑定调用,很多桌面应用直接用这套机制就够了。但 Kakapo 里,我还是保留了一层 HTTP 风格的接口。

主要原因是翻译这部分逻辑本身就很像一个小型 Web 服务:前端提交文本和目标语言,后端读取配置、拿 API Key、请求模型服务,然后把结果返回给前端。用 HTTP API 表达这件事很直观:

/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。相比之下,更重要的是先把状态边界拆清楚:

settingsState
providersState
translationState
historyState
uiState

等这些状态关系真的开始变复杂,再考虑 TypeScript 或轻量前端框架会更合适。

Bun、Vite 和 Task

Kakapo 的前端用 Vite 构建,用 Bun 安装依赖和运行前端工具和脚本,用 Taskfile 组织常用命令。

粗看,这只是几个工具选择。但桌面应用和普通 Web 项目不太一样,它不只是把前端 build 一下就结束了。

实际开发时,会反复遇到这些事情:

安装前端依赖
生成 Wails bindings
构建多页面前端
把静态资源嵌入 Go 应用
生成图标
构建应用
运行应用
打包 macOS App
检查许可证头

这些命令如果都靠手敲,很快就会变得不好维护。所以,项目里用 Taskfile 把它们收拢起来。例如:开发、构建、运行、打包这些动作,都可以通过相对固定的任务入口完成。这样做不是为了追求工具链复杂,而是为了让项目在不同阶段都能按同一套流程重复执行。

代价也很明显。

从源码运行 Kakapo 时,开发者需要准备 Go、Wails CLI、Bun、Task,以及对应平台的打包环境。

好在,使用者只需要关心:

下载应用
配置服务商
开始翻译

而开发者才需要关心:

安装依赖
启动开发模式
构建前端
生成绑定
打包应用

架构取舍

我们将上面提到的内容放进一张表格里,大概是这个样子:

选择 带来的好处 需要承担的成本
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 做类似的小工具,最先要看的不是框架本身,而是项目形态。

这套组合比较适合这样的场景:

需要一个本地窗口
需要访问本地文件、系统命令或系统密钥链
主要逻辑更适合放在 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