ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

用Wails v2 + Go + Vue3构建轻量级AI桌面应用:从选型到打包的实战指南

用Wails v2 + Go + Vue3构建轻量级AI桌面应用:从选型到打包的实战指南

做 AI 桌面应用这件事,我一直觉得是"听起来简单,做起来全是细节"。浏览器里开个网页聊大模型当然容易,但一旦涉及本地文件读取、长期保存的会话记录、系统级快捷键、托盘常驻这些需求,网页就不够用了。去年我决定把手里一个 AI 对话工具从纯网页改成桌面应用,对比了一圈方案,最后落在 Wails v2 + Go + Vue3 上——这套组合让我第一次觉得,用 Go 写桌面端后端,是件很顺手的事。

这篇内容适合两类人:一类是已经写过 Vue3 或 Go,想把现有能力迁移到桌面端的开发者;另一类是被 Electron 的资源占用和打包体积劝退,想找一个更轻的替代方案的人。我会把从选型、初始化、后端 AI 服务封装、前端流式渲染,到打包发布一整个链路都讲一遍,重点放在那些"文档里不会写、只有跑起来才发现的坑"上。

1. 为什么是 Wails v2:在 Electron 和 Tauri 之间,我选了第三条路

先说结论:Wails v2 的核心理念是"用 Go 写后端,用 Web 前端技术写界面,两者通过本地绑定通信"。它没有像 Electron 那样内置整个 Chromium,也没有像 Tauri 那样引入 Rust 的编译链,而是直接调度操作系统自带的 WebView 渲染界面——Windows 上是 WebView2,macOS 上是 WKWebView,Linux 上是 WebKitGTK。

对 AI 桌面应用这个场景,这个选型带来的收益是实实在在的。调用大模型 API 的过程天然是网络 IO 密集型的,Go 的 goroutine 和 net/http 标准库在处理流式响应、并发请求、超时控制这些事上非常顺手。而 Electron 那种"JavaScript 全栈"的模式,虽然也能做,但 Node.js 的异步模型在处理长连接和系统级能力时,代码组织复杂度明显更高。

1.1 一张表看懂三个框架的取舍

我直接用当时考量过的三个方案做个对比,这不是参数打架,而是实际用下来之后的体感:

对比维度ElectronTauriWails v2
后端语言Node.jsRustGo
前端渲染内置 Chromium系统 WebView系统 WebView
打包体积通常 100MB+10MB 左右10MB 左右
内存占用较高低低
后端生态npm 生态Cargo 生态Go 生态
上手难度中等较高(Rust 学习曲线)低(会 Go 就够)
系统 API 访问需要 IPC/原生模块需要 Rust 能力Go 直接写

Tauri 的问题不在性能,而在开发效率。Rust 的所有权和生命周期模型,写小型工具还觉不出什么,一旦要处理像 SSE 流式解析、对话上下文管理、配置文件热更新这种带状态的逻辑,开发速度会明显慢下来。Go 就没有这个问题,协程 + channel 处理流式数据,写起来非常直白。

1.2 对 AI 应用来说,Go 侧意味着什么

AI 桌面应用比普通工具类应用多两个硬需求:一是要处理大模型 API 的流式返回,二是要持久化会话数据。这两件事在 Go 侧都有成熟的解决方案。

流式处理方面,Go 标准库 bufio 配合 ReadString 就可以逐行解析 SSE 格式;数据持久化方面,Go 的 SQLite 驱动(比如 mattn/go-sqlite3、modernc.org/sqlite)非常成熟,把对话历史存进本地数据库,再用 Wails 暴露给前端,整个过程不需要额外起服务。这些你改用 Tauri 就得重新在 Rust 生态里找一遍,改用 Electron 又得回到 Node 的 JSON 进程通信模型里折腾。

当然,Wails 也不是没有问题。系统 WebView 在旧版 Windows 上表现不稳定,WebView2 Runtime 出了问题会很麻烦;Linux 上 WebKitGTK 的渲染差异偶尔会让你怀疑是不是写了 Bug。但从开发效率和最终效果来看,这套组合的优势是清晰且可复现的。

2. 项目初始化与工程骨架:环境准备里最容易翻车的三个环节

