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

0 阅读13分钟

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 差异。对内部工具完全可接受。

二、环境与项目骨架

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

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

├── 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: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 即用:

// 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
}
// 前端(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:

// 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 监听:

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/ 前缀暴露:

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 三个参数:

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,但要正视现实:各家图生图的参考图参数形状不一致。项目的做法是把差异隔离在一个文件:

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

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

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

// 启动恢复(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 串行化(绑定方法可能被前端并发调用)。
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 钥匙串的轻量等价;本机已失陷的场景任何本地方案都无解。文档里写明白,比吹"军事级加密"体面得多。

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

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 的看法。