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