Wails v2 实战:用 Go + Vue3 做一个真正能用的 AI 桌面应用

Wails v2 实战:用 Go + Vue3 做一个真正能用的 AI 桌面应用

系列说明:这是「AI 详情页生成工具」系列的第 2 篇(实战教程)。第 1 篇拆解了多模型 Agent 流水线的架构设计,第 3 篇复盘踩坑。项目背景:一个上传 1 张商品图、自动生成整套淘宝详情页的桌面客户端,Go 1.26 + Wails v2.15 + Vue 3.5,文末有链接。

一、为什么是 Wails,不是 Electron

做 AI 工具,网页版是最省事的,但这个项目最后做成了桌面端,原因有三:

  1. 隐私即卖点。商品图是卖家的心血,产品宣称"图片只发到用户自己的模型账号、排版合成在本机完成、我方不经手不存储"------这话用桌面端说才成立;
  2. 本机排版引擎。750px 长图合成、系统 CJK 字体渲染,这些是 Go 代码直接干的活,不需要经过网络;
  3. BYOK 密钥存储。用户 API 密钥加密存在本机用户目录,不落任何服务器。

选 Wails 而不是 Electron 的理由很朴素:Electron 打包动辄 200MB+、常驻内存几百 MB,而 Wails 用系统 WebView 渲染前端,Go 编译成原生二进制,产物 20MB 级别、内存占用小一个量级。更重要的是:这个项目的重活(流水线、调度器、排版、加密)全在 Go 侧,前端只是壳,Go 工程师写桌面应用不必再碰 Node 主进程那一套。

代价是 WebView 碎片化:Windows 依赖 WebView2(Win10+ 一般自带)、Linux 依赖 libgtk-3-dev libwebkit2gtk-4.1-dev,不同内核偶有 CSS 差异。对内部工具完全可接受。

二、环境与项目骨架

bash 复制代码
# 依赖:Go ≥ 1.25、Node ≥ 20
go install github.com/wailsapp/wails/v2/cmd/wails@latest
wails init -n myapp -t vue  # 或者像本项目一样手工搭 Vite7 + Vue3 + TS

目录结构(节选自项目实际布局):

csharp 复制代码
├── main.go            # 入口:wails.Run 配置
├── app.go             # App 结构体:所有前端可调用的绑定方法
├── internal/
│   ├── agent/         # LLM 步骤(感知/规划/文案/自检),纯逻辑不碰库
│   ├── service/       # 业务编排:pipeline.go 流水线、scheduler.go 调度器
│   ├── llm/           # OpenAI 兼容网关
│   ├── render/        # 本机位图排版引擎
│   ├── secret/        # 密钥 AES-GCM 加密存储
│   ├── config/        # 本地 config.json
│   ├── database/      # gorm + SQLite
│   └── model/         # 数据模型
└── frontend/          # Vue3 + Pinia + Element Plus
    └── wailsjs/       # ⚠️ wails 自动生成的 Go↔TS 绑定,勿手改

这里想强调一个架构习惯:app.go 只做"绑定方法 + 参数校验 + 转发",不写业务 。项目里甚至用 AST 写了架构护栏测试,强制 app.go 不允许 import gorm/database------所有业务访问必须经 service 层。桌面应用最容易写成"一锅炖",因为所有代码物理距离都那么近,分层纪律要靠机器守。

三、组合根:main.go 里装配一切

Wails 的应用组装全在 main.go,本项目按"数据库 → 配置 → 密钥库 → 服务 → App"顺序做组合根:

go 复制代码
//go:embed all:frontend/dist
var assets embed.FS   // 前端构建产物直接嵌进二进制,发布只有一个文件