Wails v2 的初始化流程不算复杂,但有几个环节很少有人提前讲清楚。我第一次配置环境时就在 Windows 上卡了半天,后来反复重装才明白问题出在哪。

2.1 Wails v2 环境依赖清单

先说依赖,避免你走弯路:

  • Go 1.18 以上版本,这个版本以下跑不了 Wails v2 CLI
  • Node.js 14 以上版本,前端构建会用到,Vue3 的话建议直接上 18+
  • Windows 需要安装 WebView2 Runtime(Win10/11 一般自带,Win Server 或精简版系统容易漏)
  • Linux 需要 webkit2gtk、libgtk-3-dev 等系统库,发行版不同包名不同
  • Windows 还需要 CGO 能正常工作,这意味着你得有 gcc 环境

我当时在 Windows 上反复报gcc找不到,就是 TDM-GCC 没装好。Wails 的工具链依赖 CGO,所以这不是可选项。用go env CGO_ENABLED确认是不是1,如果是0就先把环境变量改回来。

2.2 初始化命令和目录结构

确认环境无误后,接下来的命令会生成一个完整的前后端工程骨架:

go install github.com/wailsapp/wails/v2/cmd/wails@latest wails init -n ai-desktop -t vue-ts

vue-ts模板是 Vue3 + TypeScript + Vite 的组合,做 AI 对话界面很合适。生成的目录结构分三层:

  • frontend/:Vue3 前端源码,和普通 Vite 项目没有区别
  • main.go:Wails 应用入口,负责创建应用实例
  • app.go:默认的 Go 后端逻辑,你的 AI 服务方法就加在这个文件或同包文件里

开发模式下用wails dev启动,这个命令会同时拉起前端 Vite 开发服务器和后端 Go 程序,并在一个窗口里运行。当你改了 Go 代码,它会自动重新编译并重启应用;改了前端代码,Vite 的热更新直接生效。

2.3 连接云端大模型还是本地模型:先想清楚再动手

这个选择会在初始化时就影响架构设计。我当时做的是云端大模型 API 的客户端,所以后端只需要负责 HTTP 调用和 API Key 管理。如果你打算接本地模型(比如 ollama、llama.cpp 拉起 localhost 接口),架构和这个类似,只是地址从远程变成 127.0.0.1,响应模型没有本质区别。

这里给个实在的建议:无论你最终用哪个,后端都抽象出一个ChatProvider接口。这样以后换模型服务商或切换到本地模型,只需要改一处实现,而不是在界面逻辑里到处找请求代码。

type ChatProvider interface { StreamChat(ctx context.Context, messages []Message, onChunk func(string)) error Name() string }

我在项目初始化时就按这个思路搭了骨架,后面加了两个不同的 Provider,前端完全没感知。这个设计让"真正能用"这个目标变得更容易达到——因为你会不可避免地想换模型、调参数、加本地知识库。

3. 后端 Go 层:把 AI 服务封装成 Wails 可调用方法

Wails 的通信模型和传统 Web 开发不一样:前端不是通过 HTTP 请求后端,而是直接调用编译进二进制里的 Go 方法。这个模式省掉了网络端口、跨域配置、鉴权机制这些 Web 层的东西,前后端共享内存地址空间,性能几乎没有额外损耗。

对 AI 应用来说,这种模型最大的优势是:后端可以维护全局状态(比如对话历史、模型配置、用户偏好),前端直接读取或修改,不需要序列化传来传去。

3.1 定义对话数据结构

先定义一套干净的数据结构,这决定了前端交互的边界。我最开始踩过一个坑:直接把大模型 API 的格式透传给前端,导致前端代码里到处是role、content、tool_calls这种变成了隐患的魔法字段。后来重构成只暴露业务模型:

