用Go和Wails v2打造AI对话桌面应用:从流式后端到打包实践

发布时间:2026/11/2 13:23:47
用Go和Wails v2打造AI对话桌面应用:从流式后端到打包实践 拿 Go 写桌面应用在五年前听着像玩笑话但这几年 Wails 把它变成了很现实的选择。我最近做完了一个真正在用的 AI 对话桌面工具就是用 Wails v2 做壳、Go 写后端逻辑、Vue3 渲染聊天界面。起因很简单我平时用网页版 AI 对话挺顺手但要在浏览器一堆标签页里翻来翻去时间一长就觉得烦躁而且网页端的对话记录和配置都散落在各处总有种数据不在自己手里的感觉。干脆自己做一个桌面端聊天窗口固定在那里API Key 我存本地历史记录也归本地管想接哪个模型改个配置就行。这篇文章不聊概念直接讲落地的完整路径。从技术选型、环境初始化、Go 后端的模型调用封装、Vue3 前端的流式渲染到最后的打包发布和进阶改造都是我实测过、踩过坑之后沉淀下来的做法。适合有一点 Go 基础、用过 Vue3、想认真做个桌面 AI 应用的开发者参考。1. 技术选型背后的权衡为什么是 Wails v2 而不是 Electron 或 Tauri先说清楚一件事我不是为了追新才选 Wails而是桌面 AI 应用这个场景里它确实是综合成本最合适的选择。做 AI 桌面应用核心需求和普通管理系统不一样。它要频繁发起 HTTP 请求、处理 SSE 流式数据、管理多轮对话上下文、保存本地配置和聊天记录。这些活儿放在后端比放在前端更合适因为逻辑更清晰、数据更安全、内存控制也更稳健。1.1 桌面 AI 应用的真实需求资源、隐私、模型调用灵活性拿我自己举例。我之前用 Electron 做过一个内部工具界面确实灵活但一个聊天窗口动辄占用三四百 MB 内存风扇呼呼转。AI 对话应用本来就要长时间挂着再加上模型返回内容时前端还要做流式渲染Electron 的进程模型在这种轻交互场景里显得过于笨重。包体积也离谱一个简单聊天工具装出来就是 80MB 起步放在桌面上十分别扭。还有隐私问题。网页版对话每次都要登录、数据都存在服务商那边很多人其实介意这一点。桌面应用的好处是可以把 API Key 和聊天记录全部留在本机对话请求从本地直接发出中间不经过任何第三方转发。要做到这一点后端语言必须能干净利落地处理 HTTP/HTTPS 请求、JSON 序列化和文件存储Go 在这方面的体验是公认的好。再一个是模型调用的灵活性。现在的模型接口五花八门有 OpenAI 兼容协议的有各家厂商自定义协议的还有本地跑 Ollama 的。一个真正能用的 AI 桌面应用最好能统一封装一层让用户填个 Base URL 就能切换模型。这个工作在 Go 后端做比在 JavaScript 前端做要顺手得多。1.2 Wails v2 与主流桌面方案的横向对比我用一张表把几个主流方案的关键差异列出来都是基于我实际开发体验的感受。方案后端语言典型包体Hello World 级运行时内存前端生态适用人群ElectronNode.js / JavaScript70-120MB高最丰富不打算碰其他语言的前端团队TauriRust3-10MB较低丰富能接受 Rust 的学习曲线追求极致体积Wails v2Go8-25MB低成熟Web 标准皆可用以 Go 为主要语言的开发者Electron 的问题是内存和包体Tauri 的问题则在 Rust 本身。Rust 写系统级服务很强但对于调 HTTP 接口、解析 JSON、存个文件这类业务开发效率确实不如 Go。Go 在这类场景就是两个字顺手。标准库里的 net/http 和 encoding/json 足够打天下第三方 SDK 也一抓一大把。1.3 架构概览Wails 前后端通信机制Wails v2 的架构很简单Go 进程作为主进程启动一个原生窗口窗口里加载的是前端资源。前端通过自动生成的绑定代码直接调用 Go 方法参数和返回值自动做 JSON 序列化。这个体验非常接近 Electron 的 IPC但比 IPC 简单得多因为不需要手动维护通道名称和参数序列化。需要重点理解的是事件推送机制。普通的前端调 Go、Go 返回结果是同步请求但对于 AI 流式输出你必须从 Go 后端持续向前端推送数据块这时候要用 Wails 的runtime.EventsEmit和前端EventsOn的组合。这是整个流式对话体验的技术基石后面第 3、4 部分会详细讲。先把这套通信逻辑印在脑子里后面所有代码都围绕它展开。2. 从零初始化项目环境安装与 Wails 项目骨架这套环境的坑不多但有一个前置依赖容易漏导致编译报错时一脸懵。我按顺序把完整流程过一遍。2.1 开发环境准备Go、Node、WebView2 和 Wails CLI先确认基础环境。Go 版本我建议 1.21 以上Node.js 用 16 或更高版本我实测 18.18 LTS 没有问题。Wails CLI 是自己安装在系统里的go install github.com/wailsapp/wails/v2/cmd/wailslatest装完执行wails doctor检查依赖它会提示你缺什么。重点来了Windows 上 Wails 依赖 WebView2 Runtime。Win10 和 Win11 大多数情况下系统自带但某些精简版系统或未更新的设备会没有必须去微软官网单独装。如果你的wails doctor显示 WebView2 缺失或者窗口启动时白屏先查这东西。Linux 用户注意需要系统里装了 WebKit2GTK 开发包。Debian/Ubuntu 系的命令是sudo apt install libgtk-3-dev libwebkit2gtk-4.0-dev这个包安装有时会失败通常是因为 apt 源没更新先sudo apt update再装。2.2 wails init 创建项目并理解目录结构用官方模板初始化wails init -n ai-desktop -t vue-ts cd ai-desktop wails dev这个 vue-ts 模板自带 Vue3 TypeScript Vite 的完整前端工程省去手工配置 Webpack 或 Vite 的步骤。首次运行会自动编译 Go 后端、启动 Vite 开发服务器、打开一个原生窗口前端代码改动热更新Go 代码改动需要手动重启。项目结构拆开看ai-desktop/ ├── main.go # 入口负责创建应用窗口 ├── app.go # App 结构体绑定的 Go 方法写在这里 ├── go.mod ├── wails.json # 项目配置名称、图标、构建参数 ├── frontend/ │ ├── src/ │ │ ├── main.ts │ │ ├── App.vue │ │ └── wailsjs/ # 关键目录自动生成的绑定代码 │ ├── index.html │ ├── package.json │ └── vite.config.ts └── build/ ├── icon.png └── windows/ # 打包用的图标和配置wailsjs目录特别说一下。你写了一个 Go 方法wails dev启动时会自动在前端生成对应的 TypeScript 绑定代码比如wailsjs/go/main/App.ts里会有ChatStream、LoadConfig这些可调用方法。这个目录的内容不要手动改每次启动都会重新生成。如果发现前端调用时方法不存在十有八九是绑定时报错了。app.go里的 App 结构体默认实现了 Wails 的生命周期方法type App struct { ctx context.Context } func (a *App) startup(ctx context.Context) { a.ctx ctx }所有要暴露给前端的方法都要挂在 App 结构体上并且是导出方法。这就是 Wails 的绑定约定只要方法签名合理启动时就会自动变成前端可调用的函数。2.3 跑通第一个开发窗口时要注意的细节wails dev启动后前端 Vite 开发服务器默认跑在一个随机端口上Wails 的窗口直接加载这个地址。你可能会遇到一个情况窗口打开了但内容一直在转圈控制台显示资源加载失败。这基本就是 Vite 端口和前端的wailsjs绑定没有对齐。最笨也最有效的解决办法完全停掉wails dev删除frontend/node_modules重新npm install再启动。这个流程我遇到好几次多半是初始化过程中 npm 和 Go 编译的时序问题。还有一点frontend/src/wailsjs是运行时生成的但如果你用编辑器打开的时机不对会看不到这个目录从而报一堆找不到模块的错误。不要慌启动wails dev前先确认这目录存在第一次启动慢是正常的因为要装 npm 依赖。3. Go 后端核心AI 模型调用的封装与流式输出实现这是整篇文章的重头戏。后端设计得好不好直接决定这个应用能用还是勉强能跑。3.1 统一模型接口设计走 OpenAI 兼容协议现在市面上所有主流模型服务都提供 OpenAI 兼容协议包括各家云厂商的网关和本地 Ollama。这意味着我只需要实现一套客户端逻辑用户填不同的 Base URL 和模型名就能切换服务商。我设计了一个 Config 结构体用来保存所有用户可配置项type Config struct { APIKey string json:apiKey BaseURL string json:baseUrl Model string json:model SystemPrompt string json:systemPrompt } type Message struct { Role string json:role Content string json:content } type App struct { ctx context.Context config *Config history []Message httpClient *http.Client mu sync.Mutex }这里加了一个sync.Mutex因为聊天过程中用户在连续发消息Go 在 Windows 上的事件循环也只是单 goroutine 调用但不能假设它一定安全。多一条锁不会错。3.2 SSE 流式响应的完整实现核心方法叫ChatStream它接收用户消息拼接上下文向模型服务发请求然后把返回的数据流一块块推送出去。func (a *App) ChatStream(userMessage string) error { a.mu.Lock() defer a.mu.Unlock() if a.ctx nil { return fmt.Errorf(app context not ready) } a.config a.LoadConfig() if a.config.BaseURL { a.config.BaseURL https://api.openai.com/v1 } a.history append(a.history, Message{Role: user, Content: userMessage}) payload : map[string]any{ model: a.config.Model, messages: a.buildMessages(), stream: true, } body, err : json.Marshal(payload) if err ! nil { return err } req, err : http.NewRequest(POST, a.config.BaseURL/chat/completions, bytes.NewReader(body)) if err ! nil { return err } req.Header.Set(Content-Type, application/json) req.Header.Set(Authorization, Bearer a.config.APIKey) client : http.Client{} resp, err : client.Do(req) if err ! nil { runtime.EventsEmit(a.ctx, chat:error, err.Error()) return err } defer resp.Body.Close() if resp.StatusCode ! http.StatusOK { errBody, _ : io.ReadAll(resp.Body) runtime.EventsEmit(a.ctx, chat:error, string(errBody)) return fmt.Errorf(model api returned %d, resp.StatusCode) } var fullResponse string scanner : bufio.NewScanner(resp.Body) for scanner.Scan() { line : strings.TrimSpace(scanner.Text()) if line || !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 { piece : chunk.Choices[0].Delta.Content if piece ! { fullResponse piece runtime.EventsEmit(a.ctx, chat:chunk, piece) } } } if fullResponse { runtime.EventsEmit(a.ctx, chat:error, model returned empty response) return fmt.Errorf(empty response) } a.history append(a.history, Message{Role: assistant, Content: fullResponse}) runtime.EventsEmit(a.ctx, chat:done, fullResponse) return nil }这里有几个细节要展开讲。第一http.Client{}我没有设置 Timeout。倒不是说偷懒而是因为本地跑 Ollama 时首次加载模型可能要三十秒甚至更久如果设个 60 秒的超时加载慢的模型会被硬生生掐断。正确做法是不设全局超时用context做主动取消或者干脆让用户在前端手动停止。生产环境里可以做一个停止生成按钮用context.WithCancel实现我后面会提到。第二SSE 解析我用的是bufio.Scanner默认按换行分割。这没问题因为 OpenAI 兼容接口返回的 SSE 就是一行一个字段。但要注意有些代理服务器会额外返回一些空行或者注释行所以我在循环里加了两个条件空行忽略、[DONE]结束实测够用。第三runtime.EventsEmit的次数。模型输出长文的时候每次可能只返回几个字符但事件推送频率极高。实测中发现如果前端渲染逻辑写得糙会明显掉帧。这个问题的解法不是在后端合并数据而是在前端做节流后面会详细说。3.3 配置管理API Key 和模型参数怎么存桌面应用不是 Web 服务没有环境变量可用。我选择把配置写进用户配置目录的 JSON 文件。Go 的标准库os.UserConfigDir()在 Windows 上会返回C:\Users\用户名\AppData\Roaming在 Linux 和 macOS 上分别对应~/.config和~/Library/Application Support天然符合各平台习惯。func (a *App) LoadConfig() *Config { dir, err : os.UserConfigDir() if err ! nil { return defaultConfig() } path : filepath.Join(dir, ai-desktop, config.json) data, err : os.ReadFile(path) if err ! nil { return defaultConfig() } var cfg Config if err : json.Unmarshal(data, cfg); err ! nil { return defaultConfig() } if cfg.BaseURL { cfg.BaseURL https://api.openai.com/v1 } if cfg.Model { cfg.Model gpt-4o-mini } return cfg } func (a *App) SaveConfig(cfg Config) error { dir, err : os.UserConfigDir() if err ! nil { return err } path : filepath.Join(dir, ai-desktop) if err : os.MkdirAll(path, 0755); err ! nil { return err } data, _ : json.MarshalIndent(cfg, , ) return os.WriteFile(filepath.Join(path, config.json), data, 0644) }API Key 明文存 JSON 有没有问题从绝对安全角度说应该用系统钥匙串Windows Credential Manager、macOS Keychain但那样会引入额外的系统 API 依赖对于个人使用的工具来说有些过度。我目前的做法是明文存在本地文件里设置里提示用户此文件仅本机可访问。如果你要发布给别人用建议至少做一层简单的混淆或者转接系统钥匙串。这是我给所有读者的第一个实用提醒不要因为本地文件就觉得万事大吉要意识到这个文件的使用边界。3.4 多轮对话上下文怎么管理OpenAI 兼容接口本身是无状态的每次请求都得把完整对话历史带上去。很多人在这一步踩坑把整个 history 无条件传给模型结果上下文越来越长token 消耗越来越大还会触发上下文窗口超限。我用了简单的滑动窗口策略最近 20 轮对话 系统提示词。这个数字可以写在配置里让用户调。func (a *App) buildMessages() []Message { msgs : make([]Message, 0) if a.config.SystemPrompt ! { msgs append(msgs, Message{Role: system, Content: a.config.SystemPrompt}) } start : len(a.history) - 40 if start 0 { start 0 } msgs append(msgs, a.history[start:]...) return msgs }40 条消息大约是 20 轮对话兼顾上下文连续性和 token 成本。如果用户觉得模型忘事就可以手动调大这个值。还有个小细节在启动应用时startup里就应该把配置和 history 从磁盘加载好而不是每次调用 ChatStream 时重新读文件。我最初的版本就是每次读虽然 JSON 很小读起来很快但无谓的 IO 没有意义。后来改成 App 结构体维护内存态只有 SaveConfig 和退出时才写磁盘干净很多。4. Vue3 前端实现聊天界面、流式渲染与状态管理后端跑通了接下来就是把对话体验做出来。前端这部分我有几个经验和大家分享都是真正影响使用体验的关卡。4.1 前端工程结构与界面规划我用了标准的 Vue3 组合式 API 写法界面主要分成三个区域左侧栏会话列表和新建会话按钮主区域消息列表底部输入框和发送按钮组件划分大致是App.vue管全局状态ChatWindow.vue管消息渲染SettingsPanel.vue管配置。不要把聊天逻辑全部塞在 App.vue 里否则后面加功能会很难受。4.2 调用 Wails 绑定方法的正确姿势wails dev启动后frontend/src/wailsjs/go/main/App.ts会自动生成绑定方法。前端直接 import 使用import { ChatStream, LoadConfig, SaveConfig } from ../wailsjs/go/main/App import { EventsOn } from ../wailsjs/runtime/runtime注意EventsOn是专门用来接收 Go 侧runtime.EventsEmit推送的事件的它和 Wails 的方法绑定是两套体系方法绑定是请求-响应事件是服务器推送到客户端。流式输出必须用后者这个设计先想明白代码读起来就顺了。4.3 SSE 事件接收与打字机效果的实现这段代码是核心中的核心。我在onMounted里注册事件监听注意事件监听器一定要清理否则组件卸载后监听器还挂着再次进入页面会造成重复接收。script setup langts import { ref, onMounted, onUnmounted } from vue import { ChatStream, LoadConfig } from ../wailsjs/go/main/App import { EventsOn } from ../wailsjs/runtime/runtime interface Message { role: user | assistant content: string } const messages refMessage[]([]) const inputText ref() const isResponding ref(false) let offlineChunkHandler: (...args: unknown[]) void let offlineDoneHandler: (...args: unknown[]) void let offlineErrorHandler: (...args: unknown[]) void onMounted(() { offlineChunkHandler EventsOn(chat:chunk, (piece: string) { const last messages.value[messages.value.length - 1] if (last last.role assistant) { last.content piece } else { messages.value.push({ role: assistant, content: piece }) } }) offlineDoneHandler EventsOn(chat:done, () { isResponding.value false }) offlineErrorHandler EventsOn(chat:error, (errMsg: string) { isResponding.value false // 把错误信息显示到界面上 messages.value.push({ role: assistant, content: 请求出错${errMsg} }) }) }) onUnmounted(() { offlineChunkHandler() offlineDoneHandler() offlineErrorHandler() }) async function sendMessage() { const text inputText.value.trim() if (!text || isResponding.value) return messages.value.push({ role: user, content: text }) inputText.value isResponding.value true try { await ChatStream(text) } catch (e) { isResponding.value false // 这里捕获的是方法绑定层的错误比如 Go panic 或返回 error } } /script这段逻辑的关键在于Go 侧在持续推送chat:chunk前端每收到一个 piece 就追加到当前最后一条 assistant 消息上。这样渲染出来的效果就是打字机式的逐字输出。实测中的一个性能问题如果模型输出速度很快chat:chunk事件可能在几十毫秒内触发多次Vue 的响应式系统会频繁触发 DOM 更新长回复时会有明显掉帧。我用的解法是加一个简单的节流用requestAnimationFrame批次更新let pendingText let rafId: number | null null offlineChunkHandler EventsOn(chat:chunk, (piece: string) { pendingText piece if (rafId ! null) return rafId requestAnimationFrame(() { const last messages.value[messages.value.length - 1] if (last last.role assistant) { last.content pendingText } else { messages.value.push({ role: assistant, content: pendingText }) } pendingText rafId null }) })这样无论 Go 侧推送多密前端一帧最多重新渲染一次性能会好很多。这个经验适合所有做流式输出的前端不管是不是 Wails。4.4 多会话和设置面板的进阶设计只有单个聊天窗口的应用太单薄。我加了两个功能多会话切换和设置面板。多会话的做法是前端维护一个conversations数组每个会话有独立的 message 数组和 id。切换会话时把当前会话存起来再加载另一个。这个逻辑在 Vue3 里用ref包一个对象数组就够不需要引入 Pinia。interface Conversation { id: string title: string messages: Message[] } const conversations refConversation[]([]) const activeConversationId ref() const activeConversation computed(() conversations.value.find(c c.id activeConversationId.value) )设置面板则是调用之前写的LoadConfig和SaveConfigasync function openSettings() { const cfg await LoadConfig() settings.form.apiKey cfg.apiKey settings.form.baseUrl cfg.baseUrl settings.form.model cfg.model settings.form.systemPrompt cfg.systemPrompt settings.visible true } async function saveSettings() { await SaveConfig(settings.form) settings.visible false }这里注意一点加载配置的时机最好在应用启动时。我在onMounted里直接就加载了避免用户第一次打开设置面板时看到空值。4.5 一个容易忽略的坑前端路由模式如果你用了 vue-router一定不要用createWebHistory要用createWebHashHistory。原因很简单Wails 打包后加载的是本地静态文件没有服务器做路径重写history 模式的a/b路径刷新后就是找不到资源。而 hash 模式路径全在#后面刷新和导航都走前端逻辑没有任何问题。这是我第一次wails build后白屏的唯一原因排查了半小时。5. 从 dev 到 build打包配置与实战避坑开发模式跑通只是第一步真正折磨人的是构建、打包和分发。我把这一路的经验汇总一下。5.1 wails dev 里的两个高频问题第一个是 Go 方法签名改了但前端调不到。wails dev启动时会重新生成绑定代码但如果你的 Go 代码编译报错或者前端wailsjs目录被手动动过绑定可能停留在旧版本。排查路径看frontend/src/wailsjs/go/main/App.ts里有没有最新方法没有就重启wails dev。第二个是热更新和端口错乱。Wails 的 dev 模式会自己管理 Vite 地址wails.json里有个frontend:dev:serverUrl配置默认是auto表示自动检测端口。如果你手工改了 Vite 的server.port可能导致窗口加载不到资源。最稳的方案是保持默认auto不要手工改 Vite 端口。5.2 跨平台构建的实战操作wails build是最基本的构建命令构建结果输出到build/bin目录。不同平台有各自的定制项。Windows 平台的完整构建wails build -platform windows/amd64 -nsis-nsis参数会生成 NSIS 安装包输出一个 Setup.exe这样用户双击就能安装。如果没有这个参数只会有免安装的 exe。macOS 平台的构建wails build -platform darwin/universal这个会生成同时支持 Apple Silicon 和 Intel 的通用包。需要注意的是Wails 依赖 WebKit而 macOS 上 WebKit 是系统自带的所以不需要额外处理 Runtime 依赖。Linux 平台wails build -platform linux/amd64默认生成 AppImage 还是裸二进制取决于你的 Wails 版本和配置。裸二进制的可移植性不如 AppImage发布时我倾向于用-appimage参数。图标文件是另一个绕不开的坑。Wails 在build/目录下默认放了一个icon.png但不要以为做 Windows 安装包时能直接拿 PNG 用。NSIS 打包需要 ICO 格式的图标Apple 平台需要 ICNS 格式。Windows 上如果用了非 ICO 的图标打包不一定报错但生成的 exe 图标会是默认的空白图标看起来非常业余。正确的做法Windows准备 256x256 的 ICO 文件macOS准备 1024x1024 的 ICNS 文件LinuxPNG 即可wails.json里的info字段也要认真填尤其是productName和productVersion。有些开发者只改outputfilename导致安装后系统里显示的名字还是模板名发布出去很尴尬。5.3 关于包体积和应用分发的三个心得第一Wails 应用的包体比 Electron 小很多是事实我的 AI 对话工具构建出来约 15MB加上图标和资源总共 20MB 以内。这对个人开发者分发工具类应用是很友好的发邮件附件、传到网盘都轻快。第二Windows SmartScreen 的坑。没有代码签名的 exe 首次运行时SmartScreen 会弹出警告用户需要点更多信息-仍要运行。这没办法代码签名证书要钱。我的建议是不用期待所有人理解这个风险提示但如果你的读者圈子里都是开发者他们不会在意这个。第三如果你改了 Go 代码依赖比如加了第三方库最好在同一平台构建不要随便做交叉编译。Wails 依赖 cgo尤其是 Windows 和 Linux 之间的交叉编译常常卡在 GTK/WebKit 依赖上。我实测发现在 Windows 上用GOOSlinux交叉编译基本会失败老老实实在对应平台跑wails build。6. 让应用真正能用的进阶改造本地模型、记录持久化与托盘运行完成了上面这些你的 AI 桌面应用已经是一个能用的产品了。但如果要让它在实际生活里顺手还需要几个加成项。6.1 接入 Ollama 本地模型的两种方式本地模型最大的价值是断网可用、数据完全不出本机、对话免费。Ollama 是目前最省事的本地模型运行时安装后它会提供一个 OpenAI 兼容的接口地址默认是http://127.0.0.1:11434/v1。也就是说如果你的桌面应用已经实现了 OpenAI 兼容协议那么对接 Ollama 只需要在设置面板里填Base URLhttp://127.0.0.1:11434/v1Modelqwen2.5:7b或llama3.1等你本地已经拉取的模型API Key随便填一个非空字符串就行Ollama 不校验这个字段从这个角度看统一走 OpenAI 兼容协议的好处就出来了不管你后面想接云端大模型、企业私有网关、还是本地 Ollama都只需改配置而不用改一行代码。有一点要提醒本地跑 7B 模型需要的内存不小模型加载完通常占 6-8GB 内存。我用的是 7B qwen 量化版本对话流畅在 16GB 内存的机器上运行没问题。如果你的设备内存有限建议选更小的模型响应时间会更短。6.2 聊天记录的持久化方案聊天记录不保存重启就丢这是很多人做 AI 应用时的遗憾。保存方案我对比过两条路JSON 文件和 SQLite。JSON 方案简单可靠适合单人单机的使用场景。我把所有会话序列化成一个数组WriteFile 到同一个配置目录里。缺点是文件会越写越大而且不支持查询。type ChatRecord struct { ID string json:id Title string json:title Messages []Message json:messages CreatedAt time.Time json:createdAt } func (a *App) SaveChats(chats []ChatRecord) error { dir, _ : os.UserConfigDir() path : filepath.Join(dir, ai-desktop, chats.json) data, _ : json.MarshalIndent(chats, , ) return os.WriteFile(path, data, 0644) } func (a *App) LoadChats() ([]ChatRecord, error) { dir, _ : os.UserConfigDir() path : filepath.Join(dir, ai-desktop, chats.json) data, err : os.ReadFile(path) if err ! nil { return []ChatRecord{}, nil } var chats []ChatRecord err json.Unmarshal(data, chats) return chats, err }前端在每次消息更新后调用SaveChats进入应用时调用LoadChats。这里有个性能考虑如果聊天内容非常长全量序列化会有一点延迟但个人使用场景完全无所谓。真到了几百条长对话要全文搜索时换 SQLite 不迟。6.3 系统托盘与全局快捷键桌面应用和网页应用最大的区别之一就在系统集成。Wails v2 本身没有内置系统托盘 API这是它在功能完整度上和 Electron 目前仍有差距的地方。不过社区有方案用 systray 库在 Go 侧启动系统托盘。我的实际经验是托盘这个功能虽然听起来高级但做起来涉及大量系统 API 差异Windows、Linux、macOS 行为不一致。如果只是个人用可以直接把窗口的关闭行为改成隐藏而不是退出func (a *App) HideWindow() { if a.ctx ! nil { wailsWindow : wailsWindowFromContext(a.ctx) if wailsWindow ! nil { wailsWindow.Hide() } } }窗口隐藏后任务栏图标消失用户只能通过重新运行程序来唤出。这个体验不太优雅但至少比应用彻底退出丢状态要好。完整的托盘方案等 Wails 官方支持时再升级这是我在实践中做的最低成本妥协。6.4 一个实用的小技巧停止生成功能对流式对话工具来说停止生成几乎是个刚需。模型话痨的时候没有停止键就只能干等。实现方式是在 App 结构体里维护一个cancel context.CancelFunctype App struct { ctx context.Context cancel context.CancelFunc streaming bool } func (a *App) StopStream() { if a.cancel ! nil { a.cancel() } }在ChatStream创建请求前先派生一个可取消的 contextctx, cancel : context.WithCancel(context.Background()) a.cancel cancel defer cancel() req, _ : http.NewRequestWithContext(ctx, POST, ...)前端加一个停止按钮调用StopStream。这样用户看到模型说出不想要的内容时可以直接掐断请求体验提升非常明显。根据我自己的使用体验这款 AI 桌面应用现在已经完全取代了网页版对话。API Key 和聊天记录长期留在本机启动一两秒就能开聊接本地 Ollama 模型之后哪怕断网也能继续查资料和写方案。整个项目从初始化到稳定使用花了两个周末踩的坑重点都在文章里说明了。如果你正在做类似的工具我的建议是先把 OpenAI 兼容协议这一层做得足够干净后面接什么模型都只是填地址的问题。别一开始就想着把所有模型厂商的 SDK 都集成一遍那是给自己找麻烦。

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询