[{"data":1,"prerenderedAt":2130},["ShallowReactive",2],{"\u002F2026-06-16-skill":3,"\u002F2026-06-16-skill-rel":530},{"id":4,"title":5,"body":6,"column":513,"date":514,"description":12,"extension":515,"hero_image":516,"meta":517,"navigation":518,"path":519,"seo":520,"series_id":516,"severity":516,"stem":521,"summary":522,"tags":523,"__hash__":529},"posts\u002F2026-06-16-把高风险发版固化成skill.md","不让 AI 碰发版，不等于靠人记住每一步",{"type":7,"value":8,"toc":507},"minimark",[9,13,18,21,24,46,57,60,64,67,70,209,217,220,227,257,260,267,271,274,277,284,354,365,428,431,446,453,456,473,480,483,486,497,500,503],[10,11,12],"p",{},"发版从来不应该由人工记忆驱动。5 月那一轮 OTA 系列故障（FA-005 到 FA-008）和跨版本步进导致的漏 DLL 事故反复说明了这一点：发版是不可逆的、影响全部用户的、细节繁琐的操作，任何一个环节遗漏或顺序错误就导致客户端卡死、文件不一致、激活页死循环。我现在不是让 AI 执行发版——那是禁地——而是把 5 个已经踏过所有坑的发版流程全部固化成 skill，让执行者用最小脑力成本跑完完整链路，而不是依赖文档翻译和记忆。",[14,15,17],"h2",{"id":16},"触发词不该触发时绝对不触发","触发词：不该触发时绝对不触发",[10,19,20],{},"一个 skill 最危险的时刻是被错误调用。\"现在是什么版本\"听起来像发版，\"改一下版本号\"听起来像发版，但它们完全不是。",[10,22,23],{},"Wakou 发版 skill 的触发词清单（必读触发词段）就是为了卡死这条线：",[25,26,27,35],"ul",{},[28,29,30,31],"li",{},"触发：",[32,33,34],"code",{},"发版 \u002F 发布 \u002F release \u002F 打个新版本 \u002F OTA \u002F OTA 死循环 \u002F 版本 bump \u002F \u002Fwakou-release",[28,36,37,38,41,42,45],{},"不触发：",[32,39,40],{},"改一下 package.json 版本号","（用户只想改文件），",[32,43,44],{},"看看现在是什么版本","（只查询）",[10,47,48,49,52,53,56],{},"反面教材我都经历过。改一下配置文件、改一个环境变量、查一个版本号，因为没有明确的触发词界限，最后莫名其妙走到了发版脚本的某一步。Connector OTA skill 同样列明了不触发场景：",[32,50,51],{},"\"Connector 现在是什么版本\"(只查询)"," ",[32,54,55],{},"\"改 tauri.conf 版本号\"(用户只想改文件)"," — 这些都不触发，即使命令里含了 version、release 这类关键字。",[10,58,59],{},"这条最反直觉但最重要：不该触发时被触发，等于主动送一次误操作机会给自动化流程。",[14,61,63],{"id":62},"步骤不可跳过验证链条硬卡","步骤不可跳过：验证链条硬卡",[10,65,66],{},"一旦 skill 确定要执行，每一步都必须有验证关卡，前一步没通过绝不进下一步。我用 Wakou 全自动发版流程作例子。",[10,68,69],{},"预检（Step 1）是整个流程的闸门。跑这些 Bash 检查：",[71,72,77],"pre",{"className":73,"code":74,"language":75,"meta":76,"style":76},"language-bash shiki shiki-themes github-light github-dark","cd \u002FUsers\u002Fxingye\u002FAi\u002Fnew-openclaw\ngit status --short                                          # 工作区干净\ntest -x \u002Fopt\u002Fhomebrew\u002Fbin\u002Fsshpass                           # ssh 工具\ntest -x \u002Fopt\u002Fhomebrew\u002Fbin\u002Fminisign                          # 签名工具\ntest -x \u003C签名 CLI 路径>                                      # 内部签名工具就位\ntest -f \u003C发版签名私钥路径>                                   # 私钥就位\ntest -f \u003CCDN 上传凭据路径>                                   # CDN 凭据就位\n","bash","",[32,78,79,92,109,124,137,167,187],{"__ignoreMap":76},[80,81,84,88],"span",{"class":82,"line":83},"line",1,[80,85,87],{"class":86},"sj4cs","cd",[80,89,91],{"class":90},"sZZnC"," \u002FUsers\u002Fxingye\u002FAi\u002Fnew-openclaw\n",[80,93,95,99,102,105],{"class":82,"line":94},2,[80,96,98],{"class":97},"sScJk","git",[80,100,101],{"class":90}," status",[80,103,104],{"class":86}," --short",[80,106,108],{"class":107},"sJ8bj","                                          # 工作区干净\n",[80,110,112,115,118,121],{"class":82,"line":111},3,[80,113,114],{"class":86},"test",[80,116,117],{"class":86}," -x",[80,119,120],{"class":90}," \u002Fopt\u002Fhomebrew\u002Fbin\u002Fsshpass",[80,122,123],{"class":107},"                           # ssh 工具\n",[80,125,127,129,131,134],{"class":82,"line":126},4,[80,128,114],{"class":86},[80,130,117],{"class":86},[80,132,133],{"class":90}," \u002Fopt\u002Fhomebrew\u002Fbin\u002Fminisign",[80,135,136],{"class":107},"                          # 签名工具\n",[80,138,140,142,144,148,151,154,157,161,164],{"class":82,"line":139},5,[80,141,114],{"class":86},[80,143,117],{"class":86},[80,145,147],{"class":146},"szBVR"," \u003C",[80,149,150],{"class":90},"签名",[80,152,153],{"class":90}," CLI",[80,155,156],{"class":90}," 路",[80,158,160],{"class":159},"sVt8B","径",[80,162,163],{"class":146},">",[80,165,166],{"class":107},"                                      # 内部签名工具就位\n",[80,168,170,172,175,177,180,182,184],{"class":82,"line":169},6,[80,171,114],{"class":86},[80,173,174],{"class":86}," -f",[80,176,147],{"class":146},[80,178,179],{"class":90},"发版签名私钥路",[80,181,160],{"class":159},[80,183,163],{"class":146},[80,185,186],{"class":107},"                                   # 私钥就位\n",[80,188,190,192,194,196,199,202,204,206],{"class":82,"line":189},7,[80,191,114],{"class":86},[80,193,174],{"class":86},[80,195,147],{"class":146},[80,197,198],{"class":90},"CDN",[80,200,201],{"class":90}," 上传凭据路",[80,203,160],{"class":159},[80,205,163],{"class":146},[80,207,208],{"class":107},"                                   # CDN 凭据就位\n",[10,210,211,212,216],{},"任何一条 fail，流程停止并告诉用户修什么。如果 git 工作区脏，不是自动 stash，而是问用户：工作区有未提交改动，要先 commit 还是 stash？发版会自动 bump 三个版本文件并 commit。这样做的目的很明确——让用户",[213,214,215],"strong",{},"清晰意识到","发版会改动工作区，而不是闭着眼睛走入自动流程。",[10,218,219],{},"收三个密码（Step 2）也有安全约定。不要把密码记到内存或 commit 进文件，只读 shell 的 export。如果用户在过去几分钟内说过这些密码（在 conversation context 里能直接拿到），可以直接传 env 跑；否则必须用户主动导入。",[10,221,222,223,226],{},"跑 release.sh（Step 3）用 ",[32,224,225],{},"run_in_background=true"," 避免 5-15 分钟阻塞主线，把输出 tee 到日志。然后（Step 4）用 Monitor 工具监控 10 个 stage 的进度：",[228,229,230,233,236,239,242,245,248,251,254],"ol",{},[28,231,232],{},"preflight",[28,234,235],{},"detect FROM_VERSION",[28,237,238],{},"tar + scp source to Win build host",[28,240,241],{},"build on Win",[28,243,244],{},"stage + diff-pack OTA full.zip",[28,246,247],{},"minisign full.zip + release-manifest.json",[28,249,250],{},"upload to CDN",[28,252,253],{},"admin: create release + PATCH rollout",[28,255,256],{},"git tag + commit version bump",[10,258,259],{},"每个 stage 完成给用户报一句。出现 ✗ FAILED 立刻 surface 错误。没有\"继续试试能不能救\"，就是暴露失败。",[10,261,262,263,266],{},"这整套链条的目的是",[213,264,265],{},"让人工审核点布满全流程","。不是机器自动发版，而是 AI 陪着用户一步步走完发版，每步都看得清、验得出。",[14,268,270],{"id":269},"故障处置内联把坑写在流程里","故障处置内联：把坑写在流程里",[10,272,273],{},"OTA 系列故障的教训不应该沉在 bug 复盘里。我把它们直接铺进 skill 的执行逻辑。",[10,275,276],{},"Wakou 发版 skill 有整整一章叫「🚨 阻塞性 bug 处置 SOP」。列出了从 G19 到 G31 的 11 个已知 bug：",[10,278,279,280,283],{},"G19 是 OTA modal 反复弹。原因是 state base 残留 stale ",[32,281,282],{},"download.zip.minisig","，当时的修复是：",[71,285,287],{"className":73,"code":286,"language":75,"meta":76,"style":76},"# 预检 sanity#3: data\u002F 不删\nsanity#3 强制阻断 deletes 命中 data\u002F。某次发版失败提示 sanity#3 一定是 build 步骤搞坏了 data\u002F 目录的同步，不要绕过 sanity check 去强发，先 debug 为什么 data\u002F 出现在 deletes 列表。\n",[32,288,289,294],{"__ignoreMap":76},[80,290,291],{"class":82,"line":83},[80,292,293],{"class":107},"# 预检 sanity#3: data\u002F 不删\n",[80,295,296,299,302,305,308,311,314,317,320,323,326,329,332,335,338,341,344,346,349,351],{"class":82,"line":94},[80,297,298],{"class":97},"sanity#3",[80,300,301],{"class":90}," 强制阻断",[80,303,304],{"class":90}," deletes",[80,306,307],{"class":90}," 命中",[80,309,310],{"class":90}," data\u002F。某次发版失败提示",[80,312,313],{"class":90}," sanity#3",[80,315,316],{"class":90}," 一定是",[80,318,319],{"class":90}," build",[80,321,322],{"class":90}," 步骤搞坏了",[80,324,325],{"class":90}," data\u002F",[80,327,328],{"class":90}," 目录的同步，不要绕过",[80,330,331],{"class":90}," sanity",[80,333,334],{"class":90}," check",[80,336,337],{"class":90}," 去强发，先",[80,339,340],{"class":90}," debug",[80,342,343],{"class":90}," 为什么",[80,345,325],{"class":90},[80,347,348],{"class":90}," 出现在",[80,350,304],{"class":90},[80,352,353],{"class":90}," 列表。\n",[10,355,356,357,360,361,364],{},"G25 是跨用户机器后 chat 权限错。根因是 ",[32,358,359],{},"sessions\u002Faudit"," 含绝对路径，guardian sync 扩散。这不是\"修了以后的故事\"，而是",[213,362,363],{},"发版前防御 checklist"," 的一部分：",[71,366,368],{"className":73,"code":367,"language":75,"meta":76,"style":76},"# 发版前防御 checklist (每次发版前在 build host 做完整新用户回归)\n# 3. dist 内 grep C:\\Users\\ 应该 0 命中（防 G25 类绝对路径泄露）\nssh CHANCHING@\u003C构建机> 'powershell -Command \"\n  Get-ChildItem D:\\wakou-build\\dist-X.Y.Z\\WakouPanelPortable -Recurse -File -Include *.json,*.jsonl |\n    Where-Object { $_.Length -lt 200KB } |\n    Select-String C:\\\\Users\\\\Administrator -SimpleMatch -List |\n    Select-Object FullName\n\"'\n",[32,369,370,375,380,402,407,412,417,422],{"__ignoreMap":76},[80,371,372],{"class":82,"line":83},[80,373,374],{"class":107},"# 发版前防御 checklist (每次发版前在 build host 做完整新用户回归)\n",[80,376,377],{"class":82,"line":94},[80,378,379],{"class":107},"# 3. dist 内 grep C:\\Users\\ 应该 0 命中（防 G25 类绝对路径泄露）\n",[80,381,382,385,388,391,394,397,399],{"class":82,"line":111},[80,383,384],{"class":97},"ssh",[80,386,387],{"class":90}," CHANCHING@",[80,389,390],{"class":146},"\u003C",[80,392,393],{"class":90},"构建",[80,395,396],{"class":159},"机",[80,398,163],{"class":146},[80,400,401],{"class":90}," 'powershell -Command \"\n",[80,403,404],{"class":82,"line":126},[80,405,406],{"class":90},"  Get-ChildItem D:\\wakou-build\\dist-X.Y.Z\\WakouPanelPortable -Recurse -File -Include *.json,*.jsonl |\n",[80,408,409],{"class":82,"line":139},[80,410,411],{"class":90},"    Where-Object { $_.Length -lt 200KB } |\n",[80,413,414],{"class":82,"line":169},[80,415,416],{"class":90},"    Select-String C:\\\\Users\\\\Administrator -SimpleMatch -List |\n",[80,418,419],{"class":82,"line":189},[80,420,421],{"class":90},"    Select-Object FullName\n",[80,423,425],{"class":82,"line":424},8,[80,426,427],{"class":90},"\"'\n",[10,429,430],{},"任何一项 fail → 不要 GA，修了再 bump 一个 patch 版本。",[10,432,433,434,437,438,441,442,445],{},"Connector OTA skill 的故障字典（§4）则是一张病症与病因的对照表。\"signature verification failed\" 对应的处置是检查 ",[32,435,436],{},"TAURI_SIGNING_PRIVATE_KEY"," 是不是 .sec 文件",[213,439,440],{},"base64 编码后","的内容。\"OTA 不跨级但用户期望直跳\" 的处置是调整 ",[32,443,444],{},"findNextVersion"," 逻辑或在灰度模式放上一个 manifest。",[10,447,448,449,452],{},"关键在",[213,450,451],{},"处置是写死在 skill 里","，不是留在事后复盘让人去翻。OpenClaw Runtime 发版 skill 的故障字典甚至铺了 10 条常见陷阱：BuildKit context 缓存不清导致改动没编进去、gwbridge IP 池满导致 service 无法起、孤儿 docker-proxy 占着 host port、磁盘满导致 openclaw.json 被写成 0 字节。",[10,454,455],{},"每一条都带着：\"现象是什么、根因是什么、怎么修\"。拿 BuildKit 缓存那条：",[457,458,459],"blockquote",{},[10,460,461,462,465,466,469,470,472],{},"症状：rsync\u002FSFTP 把新源推上 prod repo，源文件 grep 确认是新的，但 docker build 出来的镜像里还是旧 bundle。根因：",[32,463,464],{},"--no-cache-filter"," 不可靠，BuildKit 的 build context 层仍可能命中缓存。修法：clawpanel 源改动后，必须用整体 ",[32,467,468],{},"--no-cache","，不要用 ",[32,471,464],{},"。代价：单次 ~20-30 分钟。但这是唯一能确保改动真编进去的方式。",[10,474,475,476,479],{},"这不是\"高级技巧\"或\"经验之谈\"。这是",[213,477,478],{},"发版的必读须知","，写进 skill 里才能确保每次都不遗漏。",[14,481,482],{"id":482},"为什么这样做有效",[10,484,485],{},"高风险操作的安全性来自流程的确定性，不来自执行者的谨慎。人会忘、会打错、会跳步。但固化的流程不会：",[25,487,488,491,494],{},[28,489,490],{},"触发词明确化杀死了\"我不是故意调用发版\"的那类误操作。",[28,492,493],{},"步骤验证链条把每一步的前置条件和验收标准写成 Bash 检查，没法绕过。",[28,495,496],{},"故障处置内联把一个多月里积累的坑提前封死在流程里，新一轮发版不会重蹈覆辙。",[10,498,499],{},"5 个 skill 现在覆盖了：Wakou panel OTA、Connector 桌面端 OTA、OpenClaw runtime 容器全量发布、Codex 助手静默更新、加上客服远程调试的风险边界。它们没有一个是让 AI 执行发版的——都是让 AI 陪着用户，一步步走完一个铺满护栏的流程。",[10,501,502],{},"这是把\"不要让 AI 碰发版\"这条禁区，变成了\"AI 执行发版的框架，确保每一步都验证、每个坑都已知、每种故障都有救法\"的可行路径。",[504,505,506],"style",{},"html pre.shiki code .sj4cs, html code.shiki .sj4cs{--shiki-default:#005CC5;--shiki-dark:#79B8FF}html pre.shiki code .sZZnC, html code.shiki .sZZnC{--shiki-default:#032F62;--shiki-dark:#9ECBFF}html pre.shiki code .sScJk, html code.shiki .sScJk{--shiki-default:#6F42C1;--shiki-dark:#B392F0}html pre.shiki code .sJ8bj, html code.shiki .sJ8bj{--shiki-default:#6A737D;--shiki-dark:#6A737D}html pre.shiki code .szBVR, html code.shiki .szBVR{--shiki-default:#D73A49;--shiki-dark:#F97583}html pre.shiki code .sVt8B, html code.shiki .sVt8B{--shiki-default:#24292E;--shiki-dark:#E1E4E8}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 .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}",{"title":76,"searchDepth":94,"depth":94,"links":508},[509,510,511,512],{"id":16,"depth":94,"text":17},{"id":62,"depth":94,"text":63},{"id":269,"depth":94,"text":270},{"id":482,"depth":94,"text":482},"交付与更新","2026-06-16","md",null,{},true,"\u002F2026-06-16-skill",{"title":5,"description":12},"2026-06-16-把高风险发版固化成skill","发版是明确不交给 AI 自主执行的操作。通过触发词精确化、步骤验证链条、故障处置内联，把流程固化到 skill，让高风险操作失败概率趋零。",[524,525,526,527,528],"发版流程","OTA","自动化","Tauri","Docker Swarm","WbjJuf61wSEx4d5Iov2KhPdG1N6hik51RAl8rAGgug8",[531,929,1792],{"id":532,"title":533,"body":534,"column":513,"date":914,"description":915,"extension":515,"hero_image":516,"meta":916,"navigation":518,"path":917,"seo":918,"series_id":516,"severity":516,"stem":919,"summary":920,"tags":921,"__hash__":928},"posts\u002F2026-08-16-让会话记住目录让回答能停下.md","让会话记住目录，让回答能停下",{"type":7,"value":535,"toc":905},[536,542,546,549,552,555,582,585,593,600,607,611,614,628,631,634,637,641,644,649,666,669,674,683,686,691,700,703,707,710,713,718,724,727,732,741,744,749,758,761,768,773,782,785,790,793,797,800,807,811,814,817,821,824,893,896,902],[10,537,538,539],{},"同一天做了两件事。表面上一个关于文件系统，一个关于流式生成，实际在解决同一个问题：",[213,540,541],{},"长动作要么在一开始就能定好条件，要么在过程中能叫停。",[14,543,545],{"id":544},"一工作目录原先只活在一次调用里","一、工作目录原先只活在一次调用里",[10,547,548],{},"工具调用要指定工作目录。原先的实现是每次调用各自带一个。",[10,550,551],{},"这在单次调用里没问题，但会话是连续的：用户在一个会话里说「看看这个项目」「改一下那个配置」「跑一下测试」，每次都指定一遍目录，重复且容易出错。",[10,553,554],{},"改成会话级能力。数据模型加两个字段：",[71,556,560],{"className":557,"code":558,"language":559,"meta":76,"style":76},"language-sql shiki shiki-themes github-light github-dark","-- 会话级工作文件夹：null 表示沿用设备上报的默认目录，对存量会话零影响。\nALTER TABLE \"Session\" ADD COLUMN \"workspaceDir\" TEXT;\n-- 桌面端注册时上报的本机默认工作文件夹；老版本客户端不上报则保持 null。\nALTER TABLE \"Device\" ADD COLUMN \"defaultWorkspaceDir\" TEXT;\n","sql",[32,561,562,567,572,577],{"__ignoreMap":76},[80,563,564],{"class":82,"line":83},[80,565,566],{},"-- 会话级工作文件夹：null 表示沿用设备上报的默认目录，对存量会话零影响。\n",[80,568,569],{"class":82,"line":94},[80,570,571],{},"ALTER TABLE \"Session\" ADD COLUMN \"workspaceDir\" TEXT;\n",[80,573,574],{"class":82,"line":111},[80,575,576],{},"-- 桌面端注册时上报的本机默认工作文件夹；老版本客户端不上报则保持 null。\n",[80,578,579],{"class":82,"line":126},[80,580,581],{},"ALTER TABLE \"Device\" ADD COLUMN \"defaultWorkspaceDir\" TEXT;\n",[10,583,584],{},"两个字段的分工是：设备上报它本机的默认目录，会话可以覆盖它。取值优先级：",[71,586,591],{"className":587,"code":589,"language":590},[588],"language-text","会话值 ?? 设备默认值\n","text",[32,592,589],{"__ignoreMap":76},[10,594,595,596,599],{},"迁移只用加列，",[32,597,598],{},"null"," 表示沿用设备默认值，存量会话不受影响。",[10,601,602,603,606],{},"长度上限 512 字符，写在协议包里：",[32,604,605],{},"WORKSPACE_DIR_MAX_LENGTH = 512","。",[14,608,610],{"id":609},"二四个执行入口共用同一个目录","二、四个执行入口共用同一个目录",[10,612,613],{},"目录信息要传到的位置比想象中多。同一件事在四个地方会被执行：",[25,615,616,619,622,625],{},[28,617,618],{},"工具调用",[28,620,621],{},"Agent 团队运行",[28,623,624],{},"设备协作",[28,626,627],{},"计划任务",[10,629,630],{},"如果只有工具调用拿到会话目录，其余三个各自回落到设备默认值，用户会遇到「我在会话里设了目录，跑计划任务却跑到了别的地方」。",[10,632,633],{},"所以目录跟着会话走，四个入口都从同一个地方取。",[10,635,636],{},"跨端还有一层：桌面端主进程、预加载层、网页端桥接、聊天界面。四处都要能读能写，用户才能在会话里查看或切换目录。",[14,638,640],{"id":639},"三路径校验的三个坑","三、路径校验的三个坑",[10,642,643],{},"工作目录涉及文件系统，三处安全处理值得单独写。",[10,645,646],{},[213,647,648],{},"相对路径要先解析成绝对路径。",[71,650,654],{"className":651,"code":652,"language":653,"meta":76,"style":76},"language-ts shiki shiki-themes github-light github-dark","\u002F\u002F 相对路径必须先解析成绝对路径再做高危校验，否则 ..\u002F..\u002F..\u002Fetc\u002Fpasswd\n\u002F\u002F 这类相对串匹配不上敏感路径正则会被直接放行\n","ts",[32,655,656,661],{"__ignoreMap":76},[80,657,658],{"class":82,"line":83},[80,659,660],{"class":107},"\u002F\u002F 相对路径必须先解析成绝对路径再做高危校验，否则 ..\u002F..\u002F..\u002Fetc\u002Fpasswd\n",[80,662,663],{"class":82,"line":94},[80,664,665],{"class":107},"\u002F\u002F 这类相对串匹配不上敏感路径正则会被直接放行\n",[10,667,668],{},"先做正则匹配再解析，等于给相对路径开了一条绕过通道——它们看起来不像敏感路径。",[10,670,671],{},[213,672,673],{},"打开目录前先确认它是目录。",[71,675,677],{"className":651,"code":676,"language":653,"meta":76,"style":76},"\u002F\u002F open-workspace-dir 先 stat 确认是目录才放行，避免经 shell.openPath 执行可执行文件\n",[32,678,679],{"__ignoreMap":76},[80,680,681],{"class":82,"line":83},[80,682,676],{"class":107},[10,684,685],{},"用系统调用打开路径时，如果路径指向一个可执行文件，行为会变成执行它。",[10,687,688],{},[213,689,690],{},"目录不存在时要说明，不能静默回落。",[71,692,694],{"className":651,"code":693,"language":653,"meta":76,"style":76},"\u002F\u002F 换机时会话目录在本机不存在则回落设备默认目录，并在工具结果中明示，不静默。\n",[32,695,696],{"__ignoreMap":76},[80,697,698],{"class":82,"line":83},[80,699,693],{"class":107},[10,701,702],{},"这一条和一个功能细节有关：会话可以跨设备打开。用户在公司电脑上设的目录，回家打开时本机不存在。此时回落是唯一选择，但用户必须知道发生了回落——否则他会以为工具跑在他指定的目录里。",[14,704,706],{"id":705},"四停止生成","四、停止生成",[10,708,709],{},"第二个功能是让流式回答可以中途停止。",[10,711,712],{},"场景很具体：模型开始生成一段长回答，用户看前两段就知道方向不对。原先只能等它写完，或者刷新页面重新提问。",[10,714,715],{},[213,716,717],{},"停止信号走 Redis。",[71,719,722],{"className":720,"code":721,"language":590},[588],"POST \u002Fapi\u002Fchat\u002Fstop\n",[32,723,721],{"__ignoreMap":76},[10,725,726],{},"校验会话归属之后，往 Redis 写一个停止信号。选 Redis 而不是进程内变量，是因为生成可能发生在任意一个 pod 上——信号要跨 pod 生效。同一个会话重复调用是幂等的。",[10,728,729],{},[213,730,731],{},"生成侧轮询读信号。",[71,733,735],{"className":651,"code":734,"language":653,"meta":76,"style":76},"\u002F\u002F 生成侧用节流轮询（300ms）读信号，轮边界与流式回调两处检查点；在途工具跑完再收尾\n",[32,736,737],{"__ignoreMap":76},[80,738,739],{"class":82,"line":83},[80,740,734],{"class":107},[10,742,743],{},"两个检查点：一个是每轮对话的边界，一个是流式回调。工具执行中途不打断——一个已经在跑的工具调用停下来会留下半截状态，等它跑完再收尾。",[10,745,746],{},[213,747,748],{},"停止必须落一条消息。",[71,750,752],{"className":651,"code":751,"language":653,"meta":76,"style":76},"\u002F\u002F 停止一定落一条 assistant 消息并记 stoppedAt，否则前端会一直判成生成中\n",[32,753,754],{"__ignoreMap":76},[80,755,756],{"class":82,"line":83},[80,757,751],{"class":107},[10,759,760],{},"这是被前端状态机决定的。前端的「生成中」判定依据是「最后一条是不是用户消息」，如果停止之后不落一条 assistant 消息，界面会一直显示在生成。",[10,762,763,764,767],{},"数据库加一个字段：",[32,765,766],{},"Message.stoppedAt","。落这条消息时记下停止时刻——刷新、重进会话之后状态不该丢。",[10,769,770],{},[213,771,772],{},"结算按实际用量。",[71,774,776],{"className":651,"code":775,"language":653,"meta":76,"style":76},"\u002F\u002F 结算走原有 settle 路径按实际用量多退少补；被停止的半截回答不进长期记忆\n",[32,777,778],{"__ignoreMap":76},[80,779,780],{"class":82,"line":83},[80,781,775],{"class":107},[10,783,784],{},"半截回答不入长期记忆这一条是必要的。记忆提取的输入是完整的一轮对话，半截内容提取出来的「用户偏好」会是错的。",[10,786,787],{},[213,788,789],{},"继续写。",[10,791,792],{},"停止之后，末条消息上给一个「继续写」。它不是重新生成——从停止的位置接着往下，上下文里包含已经写好的部分。",[14,794,796],{"id":795},"五界面上只有一个按钮","五、界面上只有一个按钮",[10,798,799],{},"生成中，发送按钮变成停止按钮。桌面端和移动端两套布局都要改。",[10,801,802,803,806],{},"这个交互决定了一个细节：停止是一个",[213,804,805],{},"和发送同一位置","的操作。用户在等待时手指本来就停在那个位置，改文案比加一个新按钮更快。",[14,808,810],{"id":809},"六桌面端版本","六、桌面端版本",[10,812,813],{},"这两个功能里，工作目录要桌面端配合。桌面端从 1.0.32 升到 1.0.33。",[10,815,816],{},"顺序上有依赖：云端先上线，桌面端包发出后功能才对用户可见。所以版本号更新是单独一个提交，不是为了记录，是为了标记「从哪个包开始这个功能可用」。",[14,818,820],{"id":819},"七两个功能的共同点","七、两个功能的共同点",[10,822,823],{},"把它们放在一起看，都是「让用户在长动作里保留控制权」：",[825,826,827,842],"table",{},[828,829,830],"thead",{},[831,832,833,836,839],"tr",{},[834,835],"th",{},[834,837,838],{},"工作目录",[834,840,841],{},"停止生成",[843,844,845,857,871,882],"tbody",{},[831,846,847,851,854],{},[848,849,850],"td",{},"控制发生在",[848,852,853],{},"事前指定",[848,855,856],{},"事中叫停",[831,858,859,862,867],{},[848,860,861],{},"数据落点",[848,863,864],{},[32,865,866],{},"Session.workspaceDir",[848,868,869],{},[32,870,766],{},[831,872,873,876,879],{},[848,874,875],{},"跨端要求",[848,877,878],{},"主进程 \u002F 预加载 \u002F 桥接 \u002F 界面",[848,880,881],{},"桌面与移动两套布局",[831,883,884,887,890],{},[848,885,886],{},"不静默的地方",[848,888,889],{},"目录不存在时回落要明示",[848,891,892],{},"停止后必须落消息，界面不能一直显示生成中",[10,894,895],{},"四条对应关系里，最后一行是最容易做错的：两个功能都有「状态没落对，界面就一直显示在跑」的风险。工作目录那条如果静默回落，用户以为跑在 A 目录，实际跑在 B；停止那条如果不落消息，用户以为停了，界面说还在跑。",[10,897,898,899],{},"改法和预想的一样朴素：",[213,900,901],{},"把状态放在能读到的地方，并且让用户看见。",[504,903,904],{},"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 .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html pre.shiki code .sJ8bj, html code.shiki .sJ8bj{--shiki-default:#6A737D;--shiki-dark:#6A737D}",{"title":76,"searchDepth":94,"depth":94,"links":906},[907,908,909,910,911,912,913],{"id":544,"depth":94,"text":545},{"id":609,"depth":94,"text":610},{"id":639,"depth":94,"text":640},{"id":705,"depth":94,"text":706},{"id":795,"depth":94,"text":796},{"id":809,"depth":94,"text":810},{"id":819,"depth":94,"text":820},"2026-08-16","同一天做了两件事。表面上一个关于文件系统，一个关于流式生成，实际在解决同一个问题：长动作要么在一开始就能定好条件，要么在过程中能叫停。",{},"\u002F2026-08-16",{"title":533,"description":915},"2026-08-16-让会话记住目录让回答能停下","两个看似无关的功能：把工作目录从单次工具调用提升为会话级能力；让正在生成的回答可以中途停止并继续。共同点是给长动作加上用户的控制权。",[922,923,924,925,926,927],"桌面端","会话状态","文件系统","路径校验","流式生成","中断","jiUeJkYnicdLCfFjfh09Sim0_nWpiER2NhMrT2FeRKQ",{"id":930,"title":931,"body":932,"column":513,"date":1780,"description":936,"extension":515,"hero_image":516,"meta":1781,"navigation":518,"path":1782,"seo":1783,"series_id":516,"severity":516,"stem":1784,"summary":1785,"tags":1786,"__hash__":1791},"posts\u002F2026-07-07-桌面端只做壳不装页面.md","桌面端只做壳，不装页面",{"type":7,"value":933,"toc":1770},[934,937,940,947,951,954,964,1095,1101,1120,1135,1154,1168,1172,1178,1187,1193,1197,1203,1209,1212,1218,1293,1296,1299,1314,1317,1333,1340,1363,1366,1370,1373,1383,1390,1407,1410,1513,1519,1535,1538,1545,1549,1552,1577,1583,1593,1599,1668,1675,1685,1704,1707,1711,1718,1725,1728,1732,1735,1741,1744,1747,1751,1754,1757,1764,1767],[10,935,936],{},"Windows 客户端有两种做法。",[10,938,939],{},"一种是把前端打进安装包，装完之后本地有一份完整页面。另一种是桌面端只做壳，窗口加载远端地址。这条线选了后者。",[10,941,942,943,946],{},"选择的结果是：7 月 7 日一天发了两个版本（1.0.19、1.0.20），两次都只动了 ",[32,944,945],{},"apps\u002Fdesktop"," 自己的代码。",[14,948,950],{"id":949},"一壳里没有页面","一、壳里没有页面",[10,952,953],{},"最直接的证据是渲染进程的产物。",[10,955,956,959,960,963],{},[32,957,958],{},"electron-vite"," 的渲染进程配置指向 ",[32,961,962],{},"src\u002Frenderer\u002Findex.html","，而这个文件全部内容是：",[71,965,969],{"className":966,"code":967,"language":968,"meta":76,"style":76},"language-html shiki shiki-themes github-light github-dark","\u003C!DOCTYPE html>\n\u003Chtml>\n\u003Chead>\n  \u003Cmeta charset=\"UTF-8\" \u002F>\n  \u003Ctitle>创玩猩球\u003C\u002Ftitle>\n\u003C\u002Fhead>\n\u003Cbody>\n  \u003Cdiv id=\"app\">Loading...\u003C\u002Fdiv>\n\u003C\u002Fbody>\n\u003C\u002Fhtml>\n","html",[32,970,971,986,994,1003,1023,1037,1046,1055,1077,1086],{"__ignoreMap":76},[80,972,973,976,980,983],{"class":82,"line":83},[80,974,975],{"class":159},"\u003C!",[80,977,979],{"class":978},"s9eBZ","DOCTYPE",[80,981,982],{"class":97}," html",[80,984,985],{"class":159},">\n",[80,987,988,990,992],{"class":82,"line":94},[80,989,390],{"class":159},[80,991,968],{"class":978},[80,993,985],{"class":159},[80,995,996,998,1001],{"class":82,"line":111},[80,997,390],{"class":159},[80,999,1000],{"class":978},"head",[80,1002,985],{"class":159},[80,1004,1005,1008,1011,1014,1017,1020],{"class":82,"line":126},[80,1006,1007],{"class":159},"  \u003C",[80,1009,1010],{"class":978},"meta",[80,1012,1013],{"class":97}," charset",[80,1015,1016],{"class":159},"=",[80,1018,1019],{"class":90},"\"UTF-8\"",[80,1021,1022],{"class":159}," \u002F>\n",[80,1024,1025,1027,1030,1033,1035],{"class":82,"line":139},[80,1026,1007],{"class":159},[80,1028,1029],{"class":978},"title",[80,1031,1032],{"class":159},">创玩猩球\u003C\u002F",[80,1034,1029],{"class":978},[80,1036,985],{"class":159},[80,1038,1039,1042,1044],{"class":82,"line":169},[80,1040,1041],{"class":159},"\u003C\u002F",[80,1043,1000],{"class":978},[80,1045,985],{"class":159},[80,1047,1048,1050,1053],{"class":82,"line":189},[80,1049,390],{"class":159},[80,1051,1052],{"class":978},"body",[80,1054,985],{"class":159},[80,1056,1057,1059,1062,1065,1067,1070,1073,1075],{"class":82,"line":424},[80,1058,1007],{"class":159},[80,1060,1061],{"class":978},"div",[80,1063,1064],{"class":97}," id",[80,1066,1016],{"class":159},[80,1068,1069],{"class":90},"\"app\"",[80,1071,1072],{"class":159},">Loading...\u003C\u002F",[80,1074,1061],{"class":978},[80,1076,985],{"class":159},[80,1078,1080,1082,1084],{"class":82,"line":1079},9,[80,1081,1041],{"class":159},[80,1083,1052],{"class":978},[80,1085,985],{"class":159},[80,1087,1089,1091,1093],{"class":82,"line":1088},10,[80,1090,1041],{"class":159},[80,1092,968],{"class":978},[80,1094,985],{"class":159},[10,1096,1097,1098,606],{},"没有 ",[32,1099,1100],{},"\u003Cscript>",[10,1102,1103,1104,1107,1108,1111,1112,1115,1116,1119],{},"构建产物三个文件：",[32,1105,1106],{},"out\u002Fmain\u002Findex.js","（884K）、",[32,1109,1110],{},"out\u002Fpreload\u002Findex.cjs","（4K）、",[32,1113,1114],{},"out\u002Frenderer\u002Findex.html","（",[213,1117,1118],{},"147 字节","）。",[10,1121,1122,1123,1126,1127,1130,1131,1134],{},"全仓搜索 ",[32,1124,1125],{},"loadFile"," \u002F ",[32,1128,1129],{},"file:\u002F\u002F","，",[32,1132,1133],{},"apps\u002Fdesktop\u002Fsrc\u002F"," 下没有任何一处加载本地 renderer。主窗口创建完就一件事：",[71,1136,1138],{"className":651,"code":1137,"language":653,"meta":76,"style":76},"void win.loadURL(cfg.webUrl);\n",[32,1139,1140],{"__ignoreMap":76},[80,1141,1142,1145,1148,1151],{"class":82,"line":83},[80,1143,1144],{"class":146},"void",[80,1146,1147],{"class":159}," win.",[80,1149,1150],{"class":97},"loadURL",[80,1152,1153],{"class":159},"(cfg.webUrl);\n",[10,1155,1156,1157,1160,1161,1160,1164,1167],{},"打包配置里的文件白名单也只列了三项：",[32,1158,1159],{},"out\u002F**\u002F*","、",[32,1162,1163],{},"resources\u002F**\u002F*",[32,1165,1166],{},"package.json","。前端产物不在其中——不是通过某个「排除 web」的配置项排除掉的，是从来没被放进来过。",[14,1169,1171],{"id":1170},"二换来的东西","二、换来的东西",[10,1173,1174,1177],{},[213,1175,1176],{},"web 发版不动桌面端。"," 页面改动走云端发版，用户下次打开就是新版本，不需要装任何东西。这也是 7 月 7 日能一天发两版的原因：两次改动都只涉及桌面端自己的代码。",[10,1179,1180,1183,1184,1186],{},[213,1181,1182],{},"更新包小。"," OTA 只依赖 ",[32,1185,945],{}," 源码加一个协议包。更新包的内容就是主进程、预加载、几个本地二进制。",[10,1188,1189,1192],{},[213,1190,1191],{},"两端的版本可以独立走。"," 没有「前端必须等桌面端一起发」这种依赖。",[14,1194,1196],{"id":1195},"三代价","三、代价",[10,1198,1199,1202],{},[213,1200,1201],{},"离线完全不可用。"," 桌面端的 README 里，「暂未实现」清单下有一行：",[71,1204,1207],{"className":1205,"code":1206,"language":590},[588],"- 离线模式与本地回放\n",[32,1208,1206],{"__ignoreMap":76},[10,1210,1211],{},"没有本地缓存页、没有离线兜底。断网时窗口是空的。",[10,1213,1214,1217],{},[213,1215,1216],{},"首屏依赖网络。"," 主窗口的加载失败处理只做了一件事——标记这次启动是成功的：",[71,1219,1221],{"className":651,"code":1220,"language":653,"meta":76,"style":76},"win.webContents.on(\"did-fail-load\", (_e, _code, _desc, _url, isMainFrame) => {\n  if (isMainFrame) launchGuard.markLaunchSucceeded();\n});\n",[32,1222,1223,1274,1288],{"__ignoreMap":76},[80,1224,1225,1228,1231,1234,1237,1240,1244,1247,1250,1252,1255,1257,1260,1262,1265,1268,1271],{"class":82,"line":83},[80,1226,1227],{"class":159},"win.webContents.",[80,1229,1230],{"class":97},"on",[80,1232,1233],{"class":159},"(",[80,1235,1236],{"class":90},"\"did-fail-load\"",[80,1238,1239],{"class":159},", (",[80,1241,1243],{"class":1242},"s4XuR","_e",[80,1245,1246],{"class":159},", ",[80,1248,1249],{"class":1242},"_code",[80,1251,1246],{"class":159},[80,1253,1254],{"class":1242},"_desc",[80,1256,1246],{"class":159},[80,1258,1259],{"class":1242},"_url",[80,1261,1246],{"class":159},[80,1263,1264],{"class":1242},"isMainFrame",[80,1266,1267],{"class":159},") ",[80,1269,1270],{"class":146},"=>",[80,1272,1273],{"class":159}," {\n",[80,1275,1276,1279,1282,1285],{"class":82,"line":94},[80,1277,1278],{"class":146},"  if",[80,1280,1281],{"class":159}," (isMainFrame) launchGuard.",[80,1283,1284],{"class":97},"markLaunchSucceeded",[80,1286,1287],{"class":159},"();\n",[80,1289,1290],{"class":82,"line":111},[80,1291,1292],{"class":159},"});\n",[10,1294,1295],{},"这个设计的用意是区分「渲染进程起不来」和「网页加载失败」：网页加载失败说明沙箱正常，不该触发降级。但它也意味着主窗口没有错误页——加载失败时用户看到的是一块白窗口。",[10,1297,1298],{},"对比之下，agent 的内置浏览器有兜底页，注释写得很清楚：",[71,1300,1302],{"className":651,"code":1301,"language":653,"meta":76,"style":76},"\u002F\u002F 加载失败\u002F渲染崩溃时展示的兜底页：把失败原因和 URL 明明白白写出来，\n\u002F\u002F 取代过去那块「无提示纯白窗口」。\n",[32,1303,1304,1309],{"__ignoreMap":76},[80,1305,1306],{"class":82,"line":83},[80,1307,1308],{"class":107},"\u002F\u002F 加载失败\u002F渲染崩溃时展示的兜底页：把失败原因和 URL 明明白白写出来，\n",[80,1310,1311],{"class":82,"line":94},[80,1312,1313],{"class":107},"\u002F\u002F 取代过去那块「无提示纯白窗口」。\n",[10,1315,1316],{},"主窗口这一条还没补。",[10,1318,1319,1322,1323,1160,1326,1160,1329,1332],{},[213,1320,1321],{},"两端版本要各自维护兼容边界。"," 这条是最不确定的。目前桌面端和 web 之间只有三个地址契约（",[32,1324,1325],{},"webUrl",[32,1327,1328],{},"apiBase",[32,1330,1331],{},"wsUrl","），没有版本号交换，也没有「桌面端版本过低就拒绝服务」的门槛。",[10,1334,1335,1336,1339],{},"设备注册时会上报 ",[32,1337,1338],{},"appVersion","，云端落库——但它的用途是记录，不是校验。兼容靠的是「能力清单 + 可选字段」：老版本不带某个字段时，新接口按缺省处理。协议包里有对应的测试：",[71,1341,1343],{"className":651,"code":1342,"language":653,"meta":76,"style":76},"it(\"没有工作目录时不带该字段，老桌面端行为不变\", ...)\n",[32,1344,1345],{"__ignoreMap":76},[80,1346,1347,1350,1352,1355,1357,1360],{"class":82,"line":83},[80,1348,1349],{"class":97},"it",[80,1351,1233],{"class":159},[80,1353,1354],{"class":90},"\"没有工作目录时不带该字段，老桌面端行为不变\"",[80,1356,1246],{"class":159},[80,1358,1359],{"class":146},"...",[80,1361,1362],{"class":159},")\n",[10,1364,1365],{},"测试清单里有一条 P1 是「旧桌面端连新后端不崩」。这条列在那里本身就说明它还没有被系统验证过。",[14,1367,1369],{"id":1368},"四壳里真正装的是什么","四、壳里真正装的是什么",[10,1371,1372],{},"如果桌面端什么都不装，它就没必要存在。装的是三类本地运行时。",[10,1374,1375,1378,1379,1382],{},[213,1376,1377],{},"Python 运行时。"," 打包一份 Python 3.12.7 的 embed 版本，让 AI 的 ",[32,1380,1381],{},"python xxx.py"," 在任何客户机器上都能跑，不需要客户自己装 Python。",[10,1384,1385,1386,1389],{},"它必须走 ",[32,1387,1388],{},"extraResources"," 而不是进 asar，配置里写了原因：",[71,1391,1395],{"className":1392,"code":1393,"language":1394,"meta":76,"style":76},"language-js shiki shiki-themes github-light github-dark","\u002F\u002F 自带 Python 运行时：二进制必须走 extraResources（解压到 app\u002Fresources 下、不进 asar 才能执行）。\n\u002F\u002F 由 scripts\u002Ffetch-pyruntime.mjs 预先下载到 resources\u002Fpyruntime\u002F\u003C平台>\u002F。\n","js",[32,1396,1397,1402],{"__ignoreMap":76},[80,1398,1399],{"class":82,"line":83},[80,1400,1401],{"class":107},"\u002F\u002F 自带 Python 运行时：二进制必须走 extraResources（解压到 app\u002Fresources 下、不进 asar 才能执行）。\n",[80,1403,1404],{"class":82,"line":94},[80,1405,1406],{"class":107},"\u002F\u002F 由 scripts\u002Ffetch-pyruntime.mjs 预先下载到 resources\u002Fpyruntime\u002F\u003C平台>\u002F。\n",[10,1408,1409],{},"运行时目录从安装目录解析，也不可能从网络取：",[71,1411,1413],{"className":651,"code":1412,"language":653,"meta":76,"style":76},"const base = join(resourcesPath, \"pyruntime\", sub);\nconst exe = process.platform === \"win32\" ? join(base, \"python.exe\") : join(base, \"bin\", \"python3\");\nif (!existsSync(exe)) return null;\n",[32,1414,1415,1438,1487],{"__ignoreMap":76},[80,1416,1417,1420,1423,1426,1429,1432,1435],{"class":82,"line":83},[80,1418,1419],{"class":146},"const",[80,1421,1422],{"class":86}," base",[80,1424,1425],{"class":146}," =",[80,1427,1428],{"class":97}," join",[80,1430,1431],{"class":159},"(resourcesPath, ",[80,1433,1434],{"class":90},"\"pyruntime\"",[80,1436,1437],{"class":159},", sub);\n",[80,1439,1440,1442,1445,1447,1450,1453,1456,1459,1461,1464,1467,1469,1472,1474,1476,1479,1481,1484],{"class":82,"line":94},[80,1441,1419],{"class":146},[80,1443,1444],{"class":86}," exe",[80,1446,1425],{"class":146},[80,1448,1449],{"class":159}," process.platform ",[80,1451,1452],{"class":146},"===",[80,1454,1455],{"class":90}," \"win32\"",[80,1457,1458],{"class":146}," ?",[80,1460,1428],{"class":97},[80,1462,1463],{"class":159},"(base, ",[80,1465,1466],{"class":90},"\"python.exe\"",[80,1468,1267],{"class":159},[80,1470,1471],{"class":146},":",[80,1473,1428],{"class":97},[80,1475,1463],{"class":159},[80,1477,1478],{"class":90},"\"bin\"",[80,1480,1246],{"class":159},[80,1482,1483],{"class":90},"\"python3\"",[80,1485,1486],{"class":159},");\n",[80,1488,1489,1492,1495,1498,1501,1504,1507,1510],{"class":82,"line":111},[80,1490,1491],{"class":146},"if",[80,1493,1494],{"class":159}," (",[80,1496,1497],{"class":146},"!",[80,1499,1500],{"class":97},"existsSync",[80,1502,1503],{"class":159},"(exe)) ",[80,1505,1506],{"class":146},"return",[80,1508,1509],{"class":86}," null",[80,1511,1512],{"class":159},";\n",[10,1514,1515,1518],{},[213,1516,1517],{},"VC++ 运行库。"," 安装期静默装，已装则跳过，避免多余的 UAC 弹窗。",[10,1520,1521,1524,1525,1160,1528,1160,1531,1534],{},[213,1522,1523],{},"Electron 自己的运行库。"," 有一个设计文档专门钉了一份「核心文件清单」：主程序 exe、",[32,1526,1527],{},"ffmpeg.dll",[32,1529,1530],{},"chrome_elf.dll",[32,1532,1533],{},"resources\u002Fapp.asar","，检查发生在安装包解包完成之后、启动新程序之前。",[10,1536,1537],{},"这份清单来自一次真实故障：用户从旧版自动更新之后，启动时报「找不到 ffmpeg.dll」。安装包里其实有这个文件——错误发生在 Electron 主程序加载阶段，那时应用自己的错误处理还没机会运行，所以只能靠安装器检查。",[10,1539,1540,1541,1544],{},"这三类东西的共同点是",[213,1542,1543],{},"它们不能从远端加载","。Python 解释器、运行库 dll、Electron 自身，都必须在本地文件系统上。这才是壳存在的理由，不是为了渲染页面。",[14,1546,1548],{"id":1547},"五发版链路","五、发版链路",[10,1550,1551],{},"四个步骤，脚本里编号就是四段：",[71,1553,1555],{"className":73,"code":1554,"language":75,"meta":76,"style":76},"# [1\u002F4] 同步源码（只覆盖源码，保留 node_modules \u002F pyruntime \u002F dist）\n# [2\u002F4] 构建 dist:win（含 pyruntime、签名）\n# [3\u002F4] 校验 latest.yml 版本 == $VERSION\n# [4\u002F4] 上传 OTA\n",[32,1556,1557,1562,1567,1572],{"__ignoreMap":76},[80,1558,1559],{"class":82,"line":83},[80,1560,1561],{"class":107},"# [1\u002F4] 同步源码（只覆盖源码，保留 node_modules \u002F pyruntime \u002F dist）\n",[80,1563,1564],{"class":82,"line":94},[80,1565,1566],{"class":107},"# [2\u002F4] 构建 dist:win（含 pyruntime、签名）\n",[80,1568,1569],{"class":82,"line":111},[80,1570,1571],{"class":107},"# [3\u002F4] 校验 latest.yml 版本 == $VERSION\n",[80,1573,1574],{"class":82,"line":126},[80,1575,1576],{"class":107},"# [4\u002F4] 上传 OTA\n",[10,1578,1579,1582],{},[213,1580,1581],{},"为什么是「scp 源码到构建机」而不是在构建机上 clone。"," 构建机无法非交互拉取私有仓库（HTTPS 没有 tty，SSH 公钥没授权），所以从工作机把源码推过去，在一个「依赖已装、运行时齐全、密钥在系统环境变量」的固定构建目录里出包。",[10,1584,1585,1588,1589,1592],{},[213,1586,1587],{},"为什么走镜像。"," electron-builder 在国内拉 GitHub releases 会超时，报的是 ",[32,1590,1591],{},"Timeout awaiting 'request' for 600000ms","。构建前设两个镜像环境变量绕开。",[10,1594,1595,1598],{},[213,1596,1597],{},"版本校验有两道。"," 第一道在脚本里，出包之后比对产物清单里的版本和目标版本，不符就中止，不上传：",[71,1600,1602],{"className":73,"code":1601,"language":75,"meta":76,"style":76},"[[ \"$ONVER\" == \"$VERSION\" ]] || { echo \"产物版本($ONVER) != 目标($VERSION)，中止上传。\" >&2; exit 1; }\n",[32,1603,1604],{"__ignoreMap":76},[80,1605,1606,1609,1612,1615,1617,1620,1623,1626,1628,1631,1634,1637,1640,1643,1645,1648,1650,1653,1656,1659,1662,1665],{"class":82,"line":83},[80,1607,1608],{"class":159},"[[ ",[80,1610,1611],{"class":90},"\"",[80,1613,1614],{"class":159},"$ONVER",[80,1616,1611],{"class":90},[80,1618,1619],{"class":146}," ==",[80,1621,1622],{"class":90}," \"",[80,1624,1625],{"class":159},"$VERSION",[80,1627,1611],{"class":90},[80,1629,1630],{"class":159}," ]] ",[80,1632,1633],{"class":146},"||",[80,1635,1636],{"class":159}," { ",[80,1638,1639],{"class":86},"echo",[80,1641,1642],{"class":90}," \"产物版本(",[80,1644,1614],{"class":159},[80,1646,1647],{"class":90},") != 目标(",[80,1649,1625],{"class":159},[80,1651,1652],{"class":90},")，中止上传。\"",[80,1654,1655],{"class":146}," >&2",[80,1657,1658],{"class":159},"; ",[80,1660,1661],{"class":86},"exit",[80,1663,1664],{"class":86}," 1",[80,1666,1667],{"class":159},"; }\n",[10,1669,1670,1671,1674],{},"第二道在产物选择里：只允许当前版本的三件套（安装包、blockmap、清单）上传，缺任何一个直接抛错。这一道是为了处理「",[32,1672,1673],{},"dist"," 目录不清、旧版安装包残留」的情况。",[10,1676,1677,1680,1681,1684],{},[213,1678,1679],{},"客户端侧。"," 更新走 ",[32,1682,1683],{},"electron-updater"," 的 generic provider，指向一个静态目录：",[71,1686,1688],{"className":1392,"code":1687,"language":1394,"meta":76,"style":76},"publish: [{ provider: \"generic\", url: updateUrl }],\n",[32,1689,1690],{"__ignoreMap":76},[80,1691,1692,1695,1698,1701],{"class":82,"line":83},[80,1693,1694],{"class":97},"publish",[80,1696,1697],{"class":159},": [{ provider: ",[80,1699,1700],{"class":90},"\"generic\"",[80,1702,1703],{"class":159},", url: updateUrl }],\n",[10,1705,1706],{},"启动时拉清单文件、比较版本、下载、静默安装。",[14,1708,1710],{"id":1709},"六版本号的唯一来源","六、版本号的唯一来源",[10,1712,1713,1714,1717],{},"版本号只在 ",[32,1715,1716],{},"apps\u002Fdesktop\u002Fpackage.json"," 一处。产物名和清单里的版本都由它决定。",[10,1719,1720,1721,1724],{},"与之配套的一条纪律：",[213,1722,1723],{},"绝不能复用旧版本号","。客户端判断「要不要更新」靠的是版本比较，版本号相同就等于没有新版本，用户那边什么都不会发生——而这种失败是静默的，服务端看到的是上传成功。",[10,1726,1727],{},"脚本的这条判断还有一个前置的护栏：工作区有未提交的已跟踪改动时拒绝发版。因为发出去的是工作区源码，不是某个 commit。",[14,1729,1731],{"id":1730},"七一处没对齐的地方","七、一处没对齐的地方",[10,1733,1734],{},"签名。桌面端 README 写的是：",[71,1736,1739],{"className":1737,"code":1738,"language":590},[588],"当前 Windows 安装器未接入 CA 代码签名，用户首次安装可能看到 SmartScreen 提示。\n拿到证书后再通过 WIN_CSC_LINK \u002F WIN_CSC_KEY_PASSWORD 接入签名流程。\n",[32,1740,1738],{"__ignoreMap":76},[10,1742,1743],{},"而发版 skill 里写的是构建机已配 signtool、产出无 SmartScreen 强拦。两处说法不一致，仓库里也搜不到任何签名实现——那两个环境变量只出现在这一行文档里。",[10,1745,1746],{},"这两句话都写在 7 月上旬，中间没有改动过配置。真实状态是：安装包没有 CA 签名，首次安装会出现 SmartScreen 提示。文档里那句「无强拦」应该改掉。",[14,1748,1750],{"id":1749},"八这条路线适合什么","八、这条路线适合什么",[10,1752,1753],{},"选它之前要接受三个前提：用户始终在线、接受首次打开有网络等待、接受两端各自演进。",[10,1755,1756],{},"换来的是发版节奏的自由：改一个页面不需要用户装新版本，改壳里的东西不需要等前端一起走。前者让迭代变快，后者让迭代变危险——OTA 是推给所有人的，很难回滚。",[10,1758,1759,1760,1763],{},"所以这条路线上的纪律集中在一点：",[213,1761,1762],{},"发版前的校验要做足","。两道版本门禁、一条工作区护栏、一个只构建不上传的选项。四道关卡守的都是同一件事：不要把一个身份不明的包推给全部用户。",[10,1765,1766],{},"有一处边界想清楚过：安装包必须包含哪些文件。这份清单不是设计出来的，是被一次「找不到 ffmpeg.dll」的故障逼出来的。清单上的文件缺任何一个，安装器就在启动新程序之前停下来——因为在那个时刻，应用自己的任何补救代码都还没机会运行。",[504,1768,1769],{},"html pre.shiki code .sVt8B, html code.shiki .sVt8B{--shiki-default:#24292E;--shiki-dark:#E1E4E8}html pre.shiki code .s9eBZ, html code.shiki .s9eBZ{--shiki-default:#22863A;--shiki-dark:#85E89D}html pre.shiki code .sScJk, html code.shiki .sScJk{--shiki-default:#6F42C1;--shiki-dark:#B392F0}html pre.shiki code .sZZnC, html code.shiki .sZZnC{--shiki-default:#032F62;--shiki-dark:#9ECBFF}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 .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html pre.shiki code .szBVR, html code.shiki .szBVR{--shiki-default:#D73A49;--shiki-dark:#F97583}html pre.shiki code .s4XuR, html code.shiki .s4XuR{--shiki-default:#E36209;--shiki-dark:#FFAB70}html pre.shiki code .sJ8bj, html code.shiki .sJ8bj{--shiki-default:#6A737D;--shiki-dark:#6A737D}html pre.shiki code .sj4cs, html code.shiki .sj4cs{--shiki-default:#005CC5;--shiki-dark:#79B8FF}",{"title":76,"searchDepth":94,"depth":94,"links":1771},[1772,1773,1774,1775,1776,1777,1778,1779],{"id":949,"depth":94,"text":950},{"id":1170,"depth":94,"text":1171},{"id":1195,"depth":94,"text":1196},{"id":1368,"depth":94,"text":1369},{"id":1547,"depth":94,"text":1548},{"id":1709,"depth":94,"text":1710},{"id":1730,"depth":94,"text":1731},{"id":1749,"depth":94,"text":1750},"2026-07-07",{},"\u002F2026-07-07",{"title":931,"description":936},"2026-07-07-桌面端只做壳不装页面","桌面端只加载远端地址，不把前端打进安装包。这条路线换来了独立的发版节奏，代价是离线不可用、首屏依赖网络，以及壳里必须自带的那部分本地运行时。",[1787,922,525,1788,1789,1790],"Electron","electron-builder","打包","版本管理","ElNTxouudoGHuSXPhaMwM-u23sOVeK4HA3GAGBnmito",{"id":4,"title":5,"body":1793,"column":513,"date":514,"description":12,"extension":515,"hero_image":516,"meta":2127,"navigation":518,"path":519,"seo":2128,"series_id":516,"severity":516,"stem":521,"summary":522,"tags":2129,"__hash__":529},{"type":7,"value":1794,"toc":2121},[1795,1797,1799,1801,1803,1815,1821,1823,1825,1827,1829,1923,1927,1929,1933,1953,1955,1959,1961,1963,1965,1969,2019,2025,2073,2075,2083,2087,2089,2099,2103,2105,2107,2115,2117,2119],[10,1796,12],{},[14,1798,17],{"id":16},[10,1800,20],{},[10,1802,23],{},[25,1804,1805,1809],{},[28,1806,30,1807],{},[32,1808,34],{},[28,1810,37,1811,41,1813,45],{},[32,1812,40],{},[32,1814,44],{},[10,1816,48,1817,52,1819,56],{},[32,1818,51],{},[32,1820,55],{},[10,1822,59],{},[14,1824,63],{"id":62},[10,1826,66],{},[10,1828,69],{},[71,1830,1831],{"className":73,"code":74,"language":75,"meta":76,"style":76},[32,1832,1833,1839,1849,1859,1869,1889,1905],{"__ignoreMap":76},[80,1834,1835,1837],{"class":82,"line":83},[80,1836,87],{"class":86},[80,1838,91],{"class":90},[80,1840,1841,1843,1845,1847],{"class":82,"line":94},[80,1842,98],{"class":97},[80,1844,101],{"class":90},[80,1846,104],{"class":86},[80,1848,108],{"class":107},[80,1850,1851,1853,1855,1857],{"class":82,"line":111},[80,1852,114],{"class":86},[80,1854,117],{"class":86},[80,1856,120],{"class":90},[80,1858,123],{"class":107},[80,1860,1861,1863,1865,1867],{"class":82,"line":126},[80,1862,114],{"class":86},[80,1864,117],{"class":86},[80,1866,133],{"class":90},[80,1868,136],{"class":107},[80,1870,1871,1873,1875,1877,1879,1881,1883,1885,1887],{"class":82,"line":139},[80,1872,114],{"class":86},[80,1874,117],{"class":86},[80,1876,147],{"class":146},[80,1878,150],{"class":90},[80,1880,153],{"class":90},[80,1882,156],{"class":90},[80,1884,160],{"class":159},[80,1886,163],{"class":146},[80,1888,166],{"class":107},[80,1890,1891,1893,1895,1897,1899,1901,1903],{"class":82,"line":169},[80,1892,114],{"class":86},[80,1894,174],{"class":86},[80,1896,147],{"class":146},[80,1898,179],{"class":90},[80,1900,160],{"class":159},[80,1902,163],{"class":146},[80,1904,186],{"class":107},[80,1906,1907,1909,1911,1913,1915,1917,1919,1921],{"class":82,"line":189},[80,1908,114],{"class":86},[80,1910,174],{"class":86},[80,1912,147],{"class":146},[80,1914,198],{"class":90},[80,1916,201],{"class":90},[80,1918,160],{"class":159},[80,1920,163],{"class":146},[80,1922,208],{"class":107},[10,1924,211,1925,216],{},[213,1926,215],{},[10,1928,219],{},[10,1930,222,1931,226],{},[32,1932,225],{},[228,1934,1935,1937,1939,1941,1943,1945,1947,1949,1951],{},[28,1936,232],{},[28,1938,235],{},[28,1940,238],{},[28,1942,241],{},[28,1944,244],{},[28,1946,247],{},[28,1948,250],{},[28,1950,253],{},[28,1952,256],{},[10,1954,259],{},[10,1956,262,1957,266],{},[213,1958,265],{},[14,1960,270],{"id":269},[10,1962,273],{},[10,1964,276],{},[10,1966,279,1967,283],{},[32,1968,282],{},[71,1970,1971],{"className":73,"code":286,"language":75,"meta":76,"style":76},[32,1972,1973,1977],{"__ignoreMap":76},[80,1974,1975],{"class":82,"line":83},[80,1976,293],{"class":107},[80,1978,1979,1981,1983,1985,1987,1989,1991,1993,1995,1997,1999,2001,2003,2005,2007,2009,2011,2013,2015,2017],{"class":82,"line":94},[80,1980,298],{"class":97},[80,1982,301],{"class":90},[80,1984,304],{"class":90},[80,1986,307],{"class":90},[80,1988,310],{"class":90},[80,1990,313],{"class":90},[80,1992,316],{"class":90},[80,1994,319],{"class":90},[80,1996,322],{"class":90},[80,1998,325],{"class":90},[80,2000,328],{"class":90},[80,2002,331],{"class":90},[80,2004,334],{"class":90},[80,2006,337],{"class":90},[80,2008,340],{"class":90},[80,2010,343],{"class":90},[80,2012,325],{"class":90},[80,2014,348],{"class":90},[80,2016,304],{"class":90},[80,2018,353],{"class":90},[10,2020,356,2021,360,2023,364],{},[32,2022,359],{},[213,2024,363],{},[71,2026,2027],{"className":73,"code":367,"language":75,"meta":76,"style":76},[32,2028,2029,2033,2037,2053,2057,2061,2065,2069],{"__ignoreMap":76},[80,2030,2031],{"class":82,"line":83},[80,2032,374],{"class":107},[80,2034,2035],{"class":82,"line":94},[80,2036,379],{"class":107},[80,2038,2039,2041,2043,2045,2047,2049,2051],{"class":82,"line":111},[80,2040,384],{"class":97},[80,2042,387],{"class":90},[80,2044,390],{"class":146},[80,2046,393],{"class":90},[80,2048,396],{"class":159},[80,2050,163],{"class":146},[80,2052,401],{"class":90},[80,2054,2055],{"class":82,"line":126},[80,2056,406],{"class":90},[80,2058,2059],{"class":82,"line":139},[80,2060,411],{"class":90},[80,2062,2063],{"class":82,"line":169},[80,2064,416],{"class":90},[80,2066,2067],{"class":82,"line":189},[80,2068,421],{"class":90},[80,2070,2071],{"class":82,"line":424},[80,2072,427],{"class":90},[10,2074,430],{},[10,2076,433,2077,437,2079,441,2081,445],{},[32,2078,436],{},[213,2080,440],{},[32,2082,444],{},[10,2084,448,2085,452],{},[213,2086,451],{},[10,2088,455],{},[457,2090,2091],{},[10,2092,461,2093,465,2095,469,2097,472],{},[32,2094,464],{},[32,2096,468],{},[32,2098,464],{},[10,2100,475,2101,479],{},[213,2102,478],{},[14,2104,482],{"id":482},[10,2106,485],{},[25,2108,2109,2111,2113],{},[28,2110,490],{},[28,2112,493],{},[28,2114,496],{},[10,2116,499],{},[10,2118,502],{},[504,2120,506],{},{"title":76,"searchDepth":94,"depth":94,"links":2122},[2123,2124,2125,2126],{"id":16,"depth":94,"text":17},{"id":62,"depth":94,"text":63},{"id":269,"depth":94,"text":270},{"id":482,"depth":94,"text":482},{},{"title":5,"description":12},[524,525,526,527,528],1789212573548]