type Message struct { Role string `json:"role"` // "system" "user" "assistant" "tool" Content string `json:"content"` // 对话文本 } type Conversation struct { ID string `json:"id"` Title string `json:"title"` Messages []Message `json:"messages"` Created int64 `json:"created"` Updated int64 `json:"updated"` }

前端拿到的是一个会话对象,而不是一串 API 调用参数。用户新建对话、切换历史、删除会话这些操作,最终都映射到对Conversation的增删改查上。

3.2 流式请求的核心实现

流式响应是 AI 应用体验的灵魂。如果等大模型全部生成完再返回,一个两三百字的回答可能要等十几秒,用户什么都看不到,体验和"挂了"没有区别。

我在 Go 侧用标准的 HTTP 客户端发请求,解析 SSE 流,然后把每个 chunk 通过 Wails 的EventsEmit推给前端。核心代码大致长这样:

func (s *ChatService) StreamChat(convID string, messages []Message, system string) error { endpoint := s.cfg.BaseURL + "/chat/completions" payload := map[string]any{ "model": s.cfg.Model, "messages": messages, "stream": true, "temperature": s.cfg.Temperature, } body, _ := json.Marshal(payload) req, err := http.NewRequestWithContext(s.ctx, "POST", endpoint, bytes.NewReader(body)) if err != nil { return err } req.Header.Set("Content-Type", "application/json") req.Header.Set("Authorization", "Bearer "+s.cfg.ApiKey) resp, err := s.client.Do(req) if err != nil { return err } defer resp.Body.Close() reader := bufio.NewReader(resp.Body) for { line, err := reader.ReadString('\n') if err != nil { if errors.Is(err, io.EOF) { break } return err } line = strings.TrimSpace(line) if !strings.HasPrefix(line, "data:") { continue } data := strings.TrimSpace(strings.TrimPrefix(line, "data:")) if data == "[DONE]" { break } var chunk struct { Choices []struct { Delta struct { Content string `json:"content"` } `json:"delta"` } `json:"choices"` } if err := json.Unmarshal([]byte(data), &chunk); err != nil { continue } if len(chunk.Choices) > 0 { content := chunk.Choices[0].Delta.Content if content != "" { runtime.EventsEmit(s.ctx, "ai:chunk", content) } } } runtime.EventsEmit(s.ctx, "ai:done", convID) return nil }

几个重要的细节:

  • 用http.NewRequestWithContext而不是裸的http.NewRequest,原因是前端取消请求、用户关闭窗口时,这个 context 可以立刻中断连接,避免 goroutine 泄漏。我实测过,不做这个处理,长期使用后内存会缓慢增长,就是这些"死掉的流"在堆积。
  • EventsEmit推的是原始字符串而不是 JSON,因为前端要做的是实时追加,没必要包装。
  • 每个 chunk 单独 emit,不做缓冲。Wails 的 runtime 事件传递开销很低,前端 V8 引擎逐字符追加也不卡。

3.3 上下文管理:别把会话状态丢给前端

对话类的 AI 应用有个隐藏需求:多轮对话要携带上下文。用户问"它是什么",其实指的是上文提到的某个概念,如果后端不把历史消息一起发给模型,模型根本答不出来。

最简单的做法是把历史消息全量拼进每次请求的 messages 数组里。缺点是随着对话变长,请求体越来越大,费用也会线性上涨。所以我在后端做了一个截断策略:

  • 只保留最近的 N 条消息(我这里设的是系统消息 + 最近 12 轮)
  • 超过部分在服务层裁剪,前端不需要感知

这个策略让我在后续扩展"本地知识库检索"时非常方便——剪切后的历史会进入一个摘要机制重新生成,然后继续参与上下文。

数据持久化方面,我用 SQLite 存会话和消息。每次流式响应结束后,把完整消息批量写入数据库,而不是边收 chunk 边写——尽量减少 IO 次数,让数据库写入集中在请求尾部。

4. 前端 Vue3:对话界面与流式渲染的配合

前端是整个应用的观感担当,同时也是最容易出现"看似小事、实则是坑"的地方。

4.1 初始化 Wails 前端模板

Wails 的vue-ts模板自带了一个基础布局。第一次跑起来你会发现,它没有 router、没有 pinia,就是最简单的单页结构。对 AI 对话类应用来说这其实是好事——减少依赖意味着减少出问题的表面。

但你一定要先跑通模板默认提供的Greet方法,确认前后端绑定链路是通的。我见过不少人上来就把模板改成自己的界面,结果一直报window.go is undefined,最后发现是忘了先启动一次默认模板。

4.2 调用后端方法与监听流式事件

前端调用 Go 方法的方式是直接通过 Wails 生成的绑定对象:

// 这是模板自动生成的绑定,你的 Go 方法都挂在这个对象下 const callStream = async (convId: string, messages: Message[], system: string) => { const ok = await window.go.main.ChatService.StreamChat(convId, messages, system) if (!ok) { // 后端返回错误,处理业务异常 } }

关键点在于:ChatService是 Go struct 的名称转成驼峰格式以后,直接挂在window.go下。如果你的 Go 类型名是chatService,那前端调用的就是window.go.main.chatService。Wails 的绑定生成规则不算复杂,但类型大小写不一致时最容易踩到。

流式输出到 UI 的核心逻辑是监听ai:chunk事件,然后更新当前回复的 message 内容:

const messages = ref<Message[]>([]) let streamBuffer = '' runtime.EventsOn('ai:chunk', (chunk: string) => { streamBuffer += chunk if (messages.value.length > 0) { const last = messages.value[messages.value.length - 1] if (last.role === 'assistant') { last.content = streamBuffer } } }) runtime.EventsOn('ai:done', (convId: string) => { // 流式结束,执行收尾操作:保存历史、解锁输入框、关闭 loading 状态等 streamBuffer = '' })

这里有个容易踩的坑:runtime.EventsOn注册的事件回调是整个应用全局共享的。如果你在多个页面或组件里都注册了相同的ai:chunk回调,那同一个 chunk 会被触发多次,界面文字会重复。我在重构组件时连续两次加载同一个对话页面,结果界面上出现了两遍相同的回答。解决方式是组件卸载时用runtime.EventsOff('ai:chunk')取消注册,或者在页面加载前确认没有重复注册。

4.3 流式 Markdown 渲染:实践中发现的卡顿根源

大模型返回的文本默认是 Markdown 格式。如果直接把字符串塞进v-html,输出会像一盘散沙;如果每次 chunk 到达都重新做一次完整的 Markdown 解析和 DOM 渲染,页面会卡。实测发现,在低配 Windows 平板设备上,一个 3000 字的回答,流式渲染到一半时输入框已经开始丢响应。

我的解决方案是:前端不直接渲染 Markdown,而是做"双缓冲"。流式过程中把原始文本追加到一个ref,展示层用一个轻量的纯文本呈现(保留基本换行和代码块的高亮可以后续做);流式结束后再切换成完整 Markdown 渲染组件。这样用户在等待过程中始终能看到文字在动,最后答案完整出现时才做一次真正的 Markdown 解析。

如果一定要实时渲染 Markdown,有一个技巧:用requestAnimationFrame做节流,把多个 chunk 合并到同一帧渲染,而不是每个 chunk 都触发一次渲染:

let pendingChunk = '' let rafId = 0 function onChunk(chunk: string) { pendingChunk += chunk if (rafId) return rafId = requestAnimationFrame(() => { // 把 pendingChunk 合并进最终文本,做一次渲染 rafId = 0 }) }

这个做法在高频事件推流下能明显减少渲染次数,代价是展示有轻微延迟,但用户感知不到。

4.4 快捷键与窗口控制

桌面应用要有桌面应用的样子。我把这些基础体验都做进了前端:

  • 主输入框绑定Ctrl+Enter发送消息,Shift+Enter换行
  • 新建对话支持Ctrl+N
  • 窗口关闭时,后端做一个拦截,如果是误触关闭且当前有未保存的对话,弹出确认框

Wails v2 的窗口控制可以在主窗口运行时通过window.SetSize、window.Minimise等方法操作,这些方法前端可以直接调用,非常自然。这套快捷键逻辑在浏览器网页里实现时必须小心翼翼地处理各种浏览器拦截,在 Wails 里就没有这个问题,事件直接经过 runtime 到达 JS。

5. 调试、构建与常见坑

到了这个阶段,应用已经能跑起来了。但离"真正能用"还有一段距离,这一段距离全是"别人踩过的坑"。

5.1 开发模式下的日志链路

Wails dev 模式下,Go 侧的fmt.Println和log输出会直接打到终端,前端 Vite 的日志也会同时输出。但这两种日志混在一起,很难分清谁是谁。我的做法是:Go 侧统一封装一个logger,带前缀[AI-Go];前端在关键操作上加console.log,前缀[AI-Vue]。排查问题时先在日志里过滤前缀,能省一半时间。

还有一个调试技巧:Wails v2 支持在 dev 模式下打开前端控制台(DevTools),通过快捷键直接进入浏览器调试界面。在这个界面里,你可以直接输入window.go.main.ChatService.StreamChat(...)来手动触发后端方法,不用走 UI 层。

5.2 打包发布:体积和依赖问题

wails build会生成一个可执行二进制文件,默认情况下体积在 10MB~20MB 之间(取决于你用了多少标准库和第三方库)。Windows 上如果你用 UPX 压缩,可以压到 8MB 左右,启动速度基本不受影响。

但打包有一个必须注意的点:除了主程序二进制,Windows 平台还需要保证目标机器上有 WebView2 Runtime。Wails 在 build 时会自动生成wailsbind.exe之类的附加文件,通常你可以通过wails build -m生成 NSIS 安装包,把 WebView2 静默安装一并带进去。

如果目标用户是 Windows 10 以下的系统,这里会有兼容性问题——WebView2 在这些系统上需要特意安装。做面向广泛用户的 AI 工具,这个前置条件要在 README 里写清楚。

5.3 我踩过的几个大坑

挑三个最具代表性的坑说一下,这些不是查文档能立刻定位的。

第一个,Wails 事件名和 Go 方法名重名时的灵异现象。我在某次改造中把前端方法命名为GetChat,把后端也暴露了同名的 Go 方法GetChat,结果前端调用window.go.main.ChatService.GetChat时收到了一个对象而不是字符串,排查了大半天才发现事件名覆盖了绑定方法。建议所有事件名带:前缀(ai:chunk),而 Go 绑定方法都是驼峰命名,两者从规范上区分开。

第二个,Linux 上开发时wails dev窗口不显示,但进程还活着。查了半天,发现是 WebKitGTK 的 GPU 加速问题。解决方法是在启动参数里加上WEBKIT_DISABLE_COMPOSITING_MODE=1,或者换一个已知稳定版本的 webkit2gtk。这个锅不在 Wails,但在 Linux 桌面环境中很影响开发体验。

第三个,SQLite 在 Windows 上的文件锁问题。对话记录默认存在用户目录下,但 Windows 的文件锁机制和 Linux 不一样,如果后端和某个杀毒软件同时扫描同一目录,会出现偶尔的database is locked。我的处理是:初始化时配置 WAL 模式,并设置合理的 busy_timeout,这就解决了大部分偶发锁冲突。在 Go 侧只是多一行连接配置。

6. 把应用进一步"真正能用"的扩展方向

如果核心链路跑通了,这个应用已经具备做正经工具的条件。但我建议你在停下来之前,再看几个"让它真正被日常使用"的扩展点。

首先是本地知识库。桌面应用的优势是能访问本地文件。我后来给这个 AI 桌面应用加了一个简单的文件检索能力:用户拖入一个目录,后端把拆分后的文本块向量化存储(纯 Go 可以用一些本地向量库实现,不需要起外部服务),对话时把用户问题向量化后从本地库检索 Top-K 片段注入上下文。这个功能让"通用聊天"质变为"能回答私有资料问题"的工具。

其次是多模型切换。因为后端一开始就抽象了ChatProvider接口,切换不同服务商只要在注册 Provider 的地方增加一个分支即可。

再有就是托盘常驻。桌面 AI 工具的价值在于"随叫随到",系统托盘 + 全局快捷键呼出主窗口是我后来加上的高频功能。Wails 可以监听全局快捷键,然后唤起窗口事件。

这些扩展不会影响核心架构,因为 Wails 的应用壳是稳定层,Go 后端是逻辑层,Vue3 是交互层,分层清楚,想加什么能力就按各层职责往里塞。

我在做完这套应用之后的最大体会是:技术选型这种事,没有绝对的最好,只有和场景匹配不匹配。Wails v2 + Go + Vue3 这套组合,适合技术栈以 Go 为主、又不想在 Electron 的依赖泥潭里挣扎的团队和个人开发者。把系统 WebView 的兼容性风险前置处理掉,它的开发效率和最终用户体验,都比我预想的要好。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进