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