1. 项目背景:为什么我会用 Wails 做 AI 桌面应用
先交代一下背景。我日常的工作流里有一大半时间花在写代码、查资料、写文档上,AI 聊天工具基本成了刚需。但用了一段时间纯 Web 版之后,有个很烦的问题:每次都要开浏览器、找标签页、复制粘贴上下文,切来切去特别割裂。后来我试过给常用的 AI 网页包一层外层壳子,体验也一般——浏览器地址栏、书签栏、插件图标都还在,完全没有"桌面应用"该有的沉浸感。
这时候我盘算着干脆自己写一个 AI 桌面客户端。第一步就是选技术方案。市面上主流的三条路子我都跑过一遍:
- Electron:生态最成熟,VSCode 都在用,但打包体积 100MB 起步,内存动不动吃 500MB 以上。我手里这块老电脑,开一个 Electron 应用再开浏览器就已经很勉强了。
- Tauri:用 Rust 写后端,体积小很多,但折腾 WebView 版本兼容性和 Rust 的异步生态让我有点头疼,而且团队里没人熟悉 Rust,后续维护成本高。
- Wails v2:用 Go 写后端,前端可以随便选 Vue3 / React / Svelte,编译出来是单个原生可执行文件。用 Go 写了几年后台服务,这个技术栈对我来说几乎没有学习成本。
实际对比下来,Wails v2 的定位非常微妙:它不像 Electron 那样负重前行,也不像 Tauri 那样要求你懂 Rust。它更像是一个"给 Go 开发者准备的桌面 UI 解决方案",把 Go 后端能力和 Web 前端生态缝合在一起。更重要的是,做 AI 桌面应用时,Go 在并发和 HTTP 请求方面的表现非常稳,模型 API 的流式响应用 Go 的io.Reader逐行读特别顺手。
跑通一个 demo 之后,我的结论是:如果你主力语言是 Go,且目标是快速交付一个体面、轻量的桌面工具,Wails 是目前性价比最高的选择。这篇文章我会从零开始,把用 Wails v2 + Go + Vue3 做一个可日常使用的 AI 桌面应用的完整思路、踩坑过程和代码细节都写清楚,适合有 Go 基础、了解一点 Vue3、想尝试桌面端的开发者参考。
2. 整体设计与技术架构拆解
2.1 Wails v2 的核心原理:为什么它能做到轻量
Wails v2 的架构可以用一句话概括:Go 进程启动一个本地 HTTP/WebSocket 服务,系统 WebView 加载前端页面,两者通过一条安全的通信桥互相调用。
具体拆开看有三层:
系统原生 WebView:在 Windows 上用的是 WebView2(Chromium 内核,Edge 浏览器同款),macOS 用的是 WKWebView,Linux 用 WebKitGTK。因为不用打包整个 Chromium,所以最终产物只是一个几 MB 到十几 MB 的原生可执行文件。
Go 后端进程:你的业务逻辑、请求转发、文件读写、数据库操作都跑在 Go 进程里。我给这个应用配置大模型 API 时,API Key 放在后端读取和保存,前端页面拿不到文件系统权限,从设计上就避免了把密钥直接暴露给渲染层。
JS Bindings 通信层:Wails 启动时会从 Go 侧定义的方法自动生成 JS 调用代码,前端可以用
import { Greet } from '../wailsjs/go/main/App'的方式直接调用 Go 方法,返回值会在底层自动做 JSON 序列化和类型转换。反过来 Go 也可以通过runtime.EventsEmit往前端推送事件。
我用一个厨房类比来解释:Electron 是自己扛着整个厨房(Chromium)去你家做菜,Wails 是只带食谱和食材,炉灶(WebView)你家本来就有。这也是 Wails 应用内存占用低、安装包小的根本原因。
2.2 AI 桌面应用的模块划分:我要做的不只是一个聊天框
做之前我先给这个应用画了功能边界。既然是"真正能用"的桌面应用,不是 demo,至少要包含四块:
- 会话管理:支持新建会话、切换历史会话、删除会话。Web 版 AI 工具的上下文隔离做得很差,桌面端可以自由设计本地会话存储。
- 模型配置:允许用户自定义模型 API 的 BaseURL、API Key、模型名称、温度参数。这样不同供应商的模型都能接入,不绑定某一家。
- 流式对话:AI 响应要一个字一个字地蹦出来,不能等全部生成完再显示,这是 AI 应用体验的底线。
- 本地历史记录:所有聊天记录保存在本地文件,不上传任何服务端。隐私可控,换模型或改配置不影响历史数据。
其中最关键的设计决策是:把会话上下文的管理放在 Go 端,前端只负责渲染和输入采集。这么做有三个好处:
- 如果以后实现会话导出、历史检索、向量化索引,数据在 Go 端直接处理,不需要走前端中转。
- 会话上下文可以被多个窗口共享(以后加多窗口功能就不用重写数据层)。
- 前端 Vue3 只维护当前会话的 UI 状态,代码简单很多,心智负担小。
2.3 为什么流式输出必须用事件推送,而不是普通返回值
Wails v2 的普通方法调用是 request/response 模式:前端调用 Go 方法,Go 方法执行完返回结果,前后端交互一次结束。但 AI 流式回复需要持续推送数据到页面,如果等全部文本生成完再一次性返回,用户会盯着空白界面等十几秒,体验非常糟糕。
Wails 官方提供了一套EventEmit / EventsOn机制,正好适合这个场景。Go 端在流式读取过程中,每读到一段增量内容就通过事件推给前端,前端监听事件并实时追加到消息气泡里。这样可以做到边生成边显示,用户感知到的延迟只是首字延迟,而不是总耗时。
我在设计里把它拆成了两个通道:
stream:chunk:传输增量文本片段。stream:done:通知前端本次回复结束,附带最终的完整消息对象。
前端把这两个事件绑定在会话的上下文中,一旦会话切换就重新绑定,避免事件串台。
3. 环境准备与项目初始化:一步步把骨架搭起来
3.1 工具链安装:Go、Node、Wails CLI
先检查已有的环境。我当前用的版本组合是:
| 工具 | 版本 | 说明 |
|---|---|---|
| Go | 1.21+ | Wails v2 要求 Go 1.18 以上,1.21 之后更好用 |
| Node.js | 18+ | Vue3 和 Vite 构建工具链需要 |
| Wails CLI | v2.x | 用 go install 安装,官方推荐方式 |
| WebView2 Runtime | 稳定版 | Windows 10/11 一般自带,老系统可能需要手动装 |
安装 Wails CLI 很简单:
go install github.com/wailsapp/wails/v2/cmd/wails@latest装完验证一下:
wails versionWindows 上如果提示缺少 WebView2,去微软官网装一下运行时即可,这是 Wails 在 Windows 上唯一的系统依赖。
3.2 用官方模板初始化 Vue3 + TypeScript 项目
Wails 官方提供了几种前端模板,我选了vue-ts,理由很简单:Vue3 的 Composition API 配合 TypeScript 写起来顺手,状态管理我打算只用原生的ref/reactive,不需要额外引 Pinia,毕竟这个应用的共享状态并不多。
初始化命令:
wails init -n ai-desktop -t vue-ts cd ai-desktop初始化后的目录结构很清晰:
ai-desktop/ ├── main.go # 入口,配置 Wails 应用窗口和绑定 ├── app.go # 业务逻辑层,定义暴露给前端的方法 ├── go.mod # Go 模块文件 ├── frontend/ # 前端 Vue3 工程 │ ├── index.html │ ├── src/ │ │ ├── App.vue │ │ ├── main.ts │ │ ├── views/ # 页面组件 │ │ └── wailsjs/ # Wails 自动生成的 JS 绑定目录 │ ├── package.json │ └── vite.config.ts └── build/ # 打包配置、图标资源跑起来热开发用一条命令:
wails devWails 会自动编译 Go 代码、启动 Vite 开发服务器、拉起一个原生窗口并完成前后端桥接。热更新时改前端代码,页面即时刷新;改 Go 代码,则需要重新编译。
这个 dev 流程我实测下来很稳,唯一的坑是端口占用。Vite 默认跑在 34115,如果你本机有别的服务占了端口,Wails 会随机换一个,此时前端能正常跑,但 Go 侧如果监听的是固定端口就会对不上。遇到这种情况,直接杀掉占用端口的进程再wails dev干净重启即可。
4. 核心细节:Go 后端如何接入大模型 API
4.1 消息模型与会话上下文定义
我在app.go里定义了两个核心结构体。第一个是消息结构,对应 OpenAI 兼容 API 的标准格式;第二个是会话结构,用于管理上下文列表。
type Message struct { Role string `json:"role"` Content string `json:"content"` } type Session struct { ID string `json:"id"` Title string `json:"title"` Messages []Message `json:"messages"` Created int64 `json:"created"` }为什么要单独定义Session而不是直接用数组存消息?因为会话需要有自己的标题、创建时间、消息历史,这些元数据在 UI 左侧会话列表里都要展示。把上下文数组塞在 Session 内部,后面做持久化到 JSON 文件时就很自然——一个 Session 对应一个 JSON 对象,序列化和反序列化都非常直接。
4.2 调用模型 API:非流式到流式的完整实现
我先写了一个基础方法chatCompletion,它接收用户消息和会话 ID,把上下文拼装成 API 请求体,发送 HTTP 请求获取 AI 回复。核心参数包括:
model:模型名称,由用户配置决定。messages:整个会话的上下文数组,注意要把 system 提示词放在最前面。stream:是否开启流式返回。temperature:温度采样参数,控制回答的随机性,代码类场景我建议设 0.3。
最关键的部分是流式处理。下面是我从实战里提炼出来的一个比较稳的写法:
func (a *App) ChatStream(sessionID, userMessage string) (string, error) { session := a.getSession(sessionID) // 从内存获取会话上下文 session.Messages = append(session.Messages, Message{Role: "user", Content: userMessage}) payload := buildChatPayload(session.Messages, a.getConfig()) resp, err := http.Post(a.getConfig().BaseURL+"/chat/completions", "application/json", bytes.NewBuffer(payload)) if err != nil { return "", err } defer resp.Body.Close() scanner := bufio.NewScanner(resp.Body) // 关键是调大 scanner 的缓冲,否则长响应会被截断 scanner.Buffer(make([]byte, 1024*1024), 1024*1024) var fullContent strings.Builder for scanner.Scan() { line := scanner.Text() 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 { delta := chunk.Choices[0].Delta.Content fullContent.WriteString(delta) // 通过 Wails 事件把增量推给前端 runtime.EventsEmit(a.ctx, "stream:chunk", delta) } } session.Messages = append(session.Messages, Message{Role: "assistant", Content: fullContent.String()}) a.saveSession(session) runtime.EventsEmit(a.ctx, "stream:done", session) return fullContent.String(), nil }这里我踩过两个重要的坑:
第一个坑是bufio.Scanner的默认缓冲只有 64KB。如果模型一次返回的内容特别长,某一行data:数据超过 64KB,Scanner 会直接报错并终止读取,表现为前端只显示了前半段回答就卡住不动。解决办法就是显式设置一个大的 buffer,我直接给了 1MB。
第二个坑是解析 SSE 格式时不能用简单的字符串分割。OpenAI 兼容接口的数据格式是data: {json},但有些实现会在 JSON 里包含换行符,导致一个逻辑块被拆成多行。稳妥的做法是逐行读,只处理以data:开头且data:后面有实际内容的行。我之前用strings.Split按空行切分,结果在 Azure OpenAI 和某些国产模型的兼容接口上就出过解析错位的问题,后来统一改成逐行过滤才稳定。
4.3 上下文长度管理:防止 token 爆掉的策略
会话时间长了,消息列表会无限增长,最终超过模型最大 token 限制。我设计了一个简单的上下文截断策略:超过一定轮数后,把最早的系统提示词保留,把最老的用户/助手消息从中间删除一部分。比如保留最近 20 条消息,再对比总 token 估算,超过阈值时优先截断早期消息。
这个策略虽然朴素,但对于日常聊天场景完全够用。做产品级应用时再考虑基于 Relevance 的智能裁剪或向量摘要即可,初期不需要过度设计。
4.4 配置文件与密钥管理:不硬编码 API Key
应用一定不能让用户改代码去填 API Key。我用一个 JSON 配置文件存所有模型参数,存到用户目录下:
~/.ai-desktop/config.json结构大概是:
type AppConfig struct { BaseURL string `json:"base_url"` APIKey string `json:"api_key"` Model string `json:"model"` Temp float64 `json:"temp"` }首次启动时,如果配置文件不存在就创建一个模板,并给前端暴露LoadConfig/SaveConfig方法。前端做一个设置面板,用户填完保存,Go 端负责写文件。API Key 存在本地文件这种做法谈不上多安全,但比存在前端 localStorage 里被任何页面脚本读取要强得多,也方便用户自己在文件层面控制权限。
5. Vue3 前端实现:界面、状态与事件绑定
5.1 界面布局:三栏结构的取舍
这个应用的主界面我最终做成了左右两栏结构:
- 左侧会话栏:展示会话列表,支持新建会话、点击切换、右键删除。
- 右侧对话区:上方消息列表,下方输入区。
为什么不做三栏(加一栏工具/设置)?因为桌面窗口在 1280 宽度下,三栏会把有效对话区域挤得太窄,用户读 AI 的回答体验变差。设置面板我做成一个弹出的 Modal 窗口,需要时才打开,不让它常驻占用宽度。
App.vue 的主结构如下:
<template> <div class="app-layout"> <Sidebar :sessions="sessions" @select="switchSession" @new="createSession" /> <ChatArea :messages="currentMessages" :loading="loading" @send="handleSend" /> <SettingsModal v-model:visible="showSettings" @save="handleSaveConfig" /> </div> </template>这种拆分方式让每个组件职责单一。Sidebar 只管会话列表的展示和操作信令,ChatArea 管消息渲染和用户输入,SettingsModal 管配置编辑。父子组件之间用事件传递消息,没有引入全局状态库,因为这个应用的状态层级就这么浅,ref加props完全能覆盖。
5.2 通过 Wails Bindings 调用 Go 后端
Wails 初始化完项目后,frontend/src/wailsjs/go/main/App.js里会生成对应的方法绑定。开发时我改了 Go 代码后,记得重新运行wails dev或者在编辑器里触发一次go generate,绑定文件才会更新。这点非常容易忘,改完 Go 方法名前端调用还是旧的,报错后又排查半天。
在 ChatArea 组件里,核心发送逻辑是:
import { ChatStream } from '../wailsjs/go/main/App' import { EventsOn } from '../wailsjs/runtime/runtime' let unregisterChunk = EventsOn('stream:chunk', (delta: string) => { // 把增量文本追加到最后一条消息内容里 appendToLastMessage(delta) }) let unregisterDone = EventsOn('stream:done', (session) => { loading.value = false // 此时后端已经保存了完整会话,前端同步刷新消息列表 markdownContent.value = renderMarkdown(session.messages.at(-1).content) })有一个细节需要特别注意:事件回调里操作的是旧的messages数组引用还是新的?我一开始在回调里直接messages.value.push(...),结果发现当用户快速切换会话时,旧事件的回调会往新会话的消息列表里追加文本,串台了。解决办法是在switchSession切换时先调用unregisterChunk()和unregisterDone()解绑,再重新注册绑定到当前会话的事件回调。
5.3 流式 Markdown 渲染:性能优化
AI 回复基本都是 Markdown 格式,代码块、列表、加粗这些都要正确解析。Vue3 里最直接的做法是安装markdown-it,然后把 markdown 字符串通过v-html渲染到 DOM。
但这里有个性能隐患:流式回复时每秒可能有几十个 chunks,如果每个 chunk 都重新解析整段 Markdown 再渲染整个组件,界面会非常卡。我的优化策略比较简单:
- 消息区加入一个即时渲染的
ref绑定当前正在生成的完整文本,而不是每次接收 chunk 后重建v-for。 - 对于长文本,只在以下时机做完整 Markdown 渲染:用户停止滚动时、回复结束事件到达时、切换会话时。流式进行中,暂时用纯文本/半渲染的
<pre>展示。
实测下来,哪怕回答超过 3000 字,界面滚动和输入都保持流畅,不会因为每帧都跑 Markdown 解析而产生明显卡顿。这个优化虽然土,但在 Wails 的 WebView 上效果显著。
5.4 打字机效果与自动滚动:细节决定体验
流式文本虽然一直在追加,但如果没有自动滚动,用户看到光标停在老位置,还以为程序卡了。我在消息区监听内容高度变化,当底部新消息出现时自动滚动到底部,但如果用户主动向上滚动看历史内容,就暂停自动滚动,直到用户再次滚到底部才恢复。
这个"滚动锁定"逻辑用 Vue 的watch加一个isUserScrolling标志位实现,代码量不大,但对体验提升很明显。
6. 构建打包、体积控制与常见问题
6.1 正式构建:产出可分发安装包
开发调试完成后,执行:
wails build输出目录在build/bin/。默认生成的是当前平台对应的可执行文件。Windows 下我们可以进一步做 NSIS 安装包。Wails 的build/windows/installer目录下有现成的 NSIS 配置,安装 NSIS 工具后可以一键打成带卸载功能的安装包。
打包时会遇到图标问题,Wails 默认给了一个占位图标。我后来用在线工具生成了一套 512x512 的 icon,覆盖build/windows/icon.ico和build/appicon.png。别小看这一步,用户看到的桌面快捷方式和任务栏图标是产品观感的一部分,默认图标显得太业余。
6.2 体积实测:Windows 下 15MB 左右,内存占用低
我打出来的 Windows 可执行文件大约 15MB,加上 NSIS 安装包也在 20MB 以内。对比 Electron 随便上百 MB 的安装包,这个差距是很直观的。
内存占用方面,我在同一台机器上实测:
| 方案 | 空闲内存 | 打开一个聊天会话后 |
|---|---|---|
| Electron 版 AI 客户端 | 420MB | 520MB |
| Wails v2 版本 | 65MB | 110MB |
这里的差距主要来自 Chromium 渲染进程的完整加载和 JS 运行时。Wails 用的是系统 WebView2,WebView2 进程虽然也有内存占用,但因为是系统级共享组件,不会为每个应用各加载一套完整 Chromium,所以实际开销低得多。
6.3 常见问题速查表:我踩过并解决的坑
| 问题 | 现象 | 原因与解决 |
|---|---|---|
| Go 方法暴露不了 | 前端调用报xxx is not a function | App struct 方法必须被绑定到 Wails 应用配置的Bind列表里,且方法名需要首字母大写用a *App作为 receiver,改完务必重新wails generate |
| context 为 nil | Go 侧调用 EventEmit 时崩溃 | Wails 的 context 是通过startup回调注入的,应用启动前调用会拿到 nil。必须在OnStartup里赋值给 App 实例的字段 |
| 流式输出中途截断 | 回复只显示一半 | bufio.Scanner默认 64KB 缓冲过小,设置scanner.Buffer(...)为 1MB |
| 前端热更新失效 | 改 Vue 文件页面无变化 | wails dev偶发 Vite 缓存问题,删除frontend/node_modules/.vite后重启wails dev |
| 打包体积异常大 | 生成的 exe 超过 50MB | 检查是否开启了 CGO 且引入了外部的静态库,某些 Go 依赖会嵌入大的数据文件 |
| 多个窗口/事件串台 | 切换到另一个会话时还在显示旧回复 | 事件回调必须在会话切换时解绑和重新绑定,或在回调里校验会话 ID |
6.4 一个容易被忽视的坑:Go 方法返回值类型要可控
Wails 自动生成 JS 绑定的时候,会对 Go 方法的返回值做序列化。如果方法返回的是自定义 struct,Wails 会生成对应的 TypeScript 类型定义供前端使用,非常方便。但如果你返回的是interface{}或map[string]interface{},在 TS 侧拿到的类型就变成 any 了,开发时容易打错字段名,编译不报错运行时报 undefined。
我后来的建议是:Go 侧尽量返回强类型 struct,前端需要什么字段就定义什么字段,不要图省事返回 map。这种类型严谨性在前端代码复杂起来之后特别重要,否则改一天代码,一半时间在 debug 数据格式。
另外,Go 的int64在 JS 里默认会变成number,如果数值超过Number.MAX_SAFE_INTEGER(比如某些毫秒时间戳),会丢精度。会话时间戳这种场景够用,但如果以后做 ID 生成器,建议直接返回字符串。
7. 项目复盘与我的几点心得
这个项目从零到可日常使用,前后花了大概两周的业余时间。整个过程中最花时间的不是写 UI,也不是接 API,而是围绕流式通信做的一堆状态管理和边界处理。
我个人比较满意的几个决策:
一是把上下文管理放在 Go 端。这样一来,模型 API 的调用、上下文截断、历史持久化全部集中在能力最强的 Go 侧实现,前端只做展示,不存在第二份数据副本。后续我想加"会话重命名""自动摘要标题"之类的功能,直接操作 Session 对象就行,不用改前端。
二是主动放弃过度设计。我不需要多窗口、不需要插件系统、不需要账号体系。这个阶段,保证核心体验流畅、打包轻巧、数据可控是我的北极星指标。多加了复杂功能,最后大多会被砍掉,唯一的作用是拖延上线时间。
三是流式体验的细节打磨值得。如果你愿意,你可以快速跑通一个非流式的聊天窗口,那只需一下午。但距离"真正能用",还差自动滚动、打字机效果、Markdown 渲染性能、会话切换不串台这些细节。这些细节拼起来,才让用户愿意每天都在桌面端用它,而不是去开网页版。
最后再分享一个小技巧:Wails 应用调试时,可以用runtime.WindowShow()和runtime.WindowHide()控制窗口显隐,我把它做成了快捷键绑定,配合自定义日志输出查问题非常方便。应用内所有配置和会话文件都是纯 JSON,也可以直接用文本编辑器打开检查,大大降低了排障成本。
做桌面应用本来是一件门槛挺高的事,但 Wails 把 Go 和 Web 技术栈焊在了一起,让你不需要学一整套新体系就能上手。如果你正好熟悉 Go,也写过 Vue3,那花一个周末把 AI 桌面客户端跑起来完全可行。希望这篇文章能帮你少踩我踩过的那些坑,顺畅地把你的 AI 工具从浏览器搬进桌面。