func main() {
    db, err := database.Init(appName)                  // SQLite(纯 Go 驱动 glebarez)
    cfg, err := config.Load(appName)                   // 用户配置目录下的 config.json
    dataDir, err := config.Dir(appName)
    secretStore, err := secret.New(dataDir)            // 密钥加密存储
    app := NewApp(service.New(db, cfg, secretStore, dataDir), cfg)

    err = wails.Run(&options.App{
        Title:             "ecom-detail-ai",
        Width:             1024,
        Height:            768,
        Frameless:         frameless(),                // 无边框按平台分叉,见后文
        SingleInstanceLock: &options.SingleInstanceLock{
            UniqueId: "ecom-detail-ai-single-instance",
            OnSecondInstanceLaunch: func(data options.SecondInstanceData) {
                runtime.Show(app.ctx)                 // 二次启动唤起已有窗口
                runtime.WindowUnminimise(app.ctx)
            },
        },
        HideWindowOnClose: hideWindowOnClose(),        // 关闭=隐藏到托盘(Win/Linux)
        AssetServer: &assetserver.Options{
            Assets:  assets,
            Handler: localAssetHandler(dataDir),       // 本地产物暴露给前端
        },
        BackgroundColour: &options.RGBA{R: 14, G: 15, B: 18, A: 1}, // 与前端 --bg-base 一致,防启动白闪
        OnStartup: app.startup,
        OnShutdown: app.shutdown,
        Bind:      []interface{}{app},
    })
}

几个实战细节都是踩过坑之后定下来的:

  • 数据目录用 os.UserConfigDir() :Windows 落到 %AppData%、macOS 落到 ~/Library/Application Support、Linux 落到 ~/.config,一个 API 跨平台,配置/数据库/密钥/产物全放同一目录。千万别往应用安装目录写东西。
  • BackgroundColour 要和前端主题底色一致,否则每次启动白闪一下,廉价感拉满。
  • 无边框窗口必须按平台分叉 :macOS 上 Frameless: true 会让 Wails 跳过 NSWindowStyleMaskTitled,红黄绿交通灯直接消失。正确姿势是 macOS 用 mac.TitleBarHiddenInset() 原生方案,Windows/Linux 才自绘标题栏。

四、Go ↔ Vue 通信:绑定方法 + 事件,两板斧

4.1 前端调 Go:绑定方法

Bind 注册的结构体上,所有导出方法自动生成 TS 声明和调用桩,前端 import 即用:

go 复制代码
// app.go
func (a *App) SelectFile(title string) (string, error) {
    return dialog.SelectFile(a.ctx, title)   // 原生文件对话框
}
func (a *App) CreateTask(imagePath string, extraPaths []string, detailText string) (uint, error) {
    return a.svc.CreateTask(imagePath, extraPaths, detailText)  // 创建生成任务,转发给 service
}
ts 复制代码
// 前端(wailsjs 自动生成的绑定)
import { CreateTask, SelectFile } from '../wailsjs/go/main/App'

const path = await SelectFile('选择商品图')
const taskID = await CreateTask(path, [], '')

结构体参数、返回值会自动 JSON 序列化,等于白送一个类型安全的 RPC。两个坑:绑定方法不要在 UI 线程做长阻塞(调用是异步 Promise,但耗时的活儿要进 goroutine 后靠事件推进度);返回值里的零值字段注意前端判空。

4.2 Go 推前端:事件总线

这个项目的核心体验是"流水线实时时间线",靠的是事件推送而不是前端轮询。接线方式:OnStartup 时把 runtime.EventsEmit 注入 service,业务层持有一个抽象的 emitter:

go 复制代码
// app.go startup
a.svc.SetEmitter(func(name string, payload interface{}) {
    runtime.EventsEmit(ctx, name, payload)
    // 任务完成且窗口最小化时自动弹回
    if evt, ok := payload.(service.TaskEvent); ok && evt.Step == "task" && evt.Status == "done" {
        if runtime.WindowIsMinimised(ctx) {
            runtime.WindowUnminimise(ctx)
            runtime.Show(ctx)
        }
    }
})

service 层完全不知道 Wails 的存在(只拿了一个 func(string, interface{})),单测里注入假 emitter 即可。前端用 runtime 监听:

ts 复制代码
import { EventsOn } from '../../wailsjs/runtime'

EventsOn('task:progress', (e: TaskEvent) => {
  timeline.push(e)   // "感知完成:便携榨汁机" / "第2张触发限流,20s 后重试"
})

最终效果就是这张实拍:左列是感知与规划的结构化结果,右列是随事件一张张"长出来"的流式图片墙,顶部步骤条(抠图→感知→规划→渲染→文案→排版)逐项点亮:

给后端框架注入回调而不是让它 import 框架包,这个小动作让整个流水线层可以脱离桌面环境跑单测,强烈推荐。

4.3 本地产物怎么显示:AssetServer Handler

生成的详情页长图存在数据目录里,<img> 没法直接引 file://。Wails 的 AssetServer 允许挂一个兜底 http.Handler,本项目用它把数据目录以 /local/ 前缀暴露:

go 复制代码
func localAssetHandler(dataDir string) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        const prefix = "/local/"
        if !strings.HasPrefix(r.URL.Path, prefix) { http.NotFound(w, r); return }
        rel, err := url.PathUnescape(strings.TrimPrefix(r.URL.Path, prefix))
        if err != nil { http.NotFound(w, r); return }
        full := filepath.Clean(filepath.Join(dataDir, rel))
        if !strings.HasPrefix(full, dataDir+string(os.PathSeparator)) {
            http.NotFound(w, r)   // 防目录穿越:清洗后必须仍在数据目录内
        }
        http.ServeFile(w, r, full)
    })
}

前端 <img src="'/local/assets/task-1/longpage.png'" /> 就能看图。注意绝对路径也要校验前缀 ,..%2f 这类穿越必须拦。这个 handler 的另一个价值是让"预览本机文件"这件事零成本:不需要把产物复制到临时目录、不需要起独立 HTTP 服务、也不依赖任何自定义协议注册,一个标准 http.Handler 解决。

4.4 前端侧:三个页面 + 三个 store 就够

前端刻意保持薄。views/ 下就四个页面:工作台(Home,选图发起任务)、生成页(实时时间线 + 流式图片墙 + 长图预览)、历史页、设置页(密钥与模型位)。stores/ 下三个 Pinia store:主题、设置、任务。事件监听集中在任务 store 里做单一出口,页面只订阅状态,避免同一个 task:progress 被多个组件各自监听造成状态分叉。

样式上有一条机器强制的规矩:渲染层禁止硬编码 hex 色值 ,全部引用 styles/tokens.css 里的设计变量,ESLint 规则兜底。这条规矩的由来是明暗主题切换:项目初版有三十多处散落的颜色,加暗色主题改了一整周还漏;后来所有颜色收敛进 tokens(背景、文本、边框、品牌色各一组语义变量),换肤变成了改一个文件的事。同理,BackgroundColour 那个启动背景色也必须和 --bg-base token 保持一致------两边是同一个决策的两个出口。

五、接 AI:一个兼容网关吃三家

后端对接 LLM 这件事,被压缩到了极致。智谱、DeepSeek、火山方舟全都兼容 OpenAI 的 chat/completions 协议,所以网关只实现一种协议,换供应商 = 换 base_url + api_key + model 三个参数:

go 复制代码
type Gateway struct{ client *http.Client }

func (g *Gateway) Chat(ctx context.Context, baseURL, apiKey, model string,
    messages []Message, opts ...ChatOption) (string, Usage, error) {

    body, _ := json.Marshal(chatRequest{Model: model, Messages: messages, Stream: false})
    url := strings.TrimRight(baseURL, "/") + "/chat/completions"
    req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))
    req.Header.Set("Authorization", "Bearer "+apiKey)
    // ... 解析 choices[0].message.content + usage(token 用量)
}

两个容易忽略的点:

  • 多模态入参 :Message.Content 是 interface{},纯文本给 string,图文混排给 []ContentPart({"type":"image_url","image_url":{"url":"data:image/png;base64,..."}})。本地图片直接转 base64 data URL 传给视觉模型,无需对象存储。
  • 超时分级:对话客户端 60s,生图客户端单独 120s------图生图接口一次 10--60s 是常态,用同一个 client 会互相拖累。

图像生成同样走 OpenAI 风格 /images/generations,但要正视现实:各家图生图的参考图参数形状不一致。项目的做法是把差异隔离在一个文件:

go 复制代码
// images.go 头部注释(节选)
// ⚠️ 联调标注:各平台图生图参数形状不一致------智谱 GLM-Image、Seedream 的
// 参考图传法各异,联调时按实测修正,只允许改本文件,调用方不受影响。

六、任务持久化:SQLite 当唯一真相

桌面 AI 应用几乎都绕不开"任务队列":一次生成跑十分钟,用户关窗口、程序崩了,任务状态去哪了?本项目的回答是以数据库为唯一真相的调度器:

go 复制代码
// 启动恢复(app.startup 里调用)
func (s *Service) RecoverAndStart() {
    // 1. 上次退出时被中断的任务:running/planned 置 failed,给用户人话提示
    s.db.Model(&model.GenerationTask{}).
        Where("status IN ?", []string{"running", "planned"}).
        Updates(map[string]interface{}{"status": "failed",
            "error_msg": "应用退出中断,请重新发起", "ended_at": nowMillis()})
    // 2. 全部排队中(pending)任务按 id asc 重新入队
    var ids []uint
    s.db.Model(&model.GenerationTask{}).
        Where("status = ?", "pending").Order("id asc").Pluck("id", &ids)
    for _, id := range ids {
        s.enqueue(id)
    }
    // 3. 起 dispatcher goroutine
    s.StartDispatcher()
}

dispatcher 是单 goroutine 常驻 + channel 唤醒,出队时对每个任务 ID 做四重校验(任务仍存在 / 状态仍 pending / 批次存在 / 批次未暂停)------内存队列只是缓存,与 DB 的任何不一致都会在出队时自愈。这个设计让"重启恢复可靠"不再是特性,而是顺理成章的结果。并发上限 1--3 实时读配置热生效;改上限只影响后续出队,不打断运行中任务。

数据库用 GORM + glebarez/sqlite(纯 Go 驱动),没有 CGO,交叉编译 Windows 产物时不折腾 C 工具链------桌面应用场景下这一点值千金。

表结构上有一对值得说的配角:TaskStep(任务每个子步骤的状态与错误信息)和 GeneratedAsset(每张产物图的路径、kind、序号、版本号)。有了"步骤流水账",任何时刻重启后都能回答"这个任务跑到哪一步了";有了"资产表 + 版本号",单张图重新生成不覆盖旧版,用户在结果页可以横向对比来回切版本,导出时取最新版本即可。这两张表让"断点续查、版本化产物、成本审计"三件事共享同一份存储,不需要额外的状态文件。

七、BYOK 密钥:AES-256-GCM 的轻量方案

用户要填四家服务商的 API 密钥,明文绝不能落盘。桌面端的 OS 钥匙串各有平台 API,项目用了一个轻量的加密文件方案(约 200 行):

  • 首次使用时生成 32 字节随机主密钥,存 keystore.key(权限 0600);
  • 密钥明文以 AES-256-GCM 加密(nonce 前置拼进密文),base64 后写 secrets.json(0600);
  • 对外只暴露配置状态和尾号 4 位供 UI 比对,不提供明文枚举接口;
  • 目录权限 0700,全程 sync.Mutex 串行化(绑定方法可能被前端并发调用)。
go 复制代码
nonce := make([]byte, aead.NonceSize())
rand.Read(nonce)
ct := aead.Seal(nonce, nonce, []byte(plaintext), nil)  // nonce+密文一起存
b[provider] = entry{
    CT:   base64.StdEncoding.EncodeToString(ct),
    Tail: keyTail(plaintext),                          // 只存展示用尾号
}

诚实说明威胁模型:这防的是"文件被拷走/同步盘泄露"类低成本窥探,是 OS 钥匙串的轻量等价;本机已失陷的场景任何本地方案都无解。文档里写明白,比吹"军事级加密"体面得多。

八、开发、调试与三平台打包

bash 复制代码
make dev       # wails dev:前端 Vite 热更新 + Go 侧改动自动重编译
make build     # 当前平台产物
make package   # 真安装包:make package os=windows|macos|linux → exe/dmg/AppImage

调试小技巧:Go 侧日志用 log/slog 写文件(lumberjack 滚动),生产态安装包没有终端可看,日志文件是唯一眼睛。开发态 wails dev 自带 Chromium DevTools,前端问题常规处理。

make package 背后是一个自写的 scripts/package.sh,把各平台差异收敛成一条命令:macOS 编译出 .app 后用系统自带的 hdiutil 封装 dmg(零额外依赖);Windows 用 wails build -nsis 出安装器,检测本机没有 makensis 时明确降级为绿色 exe 并打印提示------不假装成功。CI 上 GitHub Actions 三个 runner 各跑一遍 make package 传 artifact 即可。注意 Windows 代码签名和 macOS 公证是"用户看到 SmartScreen 红牌与否"的分水岭,预算允许尽早买证书。

还有一些桌面体验的"最后一公里",Wails 都留了接口,本项目用到的有:系统托盘 (internal/tray 包,Win/Linux 上"关闭=隐藏到托盘",macOS 无托盘则关闭即退出------这个差异要在代码里按平台分叉,不要想当然);原生对话框 (runtime 包的文件选择/保存/消息框,比 HTML <input type=file> 体验好太多,而且拿到的是真实磁盘路径);外链处理 (应用内所有"去官网注册密钥"的链接必须走 runtime.BrowserOpenURL 交给系统浏览器,桌面应用里嵌外链页面是灾难)。这些细节单个都不难,凑起来就是"像正经软件"和"像网页套壳"的区别。

九、小结

回头看这个技术选型组合:

需求 方案
桌面壳 + 前端生态 Wails v2 + Vue3,产物嵌 embed.FS
前端调后端 绑定方法(自动 TS 桩)
后端推进度 EventsEmit/EventsOn,service 持回调不 import wails
本地图片预览 AssetServer Handler + 目录穿越校验
多模型接入 OpenAI 兼容网关,一协议三参数
任务不丢 SQLite 唯一真相 + 出队四重校验 + 启动恢复
密钥安全 AES-256-GCM 加密文件,0600,只露尾号

Wails 的成熟度比我预期高:单实例锁、托盘、原生对话框、无边框这些桌面细节全有官方支持,一周就能把壳搭好;真正花时间的地方在第六、七节这些"数据与钱"的设计上。

第 1 篇(流水线架构)和第 3 篇(踩坑复盘)见作者主页系列文章,欢迎在评论区聊聊你用 Wails 或 Electron 的看法。

相关推荐
牧艺41 分钟前
cos-design 4.0:91 个特效组件一次捅成 React / Vue / Web Components / Core
前端·vue.js·web components
某亿41 分钟前
为什么我放弃了 esbuild,改用 typescript 包做运行时编译
前端·electron
站大爷IP41 分钟前
Python的FastAPI把我坑惨了,原来async def和def的区别这么大
后端
货拉拉技术41 分钟前
大模型在货拉拉营销广告的应用实践
后端
怕浪猫41 分钟前
AI 知识库 WeKnora(腾讯微信团队出品)
后端·面试·github
Blanche150042 分钟前
优化 RAG 应用提升问答准确度
前端
Gopher_HBo42 分钟前
zap日志 整体架构与数据流
后端
颜进强42 分钟前
09 · NestJS Middleware 中间件:链路最外层那个"最像 Express"的家伙
前端·后端·ai编程
创新技术阁42 分钟前
FastapiAdmin 实战:演示模式开关失效的排查记录
前端·后端·fastapi