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 上用的是 WebView2Chromium 内核Edge 浏览器同款macOS 用的是 WKWebViewLinux 用 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先检查已有的环境。我当前用的版本组合是工具版本说明Go1.21Wails v2 要求 Go 1.18 以上1.21 之后更好用Node.js18Vue3 和 Vite 构建工具链需要Wails CLIv2.x用 go install 安装官方推荐方式WebView2 Runtime稳定版Windows 10/11 一般自带老系统可能需要手动装安装 Wails CLI 很简单go install github.com/wailsapp/wails/v2/cmd/wailslatest装完验证一下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 后端如何接入大模型 API4.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:数据超过 64KBScanner 会直接报错并终止读取表现为前端只显示了前半段回答就卡住不动。解决办法就是显式设置一个大的 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 classapp-layout Sidebar :sessionssessions selectswitchSession newcreateSession / ChatArea :messagescurrentMessages :loadingloading sendhandleSend / SettingsModal v-model:visibleshowSettings savehandleSaveConfig / /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 客户端420MB520MBWails v2 版本65MB110MB这里的差距主要来自 Chromium 渲染进程的完整加载和 JS 运行时。Wails 用的是系统 WebView2WebView2 进程虽然也有内存占用但因为是系统级共享组件不会为每个应用各加载一套完整 Chromium所以实际开销低得多。6.3 常见问题速查表我踩过并解决的坑问题现象原因与解决Go 方法暴露不了前端调用报xxx is not a functionApp struct 方法必须被绑定到 Wails 应用配置的Bind列表里且方法名需要首字母大写用a *App作为 receiver改完务必重新wails generatecontext 为 nilGo 侧调用 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 依赖会嵌入大的数据文件多个窗口/事件串台切换到另一个会话时还在显示旧回复事件回调必须在会话切换时解绑和重新绑定或在回调里校验会话 ID6.4 一个容易被忽视的坑Go 方法返回值类型要可控Wails 自动生成 JS 绑定的时候会对 Go 方法的返回值做序列化。如果方法返回的是自定义 structWails 会生成对应的 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 工具从浏览器搬进桌面。
阅读完成 · 觉得有帮助?