[{"data":1,"prerenderedAt":2868},["ShallowReactive",2],{"post-ecom-detail-ai-part2-wails":3,"post-nav-posts":2514,"post-project-ecom-detail-ai-part2-wails":2538},{"id":4,"title":5,"body":6,"cover":2499,"date":2500,"description":88,"extension":2501,"links":2502,"meta":2503,"navigation":211,"path":2504,"project":2505,"seo":2506,"slug":2507,"stem":2508,"summary":2509,"tags":2510,"visible":211,"__hash__":2513},"posts\u002Fposts\u002Fecom-detail-ai-part2-wails.md","Wails v2 实战：用 Go + Vue3 做一个真正能用的 AI 桌面应用",{"type":7,"value":8,"toc":2483},"minimark",[9,32,37,40,63,70,78,82,139,142,150,163,167,174,638,641,692,696,701,707,851,928,935,939,950,1103,1110,1178,1181,1188,1194,1200,1204,1223,1464,1482,1486,1501,1519,1523,1530,1760,1763,1792,1799,1819,1823,1829,2100,2107,2114,2125,2129,2132,2162,2274,2277,2281,2317,2328,2353,2382,2386,2389,2465,2468,2479],[10,11,12],"blockquote",{},[13,14,15,16,21,22,26,27,31],"p",{},"系列说明：这是「AI 详情页生成工具」系列的第 2 篇，讲实战教程。第 1 篇 ",[17,18,20],"a",{"href":19},"\u002Fblog\u002Fecom-detail-ai-part1-architecture","《我用 Go 搭了一条 AI Agent 流水线》"," 拆解多模型流水线的架构设计；第 3 篇 ",[17,23,25],{"href":24},"\u002Fblog\u002Fecom-detail-ai-part3-pitfalls","《一个人做 AI 产品，我踩过的 9 个真实的坑》"," 是踩坑复盘。项目背景：",[17,28,30],{"href":29},"\u002Fprojects\u002Fecom-detail-ai","电商详情页 AI 生成客户端","——上传 1 张商品图、自动生成整套淘宝详情页的桌面客户端，Go 1.26 + Wails v2.15 + Vue 3.5。",[33,34,36],"h2",{"id":35},"一为什么是-wails不是-electron","一、为什么是 Wails，不是 Electron",[13,38,39],{},"做 AI 工具，网页版是最省事的，但这个项目最后做成了桌面端，原因有三：",[41,42,43,51,57],"ol",{},[44,45,46,50],"li",{},[47,48,49],"strong",{},"隐私即卖点","。商品图是卖家的心血，产品宣称\"图片只发到用户自己的模型账号、排版合成在本机完成、我方不经手不存储\"——这话用桌面端说才成立；",[44,52,53,56],{},[47,54,55],{},"本机排版引擎","。750px 长图合成、系统 CJK 字体渲染，这些是 Go 代码直接干的活，不需要经过网络；",[44,58,59,62],{},[47,60,61],{},"BYOK 密钥存储","。用户 API 密钥加密存在本机用户目录，不落任何服务器。",[13,64,65,66,69],{},"选 Wails 而不是 Electron 的理由很朴素：Electron 打包动辄 200MB+、常驻内存几百 MB，而 Wails 用系统 WebView 渲染前端，Go 编译成原生二进制，产物 20MB 级别、内存占用小一个量级。更重要的是：",[47,67,68],{},"这个项目的重活（流水线、调度器、排版、加密）全在 Go 侧，前端只是壳","，Go 工程师写桌面应用不必再碰 Node 主进程那一套。",[13,71,72,73,77],{},"代价是 WebView 碎片化：Windows 依赖 WebView2（Win10+ 一般自带）、Linux 依赖 ",[74,75,76],"code",{},"libgtk-3-dev libwebkit2gtk-4.1-dev","，不同内核偶有 CSS 差异。对内部工具完全可接受。",[33,79,81],{"id":80},"二环境与项目骨架","二、环境与项目骨架",[83,84,89],"pre",{"className":85,"code":86,"language":87,"meta":88,"style":88},"language-bash shiki shiki-themes github-dark github-light","# 依赖：Go ≥ 1.25、Node ≥ 20\ngo install github.com\u002Fwailsapp\u002Fwails\u002Fv2\u002Fcmd\u002Fwails@latest\nwails init -n myapp -t vue  # 或者像本项目一样手工搭 Vite7 + Vue3 + TS\n","bash","",[74,90,91,100,114],{"__ignoreMap":88},[92,93,96],"span",{"class":94,"line":95},"line",1,[92,97,99],{"class":98},"si27w","# 依赖：Go ≥ 1.25、Node ≥ 20\n",[92,101,103,107,111],{"class":94,"line":102},2,[92,104,106],{"class":105},"sqoU-","go",[92,108,110],{"class":109},"skb7c"," install",[92,112,113],{"class":109}," github.com\u002Fwailsapp\u002Fwails\u002Fv2\u002Fcmd\u002Fwails@latest\n",[92,115,117,120,123,127,130,133,136],{"class":94,"line":116},3,[92,118,119],{"class":105},"wails",[92,121,122],{"class":109}," init",[92,124,126],{"class":125},"sTU5a"," -n",[92,128,129],{"class":109}," myapp",[92,131,132],{"class":125}," -t",[92,134,135],{"class":109}," vue",[92,137,138],{"class":98},"  # 或者像本项目一样手工搭 Vite7 + Vue3 + TS\n",[13,140,141],{},"目录结构（节选自项目实际布局）：",[83,143,148],{"className":144,"code":146,"language":147},[145],"language-text","├── main.go            # 入口：wails.Run 配置\n├── app.go             # App 结构体：所有前端可调用的绑定方法\n├── internal\u002F\n│   ├── agent\u002F         # LLM 步骤（感知\u002F规划\u002F文案\u002F自检），纯逻辑不碰库\n│   ├── service\u002F       # 业务编排：pipeline.go 流水线、scheduler.go 调度器\n│   ├── llm\u002F           # OpenAI 兼容网关\n│   ├── render\u002F        # 本机位图排版引擎\n│   ├── secret\u002F        # 密钥 AES-GCM 加密存储\n│   ├── config\u002F        # 本地 config.json\n│   ├── database\u002F      # gorm + SQLite\n│   └── model\u002F         # 数据模型\n└── frontend\u002F          # Vue3 + Pinia + Element Plus\n    └── wailsjs\u002F       # ⚠️ wails 自动生成的 Go↔TS 绑定，勿手改\n","text",[74,149,146],{"__ignoreMap":88},[13,151,152,153,159,160,162],{},"这里想强调一个架构习惯：",[47,154,155,158],{},[74,156,157],{},"app.go"," 只做\"绑定方法 + 参数校验 + 转发\"，不写业务","。项目里甚至用 AST 写了架构护栏测试，强制 ",[74,161,157],{}," 不允许 import gorm\u002Fdatabase——所有业务访问必须经 service 层。桌面应用最容易写成\"一锅炖\"，因为所有代码物理距离都那么近，分层纪律要靠机器守。",[33,164,166],{"id":165},"三组合根maingo-里装配一切","三、组合根：main.go 里装配一切",[13,168,169,170,173],{},"Wails 的应用组装全在 ",[74,171,172],{},"main.go","，本项目按\"数据库 → 配置 → 密钥库 → 服务 → App\"顺序做组合根：",[83,175,178],{"className":176,"code":177,"language":106,"meta":88,"style":88},"language-go shiki shiki-themes github-dark github-light","\u002F\u002Fgo:embed all:frontend\u002Fdist\nvar assets embed.FS   \u002F\u002F 前端构建产物直接嵌进二进制，发布只有一个文件\n\nfunc main() {\n    db, err := database.Init(appName)                  \u002F\u002F SQLite（纯 Go 驱动 glebarez）\n    cfg, err := config.Load(appName)                   \u002F\u002F 用户配置目录下的 config.json\n    dataDir, err := config.Dir(appName)\n    secretStore, err := secret.New(dataDir)            \u002F\u002F 密钥加密存储\n    app := NewApp(service.New(db, cfg, secretStore, dataDir), cfg)\n\n    err = wails.Run(&options.App{\n        Title:             \"ecom-detail-ai\",\n        Width:             1024,\n        Height:            768,\n        Frameless:         frameless(),                \u002F\u002F 无边框按平台分叉，见后文\n        SingleInstanceLock: &options.SingleInstanceLock{\n            UniqueId: \"ecom-detail-ai-single-instance\",\n            OnSecondInstanceLaunch: func(data options.SecondInstanceData) {\n                runtime.Show(app.ctx)                 \u002F\u002F 二次启动唤起已有窗口\n                runtime.WindowUnminimise(app.ctx)\n            },\n        },\n        HideWindowOnClose: hideWindowOnClose(),        \u002F\u002F 关闭=隐藏到托盘（Win\u002FLinux）\n        AssetServer: &assetserver.Options{\n            Assets:  assets,\n            Handler: localAssetHandler(dataDir),       \u002F\u002F 本地产物暴露给前端\n        },\n        BackgroundColour: &options.RGBA{R: 14, G: 15, B: 18, A: 1}, \u002F\u002F 与前端 --bg-base 一致，防启动白闪\n        OnStartup: app.startup,\n        OnShutdown: app.shutdown,\n        Bind:      []interface{}{app},\n    })\n}\n",[74,179,180,185,207,213,225,246,266,282,302,321,326,358,370,381,392,407,424,435,460,475,486,492,498,513,531,537,552,557,602,608,614,626,632],{"__ignoreMap":88},[92,181,182],{"class":94,"line":95},[92,183,184],{"class":98},"\u002F\u002Fgo:embed all:frontend\u002Fdist\n",[92,186,187,191,195,198,201,204],{"class":94,"line":102},[92,188,190],{"class":189},"spKkM","var",[92,192,194],{"class":193},"shWlK"," assets ",[92,196,197],{"class":105},"embed",[92,199,200],{"class":193},".",[92,202,203],{"class":105},"FS",[92,205,206],{"class":98},"   \u002F\u002F 前端构建产物直接嵌进二进制，发布只有一个文件\n",[92,208,209],{"class":94,"line":116},[92,210,212],{"emptyLinePlaceholder":211},true,"\n",[92,214,216,219,222],{"class":94,"line":215},4,[92,217,218],{"class":189},"func",[92,220,221],{"class":105}," main",[92,223,224],{"class":193},"() {\n",[92,226,228,231,234,237,240,243],{"class":94,"line":227},5,[92,229,230],{"class":193},"    db, err ",[92,232,233],{"class":189},":=",[92,235,236],{"class":193}," database.",[92,238,239],{"class":105},"Init",[92,241,242],{"class":193},"(appName)                  ",[92,244,245],{"class":98},"\u002F\u002F SQLite（纯 Go 驱动 glebarez）\n",[92,247,249,252,254,257,260,263],{"class":94,"line":248},6,[92,250,251],{"class":193},"    cfg, err ",[92,253,233],{"class":189},[92,255,256],{"class":193}," config.",[92,258,259],{"class":105},"Load",[92,261,262],{"class":193},"(appName)                   ",[92,264,265],{"class":98},"\u002F\u002F 用户配置目录下的 config.json\n",[92,267,269,272,274,276,279],{"class":94,"line":268},7,[92,270,271],{"class":193},"    dataDir, err ",[92,273,233],{"class":189},[92,275,256],{"class":193},[92,277,278],{"class":105},"Dir",[92,280,281],{"class":193},"(appName)\n",[92,283,285,288,290,293,296,299],{"class":94,"line":284},8,[92,286,287],{"class":193},"    secretStore, err ",[92,289,233],{"class":189},[92,291,292],{"class":193}," secret.",[92,294,295],{"class":105},"New",[92,297,298],{"class":193},"(dataDir)            ",[92,300,301],{"class":98},"\u002F\u002F 密钥加密存储\n",[92,303,305,308,310,313,316,318],{"class":94,"line":304},9,[92,306,307],{"class":193},"    app ",[92,309,233],{"class":189},[92,311,312],{"class":105}," NewApp",[92,314,315],{"class":193},"(service.",[92,317,295],{"class":105},[92,319,320],{"class":193},"(db, cfg, secretStore, dataDir), cfg)\n",[92,322,324],{"class":94,"line":323},10,[92,325,212],{"emptyLinePlaceholder":211},[92,327,329,332,335,338,341,344,347,350,352,355],{"class":94,"line":328},11,[92,330,331],{"class":193},"    err ",[92,333,334],{"class":189},"=",[92,336,337],{"class":193}," wails.",[92,339,340],{"class":105},"Run",[92,342,343],{"class":193},"(",[92,345,346],{"class":189},"&",[92,348,349],{"class":105},"options",[92,351,200],{"class":193},[92,353,354],{"class":105},"App",[92,356,357],{"class":193},"{\n",[92,359,361,364,367],{"class":94,"line":360},12,[92,362,363],{"class":193},"        Title:             ",[92,365,366],{"class":109},"\"ecom-detail-ai\"",[92,368,369],{"class":193},",\n",[92,371,373,376,379],{"class":94,"line":372},13,[92,374,375],{"class":193},"        Width:             ",[92,377,378],{"class":125},"1024",[92,380,369],{"class":193},[92,382,384,387,390],{"class":94,"line":383},14,[92,385,386],{"class":193},"        Height:            ",[92,388,389],{"class":125},"768",[92,391,369],{"class":193},[92,393,395,398,401,404],{"class":94,"line":394},15,[92,396,397],{"class":193},"        Frameless:         ",[92,399,400],{"class":105},"frameless",[92,402,403],{"class":193},"(),                ",[92,405,406],{"class":98},"\u002F\u002F 无边框按平台分叉，见后文\n",[92,408,410,413,415,417,419,422],{"class":94,"line":409},16,[92,411,412],{"class":193},"        SingleInstanceLock: ",[92,414,346],{"class":189},[92,416,349],{"class":105},[92,418,200],{"class":193},[92,420,421],{"class":105},"SingleInstanceLock",[92,423,357],{"class":193},[92,425,427,430,433],{"class":94,"line":426},17,[92,428,429],{"class":193},"            UniqueId: ",[92,431,432],{"class":109},"\"ecom-detail-ai-single-instance\"",[92,434,369],{"class":193},[92,436,438,441,443,445,449,452,454,457],{"class":94,"line":437},18,[92,439,440],{"class":193},"            OnSecondInstanceLaunch: ",[92,442,218],{"class":189},[92,444,343],{"class":193},[92,446,448],{"class":447},"sdxZB","data",[92,450,451],{"class":105}," options",[92,453,200],{"class":193},[92,455,456],{"class":105},"SecondInstanceData",[92,458,459],{"class":193},") {\n",[92,461,463,466,469,472],{"class":94,"line":462},19,[92,464,465],{"class":193},"                runtime.",[92,467,468],{"class":105},"Show",[92,470,471],{"class":193},"(app.ctx)                 ",[92,473,474],{"class":98},"\u002F\u002F 二次启动唤起已有窗口\n",[92,476,478,480,483],{"class":94,"line":477},20,[92,479,465],{"class":193},[92,481,482],{"class":105},"WindowUnminimise",[92,484,485],{"class":193},"(app.ctx)\n",[92,487,489],{"class":94,"line":488},21,[92,490,491],{"class":193},"            },\n",[92,493,495],{"class":94,"line":494},22,[92,496,497],{"class":193},"        },\n",[92,499,501,504,507,510],{"class":94,"line":500},23,[92,502,503],{"class":193},"        HideWindowOnClose: ",[92,505,506],{"class":105},"hideWindowOnClose",[92,508,509],{"class":193},"(),        ",[92,511,512],{"class":98},"\u002F\u002F 关闭=隐藏到托盘（Win\u002FLinux）\n",[92,514,516,519,521,524,526,529],{"class":94,"line":515},24,[92,517,518],{"class":193},"        AssetServer: ",[92,520,346],{"class":189},[92,522,523],{"class":105},"assetserver",[92,525,200],{"class":193},[92,527,528],{"class":105},"Options",[92,530,357],{"class":193},[92,532,534],{"class":94,"line":533},25,[92,535,536],{"class":193},"            Assets:  assets,\n",[92,538,540,543,546,549],{"class":94,"line":539},26,[92,541,542],{"class":193},"            Handler: ",[92,544,545],{"class":105},"localAssetHandler",[92,547,548],{"class":193},"(dataDir),       ",[92,550,551],{"class":98},"\u002F\u002F 本地产物暴露给前端\n",[92,553,555],{"class":94,"line":554},27,[92,556,497],{"class":193},[92,558,560,563,565,567,569,572,575,578,581,584,587,590,593,596,599],{"class":94,"line":559},28,[92,561,562],{"class":193},"        BackgroundColour: ",[92,564,346],{"class":189},[92,566,349],{"class":105},[92,568,200],{"class":193},[92,570,571],{"class":105},"RGBA",[92,573,574],{"class":193},"{R: ",[92,576,577],{"class":125},"14",[92,579,580],{"class":193},", G: ",[92,582,583],{"class":125},"15",[92,585,586],{"class":193},", B: ",[92,588,589],{"class":125},"18",[92,591,592],{"class":193},", A: ",[92,594,595],{"class":125},"1",[92,597,598],{"class":193},"}, ",[92,600,601],{"class":98},"\u002F\u002F 与前端 --bg-base 一致，防启动白闪\n",[92,603,605],{"class":94,"line":604},29,[92,606,607],{"class":193},"        OnStartup: app.startup,\n",[92,609,611],{"class":94,"line":610},30,[92,612,613],{"class":193},"        OnShutdown: app.shutdown,\n",[92,615,617,620,623],{"class":94,"line":616},31,[92,618,619],{"class":193},"        Bind:      []",[92,621,622],{"class":189},"interface",[92,624,625],{"class":193},"{}{app},\n",[92,627,629],{"class":94,"line":628},32,[92,630,631],{"class":193},"    })\n",[92,633,635],{"class":94,"line":634},33,[92,636,637],{"class":193},"}\n",[13,639,640],{},"几个实战细节都是踩过坑之后定下来的：",[642,643,644,665,674],"ul",{},[44,645,646,652,653,656,657,660,661,664],{},[47,647,648,649],{},"数据目录用 ",[74,650,651],{},"os.UserConfigDir()","：Windows 落到 ",[74,654,655],{},"%AppData%","、macOS 落到 ",[74,658,659],{},"~\u002FLibrary\u002FApplication Support","、Linux 落到 ",[74,662,663],{},"~\u002F.config","，一个 API 跨平台，配置\u002F数据库\u002F密钥\u002F产物全放同一目录。千万别往应用安装目录写东西。",[44,666,667,673],{},[47,668,669,672],{},[74,670,671],{},"BackgroundColour"," 要和前端主题底色一致","，否则每次启动白闪一下，廉价感拉满。",[44,675,676,679,680,683,684,687,688,691],{},[47,677,678],{},"无边框窗口必须按平台分叉","：macOS 上 ",[74,681,682],{},"Frameless: true"," 会让 Wails 跳过 ",[74,685,686],{},"NSWindowStyleMaskTitled","，红黄绿交通灯直接消失。正确姿势是 macOS 用 ",[74,689,690],{},"mac.TitleBarHiddenInset()"," 原生方案，Windows\u002FLinux 才自绘标题栏。",[33,693,695],{"id":694},"四go-vue-通信绑定方法-事件两板斧","四、Go ↔ Vue 通信：绑定方法 + 事件，两板斧",[697,698,700],"h3",{"id":699},"_41-前端调-go绑定方法","4.1 前端调 Go：绑定方法",[13,702,703,706],{},[74,704,705],{},"Bind"," 注册的结构体上，所有导出方法自动生成 TS 声明和调用桩，前端 import 即用：",[83,708,710],{"className":176,"code":709,"language":106,"meta":88,"style":88},"\u002F\u002F app.go\nfunc (a *App) SelectFile(title string) (string, error) {\n    return dialog.SelectFile(a.ctx, title)   \u002F\u002F 原生文件对话框\n}\nfunc (a *App) CreateTask(imagePath string, extraPaths []string, detailText string) (uint, error) {\n    return a.svc.CreateTask(imagePath, extraPaths, detailText)  \u002F\u002F 创建生成任务，转发给 service\n}\n",[74,711,712,717,760,776,780,832,847],{"__ignoreMap":88},[92,713,714],{"class":94,"line":95},[92,715,716],{"class":98},"\u002F\u002F app.go\n",[92,718,719,721,724,727,730,732,735,738,740,743,746,749,752,755,758],{"class":94,"line":102},[92,720,218],{"class":189},[92,722,723],{"class":193}," (",[92,725,726],{"class":447},"a ",[92,728,729],{"class":189},"*",[92,731,354],{"class":105},[92,733,734],{"class":193},") ",[92,736,737],{"class":105},"SelectFile",[92,739,343],{"class":193},[92,741,742],{"class":447},"title",[92,744,745],{"class":189}," string",[92,747,748],{"class":193},") (",[92,750,751],{"class":189},"string",[92,753,754],{"class":193},", ",[92,756,757],{"class":189},"error",[92,759,459],{"class":193},[92,761,762,765,768,770,773],{"class":94,"line":116},[92,763,764],{"class":189},"    return",[92,766,767],{"class":193}," dialog.",[92,769,737],{"class":105},[92,771,772],{"class":193},"(a.ctx, title)   ",[92,774,775],{"class":98},"\u002F\u002F 原生文件对话框\n",[92,777,778],{"class":94,"line":215},[92,779,637],{"class":193},[92,781,782,784,786,788,790,792,794,797,799,802,804,806,809,812,814,816,819,821,823,826,828,830],{"class":94,"line":227},[92,783,218],{"class":189},[92,785,723],{"class":193},[92,787,726],{"class":447},[92,789,729],{"class":189},[92,791,354],{"class":105},[92,793,734],{"class":193},[92,795,796],{"class":105},"CreateTask",[92,798,343],{"class":193},[92,800,801],{"class":447},"imagePath",[92,803,745],{"class":189},[92,805,754],{"class":193},[92,807,808],{"class":447},"extraPaths",[92,810,811],{"class":193}," []",[92,813,751],{"class":189},[92,815,754],{"class":193},[92,817,818],{"class":447},"detailText",[92,820,745],{"class":189},[92,822,748],{"class":193},[92,824,825],{"class":189},"uint",[92,827,754],{"class":193},[92,829,757],{"class":189},[92,831,459],{"class":193},[92,833,834,836,839,841,844],{"class":94,"line":248},[92,835,764],{"class":189},[92,837,838],{"class":193}," a.svc.",[92,840,796],{"class":105},[92,842,843],{"class":193},"(imagePath, extraPaths, detailText)  ",[92,845,846],{"class":98},"\u002F\u002F 创建生成任务，转发给 service\n",[92,848,849],{"class":94,"line":268},[92,850,637],{"class":193},[83,852,856],{"className":853,"code":854,"language":855,"meta":88,"style":88},"language-ts shiki shiki-themes github-dark github-light","\u002F\u002F 前端（wailsjs 自动生成的绑定）\nimport { CreateTask, SelectFile } from '..\u002Fwailsjs\u002Fgo\u002Fmain\u002FApp'\n\nconst path = await SelectFile('选择商品图')\nconst taskID = await CreateTask(path, [], '')\n","ts",[74,857,858,863,877,881,906],{"__ignoreMap":88},[92,859,860],{"class":94,"line":95},[92,861,862],{"class":98},"\u002F\u002F 前端（wailsjs 自动生成的绑定）\n",[92,864,865,868,871,874],{"class":94,"line":102},[92,866,867],{"class":189},"import",[92,869,870],{"class":193}," { CreateTask, SelectFile } ",[92,872,873],{"class":189},"from",[92,875,876],{"class":109}," '..\u002Fwailsjs\u002Fgo\u002Fmain\u002FApp'\n",[92,878,879],{"class":94,"line":116},[92,880,212],{"emptyLinePlaceholder":211},[92,882,883,886,889,892,895,898,900,903],{"class":94,"line":215},[92,884,885],{"class":189},"const",[92,887,888],{"class":125}," path",[92,890,891],{"class":189}," =",[92,893,894],{"class":189}," await",[92,896,897],{"class":105}," SelectFile",[92,899,343],{"class":193},[92,901,902],{"class":109},"'选择商品图'",[92,904,905],{"class":193},")\n",[92,907,908,910,913,915,917,920,923,926],{"class":94,"line":227},[92,909,885],{"class":189},[92,911,912],{"class":125}," taskID",[92,914,891],{"class":189},[92,916,894],{"class":189},[92,918,919],{"class":105}," CreateTask",[92,921,922],{"class":193},"(path, [], ",[92,924,925],{"class":109},"''",[92,927,905],{"class":193},[13,929,930,931,934],{},"结构体参数、返回值会自动 JSON 序列化，等于白送一个类型安全的 RPC。两个坑：绑定方法",[47,932,933],{},"不要在 UI 线程做长阻塞","（调用是异步 Promise，但耗时的活儿要进 goroutine 后靠事件推进度）；返回值里的零值字段注意前端判空。",[697,936,938],{"id":937},"_42-go-推前端事件总线","4.2 Go 推前端：事件总线",[13,940,941,942,945,946,949],{},"这个项目的核心体验是\"流水线实时时间线\"，靠的是事件推送而不是前端轮询。接线方式：",[74,943,944],{},"OnStartup"," 时把 ",[74,947,948],{},"runtime.EventsEmit"," 注入 service，业务层持有一个抽象的 emitter：",[83,951,953],{"className":176,"code":952,"language":106,"meta":88,"style":88},"\u002F\u002F app.go startup\na.svc.SetEmitter(func(name string, payload interface{}) {\n    runtime.EventsEmit(ctx, name, payload)\n    \u002F\u002F 任务完成且窗口最小化时自动弹回\n    if evt, ok := payload.(service.TaskEvent); ok && evt.Step == \"task\" && evt.Status == \"done\" {\n        if runtime.WindowIsMinimised(ctx) {\n            runtime.WindowUnminimise(ctx)\n            runtime.Show(ctx)\n        }\n    }\n})\n",[74,954,955,960,990,1001,1006,1056,1070,1080,1088,1093,1098],{"__ignoreMap":88},[92,956,957],{"class":94,"line":95},[92,958,959],{"class":98},"\u002F\u002F app.go startup\n",[92,961,962,965,968,970,972,974,977,979,981,984,987],{"class":94,"line":102},[92,963,964],{"class":193},"a.svc.",[92,966,967],{"class":105},"SetEmitter",[92,969,343],{"class":193},[92,971,218],{"class":189},[92,973,343],{"class":193},[92,975,976],{"class":447},"name",[92,978,745],{"class":189},[92,980,754],{"class":193},[92,982,983],{"class":447},"payload",[92,985,986],{"class":189}," interface",[92,988,989],{"class":193},"{}) {\n",[92,991,992,995,998],{"class":94,"line":116},[92,993,994],{"class":193},"    runtime.",[92,996,997],{"class":105},"EventsEmit",[92,999,1000],{"class":193},"(ctx, name, payload)\n",[92,1002,1003],{"class":94,"line":215},[92,1004,1005],{"class":98},"    \u002F\u002F 任务完成且窗口最小化时自动弹回\n",[92,1007,1008,1011,1014,1016,1019,1022,1024,1027,1030,1033,1036,1039,1042,1045,1048,1050,1053],{"class":94,"line":227},[92,1009,1010],{"class":189},"    if",[92,1012,1013],{"class":193}," evt, ok ",[92,1015,233],{"class":189},[92,1017,1018],{"class":193}," payload.(",[92,1020,1021],{"class":105},"service",[92,1023,200],{"class":193},[92,1025,1026],{"class":105},"TaskEvent",[92,1028,1029],{"class":193},"); ok ",[92,1031,1032],{"class":189},"&&",[92,1034,1035],{"class":193}," evt.Step ",[92,1037,1038],{"class":189},"==",[92,1040,1041],{"class":109}," \"task\"",[92,1043,1044],{"class":189}," &&",[92,1046,1047],{"class":193}," evt.Status ",[92,1049,1038],{"class":189},[92,1051,1052],{"class":109}," \"done\"",[92,1054,1055],{"class":193}," {\n",[92,1057,1058,1061,1064,1067],{"class":94,"line":248},[92,1059,1060],{"class":189},"        if",[92,1062,1063],{"class":193}," runtime.",[92,1065,1066],{"class":105},"WindowIsMinimised",[92,1068,1069],{"class":193},"(ctx) {\n",[92,1071,1072,1075,1077],{"class":94,"line":268},[92,1073,1074],{"class":193},"            runtime.",[92,1076,482],{"class":105},[92,1078,1079],{"class":193},"(ctx)\n",[92,1081,1082,1084,1086],{"class":94,"line":284},[92,1083,1074],{"class":193},[92,1085,468],{"class":105},[92,1087,1079],{"class":193},[92,1089,1090],{"class":94,"line":304},[92,1091,1092],{"class":193},"        }\n",[92,1094,1095],{"class":94,"line":323},[92,1096,1097],{"class":193},"    }\n",[92,1099,1100],{"class":94,"line":328},[92,1101,1102],{"class":193},"})\n",[13,1104,1105,1106,1109],{},"service 层完全不知道 Wails 的存在（只拿了一个 ",[74,1107,1108],{},"func(string, interface{})","），单测里注入假 emitter 即可。前端用 runtime 监听：",[83,1111,1113],{"className":853,"code":1112,"language":855,"meta":88,"style":88},"import { EventsOn } from '..\u002F..\u002Fwailsjs\u002Fruntime'\n\nEventsOn('task:progress', (e: TaskEvent) => {\n  timeline.push(e)   \u002F\u002F \"感知完成：便携榨汁机\" \u002F \"第2张触发限流，20s 后重试\"\n})\n",[74,1114,1115,1127,1131,1160,1174],{"__ignoreMap":88},[92,1116,1117,1119,1122,1124],{"class":94,"line":95},[92,1118,867],{"class":189},[92,1120,1121],{"class":193}," { EventsOn } ",[92,1123,873],{"class":189},[92,1125,1126],{"class":109}," '..\u002F..\u002Fwailsjs\u002Fruntime'\n",[92,1128,1129],{"class":94,"line":102},[92,1130,212],{"emptyLinePlaceholder":211},[92,1132,1133,1136,1138,1141,1144,1147,1150,1153,1155,1158],{"class":94,"line":116},[92,1134,1135],{"class":105},"EventsOn",[92,1137,343],{"class":193},[92,1139,1140],{"class":109},"'task:progress'",[92,1142,1143],{"class":193},", (",[92,1145,1146],{"class":447},"e",[92,1148,1149],{"class":189},":",[92,1151,1152],{"class":105}," TaskEvent",[92,1154,734],{"class":193},[92,1156,1157],{"class":189},"=>",[92,1159,1055],{"class":193},[92,1161,1162,1165,1168,1171],{"class":94,"line":215},[92,1163,1164],{"class":193},"  timeline.",[92,1166,1167],{"class":105},"push",[92,1169,1170],{"class":193},"(e)   ",[92,1172,1173],{"class":98},"\u002F\u002F \"感知完成：便携榨汁机\" \u002F \"第2张触发限流，20s 后重试\"\n",[92,1175,1176],{"class":94,"line":227},[92,1177,1102],{"class":193},[13,1179,1180],{},"最终效果就是这张实拍：左列是感知与规划的结构化结果，右列是随事件一张张\"长出来\"的流式图片墙，顶部步骤条（抠图→感知→规划→渲染→文案→排版）逐项点亮：",[13,1182,1183],{},[1184,1185],"img",{"alt":1186,"src":1187},"生成页实况：事件驱动的实时时间线与流式图片墙","https:\u002F\u002Fname.os.bennywen.xyz\u002Fbennywen.xyz\u002Fassets\u002Fecom-detail-ai-part2-wails\u002Fsop-process.png?imageMogr2\u002Fthumbnail\u002F1440x\u002Fformat\u002Fwebp\u002Fquality\u002F88",[13,1189,1190],{},[1191,1192,1193],"em",{},"生成页实况：事件驱动的实时时间线与流式图片墙。",[13,1195,1196,1199],{},[47,1197,1198],{},"给后端框架注入回调而不是让它 import 框架包","，这个小动作让整个流水线层可以脱离桌面环境跑单测，强烈推荐。",[697,1201,1203],{"id":1202},"_43-本地产物怎么显示assetserver-handler","4.3 本地产物怎么显示：AssetServer Handler",[13,1205,1206,1207,1210,1211,1214,1215,1218,1219,1222],{},"生成的详情页长图存在数据目录里，",[74,1208,1209],{},"\u003Cimg>"," 没法直接引 ",[74,1212,1213],{},"file:\u002F\u002F","。Wails 的 AssetServer 允许挂一个兜底 ",[74,1216,1217],{},"http.Handler","，本项目用它把数据目录以 ",[74,1220,1221],{},"\u002Flocal\u002F"," 前缀暴露：",[83,1224,1226],{"className":176,"code":1225,"language":106,"meta":88,"style":88},"func localAssetHandler(dataDir string) http.Handler {\n    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n        const prefix = \"\u002Flocal\u002F\"\n        if !strings.HasPrefix(r.URL.Path, prefix) { http.NotFound(w, r); return }\n        rel, err := url.PathUnescape(strings.TrimPrefix(r.URL.Path, prefix))\n        if err != nil { http.NotFound(w, r); return }\n        full := filepath.Clean(filepath.Join(dataDir, rel))\n        if !strings.HasPrefix(full, dataDir+string(os.PathSeparator)) {\n            http.NotFound(w, r)   \u002F\u002F 防目录穿越：清洗后必须仍在数据目录内\n        }\n        http.ServeFile(w, r, full)\n    })\n}\n",[74,1227,1228,1254,1298,1311,1339,1361,1385,1407,1428,1441,1445,1456,1460],{"__ignoreMap":88},[92,1229,1230,1232,1235,1237,1240,1242,1244,1247,1249,1252],{"class":94,"line":95},[92,1231,218],{"class":189},[92,1233,1234],{"class":105}," localAssetHandler",[92,1236,343],{"class":193},[92,1238,1239],{"class":447},"dataDir",[92,1241,745],{"class":189},[92,1243,734],{"class":193},[92,1245,1246],{"class":105},"http",[92,1248,200],{"class":193},[92,1250,1251],{"class":105},"Handler",[92,1253,1055],{"class":193},[92,1255,1256,1258,1261,1264,1266,1268,1270,1273,1276,1278,1281,1283,1286,1289,1291,1293,1296],{"class":94,"line":102},[92,1257,764],{"class":189},[92,1259,1260],{"class":193}," http.",[92,1262,1263],{"class":105},"HandlerFunc",[92,1265,343],{"class":193},[92,1267,218],{"class":189},[92,1269,343],{"class":193},[92,1271,1272],{"class":447},"w",[92,1274,1275],{"class":105}," http",[92,1277,200],{"class":193},[92,1279,1280],{"class":105},"ResponseWriter",[92,1282,754],{"class":193},[92,1284,1285],{"class":447},"r",[92,1287,1288],{"class":189}," *",[92,1290,1246],{"class":105},[92,1292,200],{"class":193},[92,1294,1295],{"class":105},"Request",[92,1297,459],{"class":193},[92,1299,1300,1303,1306,1308],{"class":94,"line":116},[92,1301,1302],{"class":189},"        const",[92,1304,1305],{"class":125}," prefix",[92,1307,891],{"class":189},[92,1309,1310],{"class":109}," \"\u002Flocal\u002F\"\n",[92,1312,1313,1315,1318,1321,1324,1327,1330,1333,1336],{"class":94,"line":215},[92,1314,1060],{"class":189},[92,1316,1317],{"class":189}," !",[92,1319,1320],{"class":193},"strings.",[92,1322,1323],{"class":105},"HasPrefix",[92,1325,1326],{"class":193},"(r.URL.Path, prefix) { http.",[92,1328,1329],{"class":105},"NotFound",[92,1331,1332],{"class":193},"(w, r); ",[92,1334,1335],{"class":189},"return",[92,1337,1338],{"class":193}," }\n",[92,1340,1341,1344,1346,1349,1352,1355,1358],{"class":94,"line":227},[92,1342,1343],{"class":193},"        rel, err ",[92,1345,233],{"class":189},[92,1347,1348],{"class":193}," url.",[92,1350,1351],{"class":105},"PathUnescape",[92,1353,1354],{"class":193},"(strings.",[92,1356,1357],{"class":105},"TrimPrefix",[92,1359,1360],{"class":193},"(r.URL.Path, prefix))\n",[92,1362,1363,1365,1368,1371,1374,1377,1379,1381,1383],{"class":94,"line":248},[92,1364,1060],{"class":189},[92,1366,1367],{"class":193}," err ",[92,1369,1370],{"class":189},"!=",[92,1372,1373],{"class":125}," nil",[92,1375,1376],{"class":193}," { http.",[92,1378,1329],{"class":105},[92,1380,1332],{"class":193},[92,1382,1335],{"class":189},[92,1384,1338],{"class":193},[92,1386,1387,1390,1392,1395,1398,1401,1404],{"class":94,"line":268},[92,1388,1389],{"class":193},"        full ",[92,1391,233],{"class":189},[92,1393,1394],{"class":193}," filepath.",[92,1396,1397],{"class":105},"Clean",[92,1399,1400],{"class":193},"(filepath.",[92,1402,1403],{"class":105},"Join",[92,1405,1406],{"class":193},"(dataDir, rel))\n",[92,1408,1409,1411,1413,1415,1417,1420,1423,1425],{"class":94,"line":284},[92,1410,1060],{"class":189},[92,1412,1317],{"class":189},[92,1414,1320],{"class":193},[92,1416,1323],{"class":105},[92,1418,1419],{"class":193},"(full, dataDir",[92,1421,1422],{"class":189},"+",[92,1424,751],{"class":189},[92,1426,1427],{"class":193},"(os.PathSeparator)) {\n",[92,1429,1430,1433,1435,1438],{"class":94,"line":304},[92,1431,1432],{"class":193},"            http.",[92,1434,1329],{"class":105},[92,1436,1437],{"class":193},"(w, r)   ",[92,1439,1440],{"class":98},"\u002F\u002F 防目录穿越：清洗后必须仍在数据目录内\n",[92,1442,1443],{"class":94,"line":323},[92,1444,1092],{"class":193},[92,1446,1447,1450,1453],{"class":94,"line":328},[92,1448,1449],{"class":193},"        http.",[92,1451,1452],{"class":105},"ServeFile",[92,1454,1455],{"class":193},"(w, r, full)\n",[92,1457,1458],{"class":94,"line":360},[92,1459,631],{"class":193},[92,1461,1462],{"class":94,"line":372},[92,1463,637],{"class":193},[13,1465,1466,1467,1470,1471,1474,1475,1478,1479,1481],{},"前端 ",[74,1468,1469],{},"\u003Cimg :src=\"'\u002Flocal\u002Fassets\u002Ftask-1\u002Flongpage.png'\">"," 就能看图。",[47,1472,1473],{},"注意绝对路径也要校验前缀","，",[74,1476,1477],{},"..%2f"," 这类穿越必须拦。这个 handler 的另一个价值是让\"预览本机文件\"这件事零成本：不需要把产物复制到临时目录、不需要起独立 HTTP 服务、也不依赖任何自定义协议注册，一个标准 ",[74,1480,1217],{}," 解决。",[697,1483,1485],{"id":1484},"_44-前端侧三个页面-三个-store-就够","4.4 前端侧：三个页面 + 三个 store 就够",[13,1487,1488,1489,1492,1493,1496,1497,1500],{},"前端刻意保持薄。",[74,1490,1491],{},"views\u002F"," 下就四个页面：工作台（Home，选图发起任务）、生成页（实时时间线 + 流式图片墙 + 长图预览）、历史页、设置页（密钥与模型位）。",[74,1494,1495],{},"stores\u002F"," 下三个 Pinia store：主题、设置、任务。事件监听集中在任务 store 里做单一出口，页面只订阅状态，避免同一个 ",[74,1498,1499],{},"task:progress"," 被多个组件各自监听造成状态分叉。",[13,1502,1503,1504,1507,1508,1511,1512,1514,1515,1518],{},"样式上有一条机器强制的规矩：",[47,1505,1506],{},"渲染层禁止硬编码 hex 色值","，全部引用 ",[74,1509,1510],{},"styles\u002Ftokens.css"," 里的设计变量，ESLint 规则兜底。这条规矩的由来是明暗主题切换：项目初版有三十多处散落的颜色，加暗色主题改了一整周还漏；后来所有颜色收敛进 tokens（背景、文本、边框、品牌色各一组语义变量），换肤变成了改一个文件的事。同理，",[74,1513,671],{}," 那个启动背景色也必须和 ",[74,1516,1517],{},"--bg-base"," token 保持一致——两边是同一个决策的两个出口。",[33,1520,1522],{"id":1521},"五接-ai一个兼容网关吃三家","五、接 AI：一个兼容网关吃三家",[13,1524,1525,1526,1529],{},"后端对接 LLM 这件事，被压缩到了极致。智谱、DeepSeek、火山方舟全都兼容 OpenAI 的 chat\u002Fcompletions 协议，所以网关只实现一种协议，",[47,1527,1528],{},"换供应商 = 换 base_url + api_key + model 三个参数","：",[83,1531,1533],{"className":176,"code":1532,"language":106,"meta":88,"style":88},"type Gateway struct{ client *http.Client }\n\nfunc (g *Gateway) Chat(ctx context.Context, baseURL, apiKey, model string,\n    messages []Message, opts ...ChatOption) (string, Usage, error) {\n\n    body, _ := json.Marshal(chatRequest{Model: model, Messages: messages, Stream: false})\n    url := strings.TrimRight(baseURL, \"\u002F\") + \"\u002Fchat\u002Fcompletions\"\n    req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))\n    req.Header.Set(\"Authorization\", \"Bearer \"+apiKey)\n    \u002F\u002F ... 解析 choices[0].message.content + usage（token 用量）\n}\n",[74,1534,1535,1560,1564,1615,1651,1655,1681,1707,1728,1751,1756],{"__ignoreMap":88},[92,1536,1537,1540,1543,1546,1549,1551,1553,1555,1558],{"class":94,"line":95},[92,1538,1539],{"class":189},"type",[92,1541,1542],{"class":105}," Gateway",[92,1544,1545],{"class":189}," struct",[92,1547,1548],{"class":193},"{ client ",[92,1550,729],{"class":189},[92,1552,1246],{"class":105},[92,1554,200],{"class":193},[92,1556,1557],{"class":105},"Client",[92,1559,1338],{"class":193},[92,1561,1562],{"class":94,"line":102},[92,1563,212],{"emptyLinePlaceholder":211},[92,1565,1566,1568,1570,1573,1575,1578,1580,1583,1585,1588,1591,1593,1596,1598,1601,1603,1606,1608,1611,1613],{"class":94,"line":116},[92,1567,218],{"class":189},[92,1569,723],{"class":193},[92,1571,1572],{"class":447},"g ",[92,1574,729],{"class":189},[92,1576,1577],{"class":105},"Gateway",[92,1579,734],{"class":193},[92,1581,1582],{"class":105},"Chat",[92,1584,343],{"class":193},[92,1586,1587],{"class":447},"ctx",[92,1589,1590],{"class":105}," context",[92,1592,200],{"class":193},[92,1594,1595],{"class":105},"Context",[92,1597,754],{"class":193},[92,1599,1600],{"class":447},"baseURL",[92,1602,754],{"class":193},[92,1604,1605],{"class":447},"apiKey",[92,1607,754],{"class":193},[92,1609,1610],{"class":447},"model",[92,1612,745],{"class":189},[92,1614,369],{"class":193},[92,1616,1617,1620,1622,1625,1627,1630,1633,1636,1638,1640,1642,1645,1647,1649],{"class":94,"line":215},[92,1618,1619],{"class":447},"    messages",[92,1621,811],{"class":193},[92,1623,1624],{"class":105},"Message",[92,1626,754],{"class":193},[92,1628,1629],{"class":447},"opts",[92,1631,1632],{"class":189}," ...",[92,1634,1635],{"class":105},"ChatOption",[92,1637,748],{"class":193},[92,1639,751],{"class":189},[92,1641,754],{"class":193},[92,1643,1644],{"class":105},"Usage",[92,1646,754],{"class":193},[92,1648,757],{"class":189},[92,1650,459],{"class":193},[92,1652,1653],{"class":94,"line":227},[92,1654,212],{"emptyLinePlaceholder":211},[92,1656,1657,1660,1662,1665,1668,1670,1673,1676,1679],{"class":94,"line":248},[92,1658,1659],{"class":193},"    body, _ ",[92,1661,233],{"class":189},[92,1663,1664],{"class":193}," json.",[92,1666,1667],{"class":105},"Marshal",[92,1669,343],{"class":193},[92,1671,1672],{"class":105},"chatRequest",[92,1674,1675],{"class":193},"{Model: model, Messages: messages, Stream: ",[92,1677,1678],{"class":125},"false",[92,1680,1102],{"class":193},[92,1682,1683,1686,1688,1691,1694,1697,1700,1702,1704],{"class":94,"line":268},[92,1684,1685],{"class":193},"    url ",[92,1687,233],{"class":189},[92,1689,1690],{"class":193}," strings.",[92,1692,1693],{"class":105},"TrimRight",[92,1695,1696],{"class":193},"(baseURL, ",[92,1698,1699],{"class":109},"\"\u002F\"",[92,1701,734],{"class":193},[92,1703,1422],{"class":189},[92,1705,1706],{"class":109}," \"\u002Fchat\u002Fcompletions\"\n",[92,1708,1709,1712,1714,1716,1719,1722,1725],{"class":94,"line":284},[92,1710,1711],{"class":193},"    req, err ",[92,1713,233],{"class":189},[92,1715,1260],{"class":193},[92,1717,1718],{"class":105},"NewRequestWithContext",[92,1720,1721],{"class":193},"(ctx, http.MethodPost, url, bytes.",[92,1723,1724],{"class":105},"NewReader",[92,1726,1727],{"class":193},"(body))\n",[92,1729,1730,1733,1736,1738,1741,1743,1746,1748],{"class":94,"line":304},[92,1731,1732],{"class":193},"    req.Header.",[92,1734,1735],{"class":105},"Set",[92,1737,343],{"class":193},[92,1739,1740],{"class":109},"\"Authorization\"",[92,1742,754],{"class":193},[92,1744,1745],{"class":109},"\"Bearer \"",[92,1747,1422],{"class":189},[92,1749,1750],{"class":193},"apiKey)\n",[92,1752,1753],{"class":94,"line":323},[92,1754,1755],{"class":98},"    \u002F\u002F ... 解析 choices[0].message.content + usage（token 用量）\n",[92,1757,1758],{"class":94,"line":328},[92,1759,637],{"class":193},[13,1761,1762],{},"两个容易忽略的点：",[642,1764,1765,1786],{},[44,1766,1767,1529,1770,1773,1774,1777,1778,1781,1782,1785],{},[47,1768,1769],{},"多模态入参",[74,1771,1772],{},"Message.Content"," 是 ",[74,1775,1776],{},"interface{}","，纯文本给 string，图文混排给 ",[74,1779,1780],{},"[]ContentPart","（",[74,1783,1784],{},"{\"type\":\"image_url\",\"image_url\":{\"url\":\"data:image\u002Fpng;base64,...\"}}","）。本地图片直接转 base64 data URL 传给视觉模型，无需对象存储。",[44,1787,1788,1791],{},[47,1789,1790],{},"超时分级","：对话客户端 60s，生图客户端单独 120s——图生图接口一次 10–60s 是常态，用同一个 client 会互相拖累。",[13,1793,1794,1795,1798],{},"图像生成同样走 OpenAI 风格 ",[74,1796,1797],{},"\u002Fimages\u002Fgenerations","，但要正视现实：各家图生图的参考图参数形状不一致。项目的做法是把差异隔离在一个文件：",[83,1800,1802],{"className":176,"code":1801,"language":106,"meta":88,"style":88},"\u002F\u002F images.go 头部注释（节选）\n\u002F\u002F ⚠️ 联调标注：各平台图生图参数形状不一致——智谱 GLM-Image、Seedream 的\n\u002F\u002F 参考图传法各异，联调时按实测修正，只允许改本文件，调用方不受影响。\n",[74,1803,1804,1809,1814],{"__ignoreMap":88},[92,1805,1806],{"class":94,"line":95},[92,1807,1808],{"class":98},"\u002F\u002F images.go 头部注释（节选）\n",[92,1810,1811],{"class":94,"line":102},[92,1812,1813],{"class":98},"\u002F\u002F ⚠️ 联调标注：各平台图生图参数形状不一致——智谱 GLM-Image、Seedream 的\n",[92,1815,1816],{"class":94,"line":116},[92,1817,1818],{"class":98},"\u002F\u002F 参考图传法各异，联调时按实测修正，只允许改本文件，调用方不受影响。\n",[33,1820,1822],{"id":1821},"六任务持久化sqlite-当唯一真相","六、任务持久化：SQLite 当唯一真相",[13,1824,1825,1826,1529],{},"桌面 AI 应用几乎都绕不开\"任务队列\"：一次生成跑十分钟，用户关窗口、程序崩了，任务状态去哪了？本项目的回答是",[47,1827,1828],{},"以数据库为唯一真相的调度器",[83,1830,1832],{"className":176,"code":1831,"language":106,"meta":88,"style":88},"\u002F\u002F 启动恢复（app.startup 里调用）\nfunc (s *Service) RecoverAndStart() {\n    \u002F\u002F 1. 上次退出时被中断的任务：running\u002Fplanned 置 failed，给用户人话提示\n    s.db.Model(&model.GenerationTask{}).\n        Where(\"status IN ?\", []string{\"running\", \"planned\"}).\n        Updates(map[string]interface{}{\"status\": \"failed\",\n            \"error_msg\": \"应用退出中断，请重新发起\", \"ended_at\": nowMillis()})\n    \u002F\u002F 2. 全部排队中(pending)任务按 id asc 重新入队\n    var ids []uint\n    s.db.Model(&model.GenerationTask{}).\n        Where(\"status = ?\", \"pending\").Order(\"id asc\").Pluck(\"id\", &ids)\n    for _, id := range ids {\n        s.enqueue(id)\n    }\n    \u002F\u002F 3. 起 dispatcher goroutine\n    s.StartDispatcher()\n}\n",[74,1833,1834,1839,1860,1865,1887,1916,1950,1973,1978,1989,2007,2049,2065,2076,2080,2085,2096],{"__ignoreMap":88},[92,1835,1836],{"class":94,"line":95},[92,1837,1838],{"class":98},"\u002F\u002F 启动恢复（app.startup 里调用）\n",[92,1840,1841,1843,1845,1848,1850,1853,1855,1858],{"class":94,"line":102},[92,1842,218],{"class":189},[92,1844,723],{"class":193},[92,1846,1847],{"class":447},"s ",[92,1849,729],{"class":189},[92,1851,1852],{"class":105},"Service",[92,1854,734],{"class":193},[92,1856,1857],{"class":105},"RecoverAndStart",[92,1859,224],{"class":193},[92,1861,1862],{"class":94,"line":116},[92,1863,1864],{"class":98},"    \u002F\u002F 1. 上次退出时被中断的任务：running\u002Fplanned 置 failed，给用户人话提示\n",[92,1866,1867,1870,1873,1875,1877,1879,1881,1884],{"class":94,"line":215},[92,1868,1869],{"class":193},"    s.db.",[92,1871,1872],{"class":105},"Model",[92,1874,343],{"class":193},[92,1876,346],{"class":189},[92,1878,1610],{"class":105},[92,1880,200],{"class":193},[92,1882,1883],{"class":105},"GenerationTask",[92,1885,1886],{"class":193},"{}).\n",[92,1888,1889,1892,1894,1897,1900,1902,1905,1908,1910,1913],{"class":94,"line":227},[92,1890,1891],{"class":105},"        Where",[92,1893,343],{"class":193},[92,1895,1896],{"class":109},"\"status IN ?\"",[92,1898,1899],{"class":193},", []",[92,1901,751],{"class":189},[92,1903,1904],{"class":193},"{",[92,1906,1907],{"class":109},"\"running\"",[92,1909,754],{"class":193},[92,1911,1912],{"class":109},"\"planned\"",[92,1914,1915],{"class":193},"}).\n",[92,1917,1918,1921,1923,1926,1929,1931,1934,1936,1939,1942,1945,1948],{"class":94,"line":248},[92,1919,1920],{"class":105},"        Updates",[92,1922,343],{"class":193},[92,1924,1925],{"class":189},"map",[92,1927,1928],{"class":193},"[",[92,1930,751],{"class":189},[92,1932,1933],{"class":193},"]",[92,1935,622],{"class":189},[92,1937,1938],{"class":193},"{}{",[92,1940,1941],{"class":109},"\"status\"",[92,1943,1944],{"class":193},": ",[92,1946,1947],{"class":109},"\"failed\"",[92,1949,369],{"class":193},[92,1951,1952,1955,1957,1960,1962,1965,1967,1970],{"class":94,"line":268},[92,1953,1954],{"class":109},"            \"error_msg\"",[92,1956,1944],{"class":193},[92,1958,1959],{"class":109},"\"应用退出中断，请重新发起\"",[92,1961,754],{"class":193},[92,1963,1964],{"class":109},"\"ended_at\"",[92,1966,1944],{"class":193},[92,1968,1969],{"class":105},"nowMillis",[92,1971,1972],{"class":193},"()})\n",[92,1974,1975],{"class":94,"line":284},[92,1976,1977],{"class":98},"    \u002F\u002F 2. 全部排队中(pending)任务按 id asc 重新入队\n",[92,1979,1980,1983,1986],{"class":94,"line":304},[92,1981,1982],{"class":189},"    var",[92,1984,1985],{"class":193}," ids []",[92,1987,1988],{"class":189},"uint\n",[92,1990,1991,1993,1995,1997,1999,2001,2003,2005],{"class":94,"line":323},[92,1992,1869],{"class":193},[92,1994,1872],{"class":105},[92,1996,343],{"class":193},[92,1998,346],{"class":189},[92,2000,1610],{"class":105},[92,2002,200],{"class":193},[92,2004,1883],{"class":105},[92,2006,1886],{"class":193},[92,2008,2009,2011,2013,2016,2018,2021,2024,2027,2029,2032,2034,2037,2039,2042,2044,2046],{"class":94,"line":328},[92,2010,1891],{"class":105},[92,2012,343],{"class":193},[92,2014,2015],{"class":109},"\"status = ?\"",[92,2017,754],{"class":193},[92,2019,2020],{"class":109},"\"pending\"",[92,2022,2023],{"class":193},").",[92,2025,2026],{"class":105},"Order",[92,2028,343],{"class":193},[92,2030,2031],{"class":109},"\"id asc\"",[92,2033,2023],{"class":193},[92,2035,2036],{"class":105},"Pluck",[92,2038,343],{"class":193},[92,2040,2041],{"class":109},"\"id\"",[92,2043,754],{"class":193},[92,2045,346],{"class":189},[92,2047,2048],{"class":193},"ids)\n",[92,2050,2051,2054,2057,2059,2062],{"class":94,"line":360},[92,2052,2053],{"class":189},"    for",[92,2055,2056],{"class":193}," _, id ",[92,2058,233],{"class":189},[92,2060,2061],{"class":189}," range",[92,2063,2064],{"class":193}," ids {\n",[92,2066,2067,2070,2073],{"class":94,"line":372},[92,2068,2069],{"class":193},"        s.",[92,2071,2072],{"class":105},"enqueue",[92,2074,2075],{"class":193},"(id)\n",[92,2077,2078],{"class":94,"line":383},[92,2079,1097],{"class":193},[92,2081,2082],{"class":94,"line":394},[92,2083,2084],{"class":98},"    \u002F\u002F 3. 起 dispatcher goroutine\n",[92,2086,2087,2090,2093],{"class":94,"line":409},[92,2088,2089],{"class":193},"    s.",[92,2091,2092],{"class":105},"StartDispatcher",[92,2094,2095],{"class":193},"()\n",[92,2097,2098],{"class":94,"line":426},[92,2099,637],{"class":193},[13,2101,2102,2103,2106],{},"dispatcher 是单 goroutine 常驻 + channel 唤醒，出队时对每个任务 ID 做",[47,2104,2105],{},"四重校验","（任务仍存在 \u002F 状态仍 pending \u002F 批次存在 \u002F 批次未暂停）——内存队列只是缓存，与 DB 的任何不一致都会在出队时自愈。这个设计让\"重启恢复可靠\"不再是特性，而是顺理成章的结果。并发上限 1–3 实时读配置热生效；改上限只影响后续出队，不打断运行中任务。",[13,2108,2109,2110,2113],{},"数据库用 GORM + ",[47,2111,2112],{},"glebarez\u002Fsqlite（纯 Go 驱动）","，没有 CGO，交叉编译 Windows 产物时不折腾 C 工具链——桌面应用场景下这一点值千金。",[13,2115,2116,2117,2120,2121,2124],{},"表结构上有一对值得说的配角：",[74,2118,2119],{},"TaskStep","（任务每个子步骤的状态与错误信息）和 ",[74,2122,2123],{},"GeneratedAsset","（每张产物图的路径、kind、序号、版本号）。有了\"步骤流水账\"，任何时刻重启后都能回答\"这个任务跑到哪一步了\"；有了\"资产表 + 版本号\"，单张图重新生成不覆盖旧版，用户在结果页可以横向对比来回切版本，导出时取最新版本即可。这两张表让\"断点续查、版本化产物、成本审计\"三件事共享同一份存储，不需要额外的状态文件。",[33,2126,2128],{"id":2127},"七byok-密钥aes-256-gcm-的轻量方案","七、BYOK 密钥：AES-256-GCM 的轻量方案",[13,2130,2131],{},"用户要填四家服务商的 API 密钥，明文绝不能落盘。桌面端的 OS 钥匙串各有平台 API，项目用了一个轻量的加密文件方案（约 200 行）：",[642,2133,2134,2141,2148,2155],{},[44,2135,2136,2137,2140],{},"首次使用时生成 32 字节随机主密钥，存 ",[74,2138,2139],{},"keystore.key","（权限 0600）；",[44,2142,2143,2144,2147],{},"密钥明文以 AES-256-GCM 加密（nonce 前置拼进密文），base64 后写 ",[74,2145,2146],{},"secrets.json","（0600）；",[44,2149,2150,2151,2154],{},"对外只暴露配置状态和",[47,2152,2153],{},"尾号 4 位","供 UI 比对，不提供明文枚举接口；",[44,2156,2157,2158,2161],{},"目录权限 0700，全程 ",[74,2159,2160],{},"sync.Mutex"," 串行化（绑定方法可能被前端并发调用）。",[83,2163,2165],{"className":176,"code":2164,"language":106,"meta":88,"style":88},"nonce := make([]byte, aead.NonceSize())\nrand.Read(nonce)\nct := aead.Seal(nonce, nonce, []byte(plaintext), nil)  \u002F\u002F nonce+密文一起存\nb[provider] = entry{\n    CT:   base64.StdEncoding.EncodeToString(ct),\n    Tail: keyTail(plaintext),                          \u002F\u002F 只存展示用尾号\n}\n",[74,2166,2167,2192,2203,2233,2245,2256,2270],{"__ignoreMap":88},[92,2168,2169,2172,2174,2177,2180,2183,2186,2189],{"class":94,"line":95},[92,2170,2171],{"class":193},"nonce ",[92,2173,233],{"class":189},[92,2175,2176],{"class":105}," make",[92,2178,2179],{"class":193},"([]",[92,2181,2182],{"class":189},"byte",[92,2184,2185],{"class":193},", aead.",[92,2187,2188],{"class":105},"NonceSize",[92,2190,2191],{"class":193},"())\n",[92,2193,2194,2197,2200],{"class":94,"line":102},[92,2195,2196],{"class":193},"rand.",[92,2198,2199],{"class":105},"Read",[92,2201,2202],{"class":193},"(nonce)\n",[92,2204,2205,2208,2210,2213,2216,2219,2221,2224,2227,2230],{"class":94,"line":116},[92,2206,2207],{"class":193},"ct ",[92,2209,233],{"class":189},[92,2211,2212],{"class":193}," aead.",[92,2214,2215],{"class":105},"Seal",[92,2217,2218],{"class":193},"(nonce, nonce, []",[92,2220,2182],{"class":189},[92,2222,2223],{"class":193},"(plaintext), ",[92,2225,2226],{"class":125},"nil",[92,2228,2229],{"class":193},")  ",[92,2231,2232],{"class":98},"\u002F\u002F nonce+密文一起存\n",[92,2234,2235,2238,2240,2243],{"class":94,"line":215},[92,2236,2237],{"class":193},"b[provider] ",[92,2239,334],{"class":189},[92,2241,2242],{"class":105}," entry",[92,2244,357],{"class":193},[92,2246,2247,2250,2253],{"class":94,"line":227},[92,2248,2249],{"class":193},"    CT:   base64.StdEncoding.",[92,2251,2252],{"class":105},"EncodeToString",[92,2254,2255],{"class":193},"(ct),\n",[92,2257,2258,2261,2264,2267],{"class":94,"line":248},[92,2259,2260],{"class":193},"    Tail: ",[92,2262,2263],{"class":105},"keyTail",[92,2265,2266],{"class":193},"(plaintext),                          ",[92,2268,2269],{"class":98},"\u002F\u002F 只存展示用尾号\n",[92,2271,2272],{"class":94,"line":268},[92,2273,637],{"class":193},[13,2275,2276],{},"诚实说明威胁模型：这防的是\"文件被拷走\u002F同步盘泄露\"类低成本窥探，是 OS 钥匙串的轻量等价；本机已失陷的场景任何本地方案都无解。文档里写明白，比吹\"军事级加密\"体面得多。",[33,2278,2280],{"id":2279},"八开发调试与三平台打包","八、开发、调试与三平台打包",[83,2282,2284],{"className":85,"code":2283,"language":87,"meta":88,"style":88},"make dev       # wails dev：前端 Vite 热更新 + Go 侧改动自动重编译\nmake build     # 当前平台产物\nmake package   # 真安装包：make package os=windows|macos|linux → exe\u002Fdmg\u002FAppImage\n",[74,2285,2286,2297,2307],{"__ignoreMap":88},[92,2287,2288,2291,2294],{"class":94,"line":95},[92,2289,2290],{"class":105},"make",[92,2292,2293],{"class":109}," dev",[92,2295,2296],{"class":98},"       # wails dev：前端 Vite 热更新 + Go 侧改动自动重编译\n",[92,2298,2299,2301,2304],{"class":94,"line":102},[92,2300,2290],{"class":105},[92,2302,2303],{"class":109}," build",[92,2305,2306],{"class":98},"     # 当前平台产物\n",[92,2308,2309,2311,2314],{"class":94,"line":116},[92,2310,2290],{"class":105},[92,2312,2313],{"class":109}," package",[92,2315,2316],{"class":98},"   # 真安装包：make package os=windows|macos|linux → exe\u002Fdmg\u002FAppImage\n",[13,2318,2319,2320,2323,2324,2327],{},"调试小技巧：Go 侧日志用 ",[74,2321,2322],{},"log\u002Fslog"," 写文件（lumberjack 滚动），生产态安装包没有终端可看，日志文件是唯一眼睛。开发态 ",[74,2325,2326],{},"wails dev"," 自带 Chromium DevTools，前端问题常规处理。",[13,2329,2330,2333,2334,2337,2338,2341,2342,2345,2346,2349,2350,2352],{},[74,2331,2332],{},"make package"," 背后是一个自写的 ",[74,2335,2336],{},"scripts\u002Fpackage.sh","，把各平台差异收敛成一条命令：macOS 编译出 ",[74,2339,2340],{},".app"," 后用系统自带的 ",[74,2343,2344],{},"hdiutil"," 封装 dmg（零额外依赖）；Windows 用 ",[74,2347,2348],{},"wails build -nsis"," 出安装器，检测本机没有 makensis 时明确降级为绿色 exe 并打印提示——不假装成功。CI 上 GitHub Actions 三个 runner 各跑一遍 ",[74,2351,2332],{}," 传 artifact 即可。注意 Windows 代码签名和 macOS 公证是\"用户看到 SmartScreen 红牌与否\"的分水岭，预算允许尽早买证书。",[13,2354,2355,2356,1781,2359,2362,2363,1781,2366,2369,2370,2373,2374,2377,2378,2381],{},"还有一些桌面体验的\"最后一公里\"，Wails 都留了接口，本项目用到的有：",[47,2357,2358],{},"系统托盘",[74,2360,2361],{},"internal\u002Ftray"," 包，Win\u002FLinux 上\"关闭=隐藏到托盘\"，macOS 无托盘则关闭即退出——这个差异要在代码里按平台分叉，不要想当然）；",[47,2364,2365],{},"原生对话框",[74,2367,2368],{},"runtime"," 包的文件选择\u002F保存\u002F消息框，比 HTML ",[74,2371,2372],{},"\u003Cinput type=file>"," 体验好太多，而且拿到的是真实磁盘路径）；",[47,2375,2376],{},"外链处理","（应用内所有\"去官网注册密钥\"的链接必须走 ",[74,2379,2380],{},"runtime.BrowserOpenURL"," 交给系统浏览器，桌面应用里嵌外链页面是灾难）。这些细节单个都不难，凑起来就是\"像正经软件\"和\"像网页套壳\"的区别。",[33,2383,2385],{"id":2384},"九小结","九、小结",[13,2387,2388],{},"回头看这个技术选型组合：",[2390,2391,2392,2405],"table",{},[2393,2394,2395],"thead",{},[2396,2397,2398,2402],"tr",{},[2399,2400,2401],"th",{},"需求",[2399,2403,2404],{},"方案",[2406,2407,2408,2417,2425,2433,2441,2449,2457],"tbody",{},[2396,2409,2410,2414],{},[2411,2412,2413],"td",{},"桌面壳 + 前端生态",[2411,2415,2416],{},"Wails v2 + Vue3，产物嵌 embed.FS",[2396,2418,2419,2422],{},[2411,2420,2421],{},"前端调后端",[2411,2423,2424],{},"绑定方法（自动 TS 桩）",[2396,2426,2427,2430],{},[2411,2428,2429],{},"后端推进度",[2411,2431,2432],{},"EventsEmit\u002FEventsOn，service 持回调不 import wails",[2396,2434,2435,2438],{},[2411,2436,2437],{},"本地图片预览",[2411,2439,2440],{},"AssetServer Handler + 目录穿越校验",[2396,2442,2443,2446],{},[2411,2444,2445],{},"多模型接入",[2411,2447,2448],{},"OpenAI 兼容网关，一协议三参数",[2396,2450,2451,2454],{},[2411,2452,2453],{},"任务不丢",[2411,2455,2456],{},"SQLite 唯一真相 + 出队四重校验 + 启动恢复",[2396,2458,2459,2462],{},[2411,2460,2461],{},"密钥安全",[2411,2463,2464],{},"AES-256-GCM 加密文件，0600，只露尾号",[13,2466,2467],{},"Wails 的成熟度比我预期高：单实例锁、托盘、原生对话框、无边框这些桌面细节全有官方支持，一周就能把壳搭好；真正花时间的地方在第六、七节这些\"数据与钱\"的设计上。",[13,2469,2470,2471,2474,2475,2478],{},"系列",[17,2472,2473],{"href":19},"第 1 篇","（流水线架构）和",[17,2476,2477],{"href":24},"第 3 篇","（踩坑复盘）见本站。欢迎聊聊你用 Wails 或 Electron 的看法。",[2480,2481,2482],"style",{},"html pre.shiki code .si27w, html code.shiki .si27w{--shiki-default:#6A737D;--shiki-light:#6A737D}html pre.shiki code .spKkM, html code.shiki .spKkM{--shiki-default:#F97583;--shiki-light:#D73A49}html pre.shiki code .shWlK, html code.shiki .shWlK{--shiki-default:#E1E4E8;--shiki-light:#24292E}html pre.shiki code .sqoU-, html code.shiki .sqoU-{--shiki-default:#B392F0;--shiki-light:#6F42C1}html pre.shiki code .skb7c, html code.shiki .skb7c{--shiki-default:#9ECBFF;--shiki-light:#032F62}html pre.shiki code .sTU5a, html code.shiki .sTU5a{--shiki-default:#79B8FF;--shiki-light:#005CC5}html pre.shiki code .sdxZB, html code.shiki .sdxZB{--shiki-default:#FFAB70;--shiki-light:#E36209}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .light .shiki span {color: var(--shiki-light);background: var(--shiki-light-bg);font-style: var(--shiki-light-font-style);font-weight: var(--shiki-light-font-weight);text-decoration: var(--shiki-light-text-decoration);}html.light .shiki span {color: var(--shiki-light);background: var(--shiki-light-bg);font-style: var(--shiki-light-font-style);font-weight: var(--shiki-light-font-weight);text-decoration: var(--shiki-light-text-decoration);}",{"title":88,"searchDepth":102,"depth":102,"links":2484},[2485,2486,2487,2488,2494,2495,2496,2497,2498],{"id":35,"depth":102,"text":36},{"id":80,"depth":102,"text":81},{"id":165,"depth":102,"text":166},{"id":694,"depth":102,"text":695,"children":2489},[2490,2491,2492,2493],{"id":699,"depth":116,"text":700},{"id":937,"depth":116,"text":938},{"id":1202,"depth":116,"text":1203},{"id":1484,"depth":116,"text":1485},{"id":1521,"depth":102,"text":1522},{"id":1821,"depth":102,"text":1822},{"id":2127,"depth":102,"text":2128},{"id":2279,"depth":102,"text":2280},{"id":2384,"depth":102,"text":2385},"https:\u002F\u002Fname.os.bennywen.xyz\u002Fbennywen.xyz\u002Fassets\u002Fecom-detail-ai-part2-wails\u002Fmain-scene1.jpg","2026-10-14","md",null,{},"\u002Fposts\u002Fecom-detail-ai-part2-wails","ecom-detail-ai",{"title":5,"description":88},"ecom-detail-ai-part2-wails","posts\u002Fecom-detail-ai-part2-wails","为什么是 Wails 不是 Electron、Go ↔ Vue 用绑定方法与事件两板斧、把 SQLite 当调度器的唯一真相、AES-256-GCM 存密钥——把一个 AI 桌面应用\"真正能用\"的那部分讲完。",[2511,2512],"Go","Wails","rKFH8f0KFUjobHtU1zgRzY8k17msR8OhnlUCGq7wFq8",[2515,2519,2523,2527,2530,2531,2534],{"title":2516,"slug":2517,"date":2518},"不引 Ghostscript：我用纯 Go 给 PDF 做了一场「压缩手术」","pure-go-pdf-compression","2026-10-24",{"title":2520,"slug":2521,"date":2522},"Wails v3 升级实战：桌面提醒客户端从 v2 升到 v3，踩了 10 个不报错的坑","wails-v3-upgrade","2026-10-23",{"title":2524,"slug":2525,"date":2526},"用 RAG + FAISS 构建古诗词 AI 取名系统：从向量检索到六层质量过滤","chinese-poetry-rag","2026-10-15",{"title":2528,"slug":2529,"date":2500},"一个人做 AI 产品，我踩过的 9 个真实的坑","ecom-detail-ai-part3-pitfalls",{"title":5,"slug":2507,"date":2500},{"title":2532,"slug":2533,"date":2500},"我用 Go 搭了一条 AI Agent 流水线：从 1 张商品图到一整套淘宝详情页","ecom-detail-ai-part1-architecture",{"title":2535,"slug":2536,"date":2537},"AI 时代，如何把一个需求真正落地成应用？——一条\"文档驱动 + 实测修订\"的实战路径","ai-requirement-landing","2026-10-13",{"id":2539,"title":30,"architecture":2540,"body":2541,"category":2781,"client":2782,"cover":2783,"date":2784,"demo":2502,"demoMobile":2502,"description":88,"extension":2501,"featured":211,"features":2785,"github":2502,"manual":2802,"meta":2855,"modules":2502,"navigation":211,"order":409,"path":29,"seo":2856,"slug":2505,"stem":2857,"summary":2858,"techStack":2859,"visible":211,"year":2866,"__hash__":2867},"projects\u002Fprojects\u002Fecom-detail-ai.md","https:\u002F\u002Fname.os.bennywen.xyz\u002Fbennywen.xyz\u002Fassets\u002Fecom-detail-ai\u002Fecom-detail-architecture.svg",{"type":7,"value":2542,"toc":2773},[2543,2546,2549,2552,2555,2558,2560,2563,2619,2622,2692,2702,2712,2720,2723,2763,2766],[33,2544,2545],{"id":2545},"缘起",[13,2547,2548],{},"帮人上过一款连衣裙的详情页。找外包要等好几天，自己上手就得同时会 PS、会布光、还会写文案，我三样都不精。第一版做出来光影像贴上去的，商品边缘还走了形。",[13,2550,2551],{},"当时想要的东西其实很朴素：扔一张挂拍图进去，别问我品类、别问我风格，直接出一套能用的。",[33,2553,2554],{"id":2554},"背景",[13,2556,2557],{},"现成的 AI 工具大多停在模板拼贴：光影有贴图感、商品容易变形，号称一键生成，实际仍要用户逐项选品类、选风格、选模板，等于把设计判断又还给了不会设计的人。而详情页本身是一整套东西——主图、场景图、白底图、详情长图、切片、文案，每一样尺寸和规则都不一样，分开做就要分开对齐。要压缩的是这一整段决策：上传一张商品图，不选品类、不选风格，直接得到一整套带真实感光影渲染的详情页。",[33,2559,2404],{"id":2404},[13,2561,2562],{},"一个装在电脑上、双击就能用的客户端，核心是一条会自己拿主意的生成流水线：",[642,2564,2565,2571,2577,2583,2589,2595,2601,2607,2613],{},[44,2566,2567,2570],{},[47,2568,2569],{},"十步全自动，中途不用管","：抠图 → 读图理解 → 自主规划（出几张图、每张什么场景与光影、页面分几块）→ 逐张渲染 → 画面描述 → 文案 → 本机排版合成 → 标准卡片 → 自检 → 完成。每一步的状态和中间产物都会落盘，界面上按时间线看得见进度，任务可以取消、关掉再开还能接着跑",[44,2572,2573,2576],{},[47,2574,2575],{},"真实感出图","：基于原图做编辑式生成，而不是把商品抠出来贴进模板；多张参考图输入保住商品一致性。出图引擎可在 Seedream \u002F GLM-Image 之间切换，主选是拿真实商品盲测定的",[44,2578,2579,2582],{},[47,2580,2581],{},"关键文字交给本机，不交给模型","：卖点、参数这类文字由本机排版引擎叠加（内置可免费商用的中文字体，不消耗模型额度，可编辑、可导出），生成模型不承担关键文字的渲染",[44,2584,2585,2588],{},[47,2586,2587],{},"自检闭环","：出完自动拿成品跟原图比——查商品有没有走形、光影是否违和、图内有没有错字；没过就带着诊断重做，还是没过就标成「待人工确认」并在结果页置顶提示",[44,2590,2591,2594],{},[47,2592,2593],{},"标准卡片产线","：主图卡 + 参数卡 × 3 规格。取色优先级为 单张卡手动改色 > 任务级模板 > 类目默认模板 > 全局默认 > 按商品图派生的配色——模板只管结构，颜色默认跟着商品走",[44,2596,2597,2600],{},[47,2598,2599],{},"商品类目可以自己管","：内置 11 组，能增、能删、能改，改完直接保存下来。类目还额外带着这个类目的参数项提示、默认卡片样式，以及敏感类目下强制关闭人像场景的硬规则（无条件压过全局的人像开关）",[44,2602,2603,2606],{},[47,2604,2605],{},"批量生成","：表格文件或整个文件夹一次导入、整批排队，共用同一套队列按设定的并发数（1-3）跑；单条失败不影响其它，同一批连续 3 条失败会自动停下并提示排查",[44,2608,2609,2612],{},[47,2610,2611],{},"密钥自带，成本透明","：智谱 \u002F DeepSeek \u002F 火山方舟 \u002F 阿里云四家各一把密钥，都是必需的；费用直接结算在用户自己的模型账号上，每个任务开跑前明示预估金额",[44,2614,2615,2618],{},[47,2616,2617],{},"导出即合规","：主图 800×800（含白底图）+ 详情 750px 切片（单张目标不超过 500KB）+ 文案 + 压缩包，导出前先预检尺寸和体积；生成来源的标记在切片、压缩全过程都不会被剥离，另在图上用角标显式标出",[33,2620,2621],{"id":2621},"技术架构",[2390,2623,2624,2634],{},[2393,2625,2626],{},[2396,2627,2628,2631],{},[2399,2629,2630],{},"模块",[2399,2632,2633],{},"技术选型",[2406,2635,2636,2644,2652,2660,2668,2676,2684],{},[2396,2637,2638,2641],{},[2411,2639,2640],{},"后端",[2411,2642,2643],{},"Go + GORM + SQLite（纯 Go 驱动，交叉编译无 CGO）",[2396,2645,2646,2649],{},[2411,2647,2648],{},"桌面运行时",[2411,2650,2651],{},"Wails v2（WebView2 \u002F WKWebView），无边框窗口 + 托盘 + 崩溃落盘",[2396,2653,2654,2657],{},[2411,2655,2656],{},"前端",[2411,2658,2659],{},"Vue3 + Vite + Element Plus + Pinia，暗色优先设计令牌",[2396,2661,2662,2665],{},[2411,2663,2664],{},"模型网关",[2411,2666,2667],{},"OpenAI 兼容协议统一接入（chat + 图像生成），模型位\u002F降级顺位\u002F价格表全部配置化",[2396,2669,2670,2673],{},[2411,2671,2672],{},"抠图",[2411,2674,2675],{},"阿里云商品分割 API（≈0.002 元\u002F次，直出白底图）",[2396,2677,2678,2681],{},[2411,2679,2680],{},"排版",[2411,2682,2683],{},"Go 侧 750px 位图合成 + CJK 字体渲染",[2396,2685,2686,2689],{},[2411,2687,2688],{},"工程化",[2411,2690,2691],{},"架构护栏（AST 断言）+ 依赖登记制 + golden 像素基线测试 + OpenSpec 治理",[13,2693,2694,2695,2697,2698,2701],{},"数据侧 6 张表（Product \u002F GenerationTask \u002F GenerationBatch \u002F TaskStep \u002F GeneratedAsset \u002F ExportRecord），其中 ",[74,2696,2119],{}," 按",[47,2699,2700],{},"图片实例","多行存储，各自携带状态与产物引用——图级断点续跑和\"已完成部分不重复计费\"都依赖这个粒度。",[13,2703,2704,2705,2707,2708,2711],{},"工程约定编译成了会红的检查：分层越界（",[74,2706,157],{}," 不直接 import gorm）、模型注册双向校验、新增依赖必须登记 ",[74,2709,2710],{},"deps.yaml","、渲染层禁 import node 能力、禁硬编码色值，且护栏解析到 0 个结果会 Fatal 而非静默放行。冒烟测试定位为本地验证——三平台 CI 只做静态检查与编译，不启动图形界面。",[13,2713,2714,2715,2719],{},"本项目由 ",[17,2716,2718],{"href":2717},"\u002Fprojects\u002Fwails-bailer-starter","Wails 桌面端脚手架基座"," 实例化而来——运行时能力与管道契约直接复用，业务面全部在此长出。",[33,2721,2722],{"id":2722},"成果",[642,2724,2725,2735,2745,2751,2757],{},[44,2726,2727,2730,2731,2734],{},[47,2728,2729],{},"从 3-7 天到 8 分半","：单套（5 图）真实密钥全流程实测 ",[47,2732,2733],{},"8 分 27 秒","，落在「≤10 分钟出整套详情页」的目标内",[44,2736,2737,2740,2741,2744],{},[47,2738,2739],{},"它要替换掉的那件事","：外包一套 300-1500 元、3-7 天交付；这里单套模型消耗理想值约 ",[47,2742,2743],{},"1 元","，算上 1-3 次单张重做的真实预期 1-2 元——费用结算在用户自己的模型账号上，中间不过手",[44,2746,2747,2750],{},[47,2748,2749],{},"出图主选是盲测翻盘定的","：Seedream 4.5 把商品结构复刻到近乎完美，GLM-Image 重绘成\"同类不同款\"——据此调换了出图位顺位",[44,2752,2753,2756],{},[47,2754,2755],{},"自检不是摆设","：成品自检比对原图查商品走形、光影违和与图内错字，未通过带诊断重做；仍不通过就置顶标记「待人工确认」，而不是悄悄交付",[44,2758,2759,2762],{},[47,2760,2761],{},"拿到手就是装完能用的安装包","：Mac 出带拖拽引导的安装包、Windows 出安装器，目标机器不需要 Go \u002F Node.js 运行时",[33,2764,2765],{"id":2765},"体验",[13,2767,2768,2769,2772],{},"内测阶段的桌面客户端，目前主要是我自己在用——",[47,2770,2771],{},"暂无公开下载与在线演示，源码也未公开","。想先看它是怎么搭起来的，可以读本页下方列出的相关技术文章——架构、工程化与踩坑各有落笔。",{"title":88,"searchDepth":102,"depth":102,"links":2774},[2775,2776,2777,2778,2779,2780],{"id":2545,"depth":102,"text":2545},{"id":2554,"depth":102,"text":2554},{"id":2404,"depth":102,"text":2404},{"id":2621,"depth":102,"text":2621},{"id":2722,"depth":102,"text":2722},{"id":2765,"depth":102,"text":2765},"ai","个人项目","https:\u002F\u002Fname.os.bennywen.xyz\u002Fbennywen.xyz\u002Fassets\u002Fecom-detail-ai\u002Fecom-detail-hero.webp","2026-09-29",[2786,2790,2794,2798],{"title":2787,"description":2788,"image":2789},"1 张挂拍图 → 一整套场景渲染","左侧用户上传的挂拍原图，右侧由程序自己规划并渲染的逆光石墙与窗边场景，商品结构保持一致","https:\u002F\u002Fname.os.bennywen.xyz\u002Fbennywen.xyz\u002Fassets\u002Fecom-detail-ai\u002Fecom-detail-input2out.webp",{"title":2791,"description":2792,"image":2793},"详情长图 · 750px 本机排版合成","关键文字在出图后由本机排版叠加，生成模型不承担文字渲染；按平台高度限制自动切片","https:\u002F\u002Fname.os.bennywen.xyz\u002Fbennywen.xyz\u002Fassets\u002Fecom-detail-ai\u002Fecom-detail-longpage.webp",{"title":2795,"description":2796,"image":2797},"主图导出 · 场景图与白底图","阿里云商品分割直出白底图，800×800 主图 + AI 标识角标 + ZIP 打包","https:\u002F\u002Fname.os.bennywen.xyz\u002Fbennywen.xyz\u002Fassets\u002Fecom-detail-ai\u002Fecom-detail-mainimgs.webp",{"title":2799,"description":2800,"image":2801},"首次启动向导 · 四把密钥各司其职","感知自检 \u002F 规划文案 \u002F 图像生成 \u002F 抠图四家服务商均为必需，内置注册指引与一键跳转控制台","https:\u002F\u002Fname.os.bennywen.xyz\u002Fbennywen.xyz\u002Fassets\u002Fecom-detail-ai\u002Fecom-detail-wizard.webp",{"specs":2803,"modules":2822,"deploy":2846,"limits":2850},[2804,2807,2810,2813,2816,2819],{"label":2805,"value":2806},"交付形态","Windows \u002F macOS 桌面客户端，装完即用，目标机器不需要另装运行环境",{"label":2808,"value":2809},"单套产出","主图 + 白底图 + 详情长图切片 + 文案，一套实测约 8 分半",{"label":2811,"value":2812},"使用成本","一套约 1-2 元，结算在你自己名下的模型账号，开跑前先给预估金额",{"label":2814,"value":2815},"风格决策","不用选品类、不用选风格——程序读图后自己定，也可在看图后手动指定",{"label":2817,"value":2818},"批量能力","表格或文件夹一次导入一整批商品排队生成，单批上限 200 条",{"label":2820,"value":2821},"隐私","商品图只发往你自己账号下的模型服务；长图排版与打包在本机完成",[2823,2826,2829,2832,2835,2838,2841,2843],{"name":2824,"desc":2825},"读图","拿到商品图先看懂它是什么——品类、材质、结构，以及有哪些值得讲的点",{"name":2827,"desc":2828},"自主规划","自己决定这套详情页要出几张图、每张配什么场景与光影、页面分几块",{"name":2830,"desc":2831},"场景渲染","按规划逐张生成，商品结构保持一致，不是把商品抠出来贴进模板",{"name":2833,"desc":2834},"文字排版","卖点与参数文字在本机排版合成，不吃模型的额度，也可以自己改",{"name":2836,"desc":2837},"成品自检","出完自动跟原图比对，发现商品走形、光影违和或图内错字就带诊断重做",{"name":2839,"desc":2840},"导出打包","一次拿到主图、白底图、详情长图切片与文案，尺寸体积先自检再打包",{"name":2605,"desc":2842},"整批商品排队跑，单条失败不影响其它；连环失败自动停下提示排查",{"name":2844,"desc":2845},"成本预估","开跑前估算这一套要花多少钱，钱花在哪一步看得见",[2847,2848,2849],"内测阶段的桌面客户端：装包后双击即用，不需要装数据库，也不需要自己的服务器","首次启动有一个配置向导，带你领四家模型服务的密钥（读图、规划、出图、抠图各一把）","生成在云端跑，不吃本机算力；长图排版与打包在本机完成",[2851,2852,2853,2854],"Mac 上关掉窗口即退出，没有常驻托盘（Windows 上可以收进托盘后台继续生成）","编辑只到「模块」这一层——增删、排序、换图可以，没有元素级的拖拽排版","商品模特图暂时没有：真人像生成涉及合规，留到下一版评估","在设置页手动换过模型服务商之后，成本估算仍按原来的价格显示，需要重过一遍向导才会同步",{},{"title":30,"description":88},"projects\u002Fecom-detail-ai","面向淘宝\u002F天猫中小卖家与美工——1 张商品图进，不选品类不选风格，8 分半出一整套详情页，单套模型消耗约 1-2 元",[2511,2860,2861,2512,2862,2863,2864,2865],"GORM","SQLite","Vue3","Element Plus","Vite","Pinia","2026","gD63yNSExkuy6opU0htv0KjL2bv-jAcGuYWjwHk1L7w",1792857942419]