[{"data":1,"prerenderedAt":5654},["ShallowReactive",2],{"\u002F2026-04-21-tauri2electron":3,"\u002F2026-04-21-tauri2electron-rel":3275},{"id":4,"title":5,"body":6,"column":3260,"date":3261,"description":12,"extension":3262,"hero_image":3263,"meta":3264,"navigation":999,"path":3265,"seo":3266,"series_id":3263,"severity":3263,"stem":3267,"summary":3268,"tags":3269,"__hash__":3274},"posts\u002F2026-04-21-Tauri2而不是Electron四个理由.md","Electron 要 60MB——在 U 盘上这不成比例",{"type":7,"value":8,"toc":3228},"minimark",[9,13,17,20,23,27,32,35,38,54,57,60,63,67,70,73,176,179,254,257,261,264,267,278,281,284,288,292,295,300,303,308,311,316,323,328,331,334,337,340,343,346,352,434,438,441,447,450,456,471,474,488,494,505,511,526,529,533,536,542,548,551,662,665,670,673,724,727,783,786,821,824,828,831,834,839,842,853,858,861,872,875,880,941,944,947,950,955,958,1177,1182,1185,1322,1327,1330,1338,1341,1344,1355,1358,1361,1455,1458,1464,1475,1479,1482,1496,1500,1504,1507,1512,1515,1621,1624,1639,1642,1647,1650,1846,1849,1866,1869,1874,1877,2005,2008,2013,2016,2073,2076,2087,2091,2094,2148,2151,2154,2157,2160,2166,2194,2200,2223,2226,2229,2232,2236,2239,2244,2270,2273,2279,2282,2296,2299,2310,2315,2323,2327,2330,2335,2346,2351,2354,2438,2441,2452,2457,2471,2476,2679,2682,2696,2700,2703,2706,2775,2778,2783,2788,2791,2805,2808,2813,2816,2821,2824,2829,2832,2835,2846,2849,3010,3016,3119,3122,3126,3129,3134,3148,3153,3179,3182,3187,3198,3203,3211,3216,3224],[10,11,12],"p",{},"便携 AI 助手需要一个配置中心——这是常见的桌面应用需求。选型时 Electron 是行业默认答案，却因为四个约束选了 Tauri 2。这篇文章把决策过程和权衡写清楚。",[14,15,16],"h2",{"id":16},"约束背景",[10,18,19],{},"整个产品装在 U 盘里交付。U 盘容量有限，内置常驻启动器（Launcher）已占据部分空间；配置中心是按需启动的配套工具，不能无限膨胀。同时，Launcher 用 Rust 写的，已有完整的进程管理、文件 I\u002FO、Windows API 调用能力——如果前端框架能复用这套基础设施，可以省掉大量重复实现。",[10,21,22],{},"这两个条件合在一起，决定了选型的方向。不是\"选最新的框架\"，而是\"在约束下找最优解\"。",[14,24,26],{"id":25},"理由一体积约束10-倍差距的现实","理由一：体积约束——10 倍差距的现实",[28,29,31],"h3",{"id":30},"electron-的包体积现实","Electron 的包体积现实",[10,33,34],{},"Electron 自带完整的 Chromium 浏览器。这听起来很方便（开箱即用的跨平台 WebView），但成本是无法回避的。一个最小化的 Electron 应用，构建后体积在 150MB 左右；经过压缩和精心优化后，可以缩小到 60-80MB。但这已经是压缩限制，再往下很难。",[10,36,37],{},"为什么这么大？核心原因是 Chromium 本身就是一个 100MB+ 的浏览器引擎。Chromium 包含：",[39,40,41,45,48,51],"ul",{},[42,43,44],"li",{},"渲染引擎（Blink）：处理 HTML\u002FCSS 的布局和绘制，约 30MB",[42,46,47],{},"JavaScript 引擎（V8）：JIT 编译、垃圾回收等，约 10MB",[42,49,50],{},"网络栈、媒体解码、GPU 命令处理：约 50MB",[42,52,53],{},"第三方库和字体：约 10MB",[10,55,56],{},"即使不加任何应用代码，空的 Electron 壳都要这个量级。Slack、VS Code、Figma 等大型应用都是 100MB+，人们习以为常。但这个数字对 U 盘便携应用是灾难性的。",[10,58,59],{},"问题不在于 60MB 本身大不大，而在于它要和什么东西抢空间。U 盘上还得放捆绑的 Node.js 运行时、Python、工具链、openclaw 本体和它的依赖树，往后还有会逐月增长的模型本地缓存。在这个清单里，一个配置界面占掉 60MB 是不成比例的——它是整套东西里最不该占地方的那部分。",[10,61,62],{},"更关键的是分发成本。如果用户想把产品拷到多个 U 盘，或者网络分发时需要压缩传输，每个版本都是 60MB 的包。假设产品月更新一次，一年就是 720MB 的总流量（对个人开发者或初创团队来说是明显的成本）。",[28,64,66],{"id":65},"tauri-的体积目标与实施","Tauri 的体积目标与实施",[10,68,69],{},"Tauri 不内置浏览器。它调用操作系统已有的 WebView 实现：Windows 上是 WebView2，macOS 上是 WKWebView，Linux 上是 GTK WebKit。这意味着应用本身只需打包前端资源和 Rust 后端，不需要背负一个完整浏览器引擎。",[10,71,72],{},"这个项目的 Tauri 构建目标是：",[74,75,76,92],"table",{},[77,78,79],"thead",{},[80,81,82,86,89],"tr",{},[83,84,85],"th",{},"组件",[83,87,88],{},"大小限制",[83,90,91],{},"说明",[93,94,95,107,118,129,140,151,162],"tbody",{},[80,96,97,101,104],{},[98,99,100],"td",{},"YourBrand.exe（Tauri Rust 二进制）",[98,102,103],{},"≤ 18 MB",[98,105,106],{},"包含 tokio、serde、windows-rs、tauri-runtime 等依赖",[80,108,109,112,115],{},[98,110,111],{},"Vue.js 打包输出（JS）",[98,113,114],{},"≤ 250 KB gzip",[98,116,117],{},"Vite 构建 + Tree shaking + 代码分割",[80,119,120,123,126],{},[98,121,122],{},"Tailwind CSS 样式表",[98,124,125],{},"≤ 80 KB gzip",[98,127,128],{},"JIT 编译后去除未使用的 utility class",[80,130,131,134,137],{},[98,132,133],{},"Inter 字体（4 字重）",[98,135,136],{},"≤ 60 KB × 4",[98,138,139],{},"woff2 格式本地嵌入，按需加载",[80,141,142,145,148],{},[98,143,144],{},"Material Symbols 图标集",[98,146,147],{},"≤ 80 KB",[98,149,150],{},"按页面功能子集，避免加载整个 45MB icon library",[80,152,153,156,159],{},[98,154,155],{},"业务 SVG 和静态资源",[98,157,158],{},"≤ 50 KB",[98,160,161],{},"本地 logo、icon、占位图等",[80,163,164,167,173],{},[98,165,166],{},"总计",[98,168,169],{},[170,171,172],"strong",{},"≤ 20 MB",[98,174,175],{},"包括所有资源和二进制",[10,177,178],{},"对比分析：",[74,180,181,197],{},[77,182,183],{},[80,184,185,188,191,194],{},[83,186,187],{},"场景",[83,189,190],{},"Electron",[83,192,193],{},"Tauri",[83,195,196],{},"差异",[93,198,199,213,226,240],{},[80,200,201,204,207,210],{},[98,202,203],{},"初次下载",[98,205,206],{},"60 MB",[98,208,209],{},"20 MB",[98,211,212],{},"节省 40 MB（-67%）",[80,214,215,218,221,223],{},[98,216,217],{},"装 3 个版本",[98,219,220],{},"180 MB",[98,222,206],{},[98,224,225],{},"节省 120 MB",[80,227,228,231,234,237],{},[98,229,230],{},"3 年（36 个版本）",[98,232,233],{},"2.16 GB",[98,235,236],{},"0.72 GB",[98,238,239],{},"节省 1.44 GB",[80,241,242,245,248,251],{},[98,243,244],{},"同时运行 10 个进程",[98,246,247],{},"600 MB 内存",[98,249,250],{},"50 MB 内存*",[98,252,253],{},"节省 550 MB",[10,255,256],{},"*Tauri 的内存占用是 Electron 的 1\u002F10 级别，因为没有每个进程独立的 Chromium 引擎。这在多窗口场景下优势明显。",[28,258,260],{"id":259},"webview2-的前置条件","WebView2 的前置条件",[10,262,263],{},"当然，代价是 WebView2 不一定预装。新安装的 Windows 系统或者长期未更新的老机器上，可能没有 WebView2。这时需要用户下载，大约 100MB 左右（一次性）。",[10,265,266],{},"项目中的应对是在 Launcher 侧做检测（注册表查询），如果缺失则提示用户：",[268,269,274],"pre",{"className":270,"code":272,"language":273},[271],"language-text","检测流程：\n  1. Launcher 启动时查询注册表\n     HKEY_LOCAL_MACHINE\\Software\\WOW6432Node\\Microsoft\\EdgeUpdate\\ClientState\\{F3017226...}\n  2. 读取版本号 pv 字段（如 \"123.0.0.0\"）\n  3. 若版本 >= 90，直接启动 Tauri\n  4. 若版本 \u003C 90 或不存在，弹 Win32 对话框\n     \"配置中心首次使用需下载 WebView2（约 100MB）\"\n  5. 用户点击\"立即下载\"\n     执行 webview2-bootstrap.exe（MS 官方下载器）\n  6. 下载完成自动重新启动配置中心\n","text",[275,276,272],"code",{"__ignoreMap":277},"",[10,279,280],{},"这是折衷方案：常见场景（WebView2 已装）下体积优势完全发挥；少数场景（首次装 WebView2）下推给用户一次性下载。",[10,282,283],{},"对于 U 盘产品而言，这个折衷是合理的。因为 WebView2 作为系统组件，用户装过一次后就永久有效，下一次拷贝不同版本的 openclaw 到另一个 U 盘时就不需要再装了。而 Electron 的 60MB 体积却是每个应用版本都绕不过的。",[14,285,287],{"id":286},"理由二rust-启动器代码复用","理由二：Rust 启动器代码复用",[28,289,291],{"id":290},"现有-launcher-的能力清单","现有 Launcher 的能力清单",[10,293,294],{},"这个产品的 Launcher（启动.exe）是用 Rust 从零写的，已有完整的五层堆栈，每层职责明确：",[10,296,297],{},[170,298,299],{},"Layer 1-2：环境自检与缓存初始化",[10,301,302],{},"启动时查注册表确认 WebView2 是否可用、检查 VC++ 运行库版本、探测捆绑的 Node.js，然后做 U 盘挂载点检测、建缓存目录、拉起日志系统。这些工作一次性完成，为后面几层打好基础。",[10,304,305],{},[170,306,307],{},"Layer 3：请求路由与状态维护",[10,309,310],{},"来自 WebUI、CLI 工具、其他组件的请求需要被正确路由到 Gateway。同时维护 Gateway 的状态机（启动中、运行中、故障、重启中）。这层处理的是应用的控制流。",[10,312,313],{},[170,314,315],{},"Layer 4：守护与恢复",[10,317,318,319,322],{},"定时检测 Gateway health（每 5 秒轮询 ",[275,320,321],{},"\u002Fhealth"," 端点），若发现故障则自动重启 Gateway 子进程。故障恢复后记录详细日志。这层是系统可靠性的保证。",[10,324,325],{},[170,326,327],{},"Layer 5：系统集成",[10,329,330],{},"Win32 托盘菜单（Shell_NotifyIcon）、文件原子操作（atomic rename）、进程管理（CreateProcess、互斥量、事件信号）。这层与操作系统底层交互。",[28,332,333],{"id":333},"为何代码复用很关键",[10,335,336],{},"这五层的代码都是可以被配置中心复用的。具体例子：",[10,338,339],{},"配置中心需要向 Gateway 发送请求（修改配置、查询状态），可以直接导入 Launcher 的 HTTP 代理层。不需要在前端再实现一套网络栈。",[10,341,342],{},"配置中心需要原子写入 user.yml 到 U 盘（确保部分写入不会导致配置文件损坏），可以复用 Launcher 的原子文件操作库。",[10,344,345],{},"配置中心需要通知 Launcher 某个事件已完成（比如首启引导完成），可以复用 Launcher 的进程通信机制（命名管道）。",[10,347,348,351],{},[170,349,350],{},"具体的 invoke 命令清单","（所有后端实现都可导入 Launcher 代码）：",[74,353,354,367],{},[77,355,356],{},[80,357,358,361,364],{},[83,359,360],{},"命令",[83,362,363],{},"来源 Launcher 模块",[83,365,366],{},"复用度",[93,368,369,382,395,408,421],{},[80,370,371,376,379],{},[98,372,373],{},[275,374,375],{},"gateway_request",[98,377,378],{},"launcher\u002Fsrc\u002Fgateway.rs 的 HTTP proxy",[98,380,381],{},"直接复用 reqwest client + error handling",[80,383,384,389,392],{},[98,385,386],{},[275,387,388],{},"user_yml_write",[98,390,391],{},"launcher\u002Fsrc\u002Ffs\u002Fatomic.rs + serde",[98,393,394],{},"导入 atomic_write 函数，套上 YAML schema",[80,396,397,402,405],{},[98,398,399],{},[275,400,401],{},"single_instance",[98,403,404],{},"launcher\u002Fsrc\u002Fipc\u002Fnamed_pipe.rs + launcher\u002Fsrc\u002Fwin32\u002Fmutex.rs",[98,406,407],{},"直接复用 CreateMutexW 和 pipe 逻辑",[80,409,410,415,418],{},[98,411,412],{},[275,413,414],{},"get_runtime_info",[98,416,417],{},"launcher\u002Fsrc\u002Fbootstrap.rs",[98,419,420],{},"读取 Launcher 写入的 runtime-info.json",[80,422,423,428,431],{},[98,424,425],{},[275,426,427],{},"get_brand",[98,429,430],{},"launcher\u002Fsrc\u002Fbrand.rs",[98,432,433],{},"直接导入常量定义",[28,435,437],{"id":436},"electron-vs-tauri-的架构对比","Electron vs Tauri 的架构对比",[10,439,440],{},"如果用 Electron，前端和后端分属两个不同的技术栈：",[268,442,445],{"className":443,"code":444,"language":273},[271],"Launcher (Rust 生态)\n├─ tokio async runtime\n├─ reqwest HTTP client\n├─ serde JSON parsing\n├─ fs2 file locking\n└─ windows-rs Win32 API\n\n↓ (跨语言通信，需要新的 IPC 和 native binding 层)\n\nElectron (Node.js 生态)\n├─ express 或 koa 本地 HTTP server（暴露 REST API 给前端）\n├─ node-ffi 或 N-API native module（调用 Launcher 的 Rust DLL）\n├─ node-gyp 编译脚本（为不同 Node 版本编译 native extension）\n└─ npm dependencies（400+ 个包）\n",[275,446,444],{"__ignoreMap":277},[10,448,449],{},"这个架构有多个问题：",[10,451,452,455],{},[170,453,454],{},"编译复杂性","：为了让 Electron 调用 Launcher 的 Rust 库，需要：",[457,458,459,462,465,468],"ol",{},[42,460,461],{},"把 Launcher 代码编译为 CDyLib（动态库）",[42,463,464],{},"用 N-API 包装暴露给 Node.js",[42,466,467],{},"用 node-gyp 配置编译脚本",[42,469,470],{},"在 CI 中为 Windows + Node 14\u002F16\u002F18\u002F20 等版本交叉编译",[10,472,473],{},"这意味着每个操作系统\u002FNode 版本组合都需要重新编译。Node.js 主版本升级时，整个构建流程都要调整。维护成本非常高。",[10,475,476,479,480,483,484,487],{},[170,477,478],{},"类型一致性","：一个数据结构（比如 Gateway HTTP 响应的 HealthStatus）在 Launcher 中定义为 Rust struct，在 native module 中序列化为 JSON，再在 Electron 中重新定义为 TypeScript interface。三层映射，容易出现脱同步。某个字段从 ",[275,481,482],{},"u64"," 改为 ",[275,485,486],{},"i32","，native module 中要改，TypeScript 中也要改。改漏了就是 runtime error。",[10,489,490,493],{},[170,491,492],{},"调试困难","：错误发生在 native module 与 Node.js 边界时，堆栈跟踪跨越两种语言，调试工具和资料都很少。比如 Launcher 的 HTTP 代理层在 Rust 侧发生了某个网络超时，这个异常需要：",[457,495,496,499,502],{},[42,497,498],{},"从 Rust panic 转换为 Node.js exception",[42,500,501],{},"通过 N-API 传递",[42,503,504],{},"在 Electron 前端被 catch\n这个过程中很容易丢失上下文信息。",[10,506,507,510],{},[170,508,509],{},"维护债务","：任何底层库升级都会触发重新编译。比如：",[39,512,513,520,523],{},[42,514,515,516,519],{},"更新 tokio 版本（从 1.x 升 1.y）：Rust side 简单 ",[275,517,518],{},"cargo update","，但 native module 需要重新编译和测试",[42,521,522],{},"升级 Node.js（18 → 20）：N-API 的行为改变，可能需要调整绑定代码",[42,524,525],{},"升级 Electron（25 → 26）：可能有 V8 引擎版本变化，影响 native module 兼容性",[10,527,528],{},"总之，跨语言的架构引入了显著的复杂性。",[28,530,532],{"id":531},"tauri-方案的优势","Tauri 方案的优势",[10,534,535],{},"选 Tauri 后，整个后端都是 Rust：",[268,537,540],{"className":538,"code":539,"language":273},[271],"Launcher (Rust)\n├─ tokio async runtime\n├─ reqwest HTTP client  ← 可以引入这个库\n├─ serde JSON parsing   ← 可以引入这个库\n├─ fs2 file locking     ← 可以引入这个库\n└─ windows-rs Win32 API ← 可以引入这个库\n\nTauri 桌面应用 (Rust + Vue 3)\n├─ src-tauri\u002F（Rust 后端）\n│  ├─ Cargo.toml: 引入 launcher 相关 crate\n│  │  reqwest = \"0.11\"  \u002F\u002F 与 Launcher 共用版本\n│  │  serde = { version = \"1.0\", features = [\"derive\"] }\n│  │  windows = { version = \"0.52\", features = [...] }\n│  ├─ commands\u002F\n│  │  ├─ gateway.rs     : 封装 reqwest client 为 invoke 命令\n│  │  ├─ user_config.rs : 使用 fs2 做原子写\n│  │  └─ runtime.rs     : 读取 runtime-info.json\n│  └─ src\u002Fmain.rs       : Tauri builder 入口\n├─ src\u002F（Vue 3 前端）\n│  ├─ stores\u002F\n│  │  ├─ gateway.ts\n│  │  ├─ userConfig.ts\n│  │  └─ runtime.ts\n│  └─ services\u002F\n│     └─ tauri.ts       : 包装 invoke() 调用\n",[275,541,539],{"__ignoreMap":277},[10,543,544,547],{},[170,545,546],{},"代码复用清单","：",[10,549,550],{},"实际计算可复用的代码行数：",[74,552,553,569],{},[77,554,555],{},[80,556,557,560,563,566],{},[83,558,559],{},"模块",[83,561,562],{},"Launcher 行数",[83,564,565],{},"复用方式",[83,567,568],{},"节省",[93,570,571,585,599,613,627,640],{},[80,572,573,576,579,582],{},[98,574,575],{},"HTTP gateway proxy",[98,577,578],{},"500 行",[98,580,581],{},"直接导入 reqwest client，wrapper 只需 50 行",[98,583,584],{},"-90%",[80,586,587,590,593,596],{},[98,588,589],{},"原子文件写",[98,591,592],{},"200 行",[98,594,595],{},"导入 fs2，wrapper 只需 30 行",[98,597,598],{},"-85%",[80,600,601,604,607,610],{},[98,602,603],{},"命名管道 IPC",[98,605,606],{},"300 行",[98,608,609],{},"导入 windows-rs，wrapper 只需 50 行",[98,611,612],{},"-83%",[80,614,615,618,621,624],{},[98,616,617],{},"错误类型",[98,619,620],{},"150 行",[98,622,623],{},"直接导入 errors module",[98,625,626],{},"-100%",[80,628,629,632,635,638],{},[98,630,631],{},"品牌常量",[98,633,634],{},"100 行",[98,636,637],{},"直接导入 brand module",[98,639,626],{},[80,641,642,647,652,657],{},[98,643,644],{},[170,645,646],{},"小计",[98,648,649],{},[170,650,651],{},"1250 行",[98,653,654],{},[170,655,656],{},"总计复用 ~900 行",[98,658,659],{},[170,660,661],{},"-72%",[10,663,664],{},"这意味着配置中心的 Rust 后端大约可以节省 900 行代码。900 行按每小时 50 行的编码速度计算，节省了 18 小时的开发时间。而且这些是业务关键的代码——用现成的、已测试过的实现，质量有保证。",[10,666,667],{},[170,668,669],{},"被放弃的选项",[10,671,672],{},"也考虑过 Electron + native module 的混合方案。伪代码：",[268,674,678],{"className":675,"code":676,"language":677,"meta":277,"style":277},"language-rust shiki shiki-themes github-light github-dark","\u002F\u002F 作为 native module 暴露给 Electron\n#[napi]\npub fn gateway_request(url: String, opts: String) -> napi::Result\u003CString> {\n    let client = reqwest::Client::new();\n    \u002F\u002F 实现 HTTP 请求\n    Ok(response_json)\n}\n","rust",[275,679,680,688,694,700,706,712,718],{"__ignoreMap":277},[681,682,685],"span",{"class":683,"line":684},"line",1,[681,686,687],{},"\u002F\u002F 作为 native module 暴露给 Electron\n",[681,689,691],{"class":683,"line":690},2,[681,692,693],{},"#[napi]\n",[681,695,697],{"class":683,"line":696},3,[681,698,699],{},"pub fn gateway_request(url: String, opts: String) -> napi::Result\u003CString> {\n",[681,701,703],{"class":683,"line":702},4,[681,704,705],{},"    let client = reqwest::Client::new();\n",[681,707,709],{"class":683,"line":708},5,[681,710,711],{},"    \u002F\u002F 实现 HTTP 请求\n",[681,713,715],{"class":683,"line":714},6,[681,716,717],{},"    Ok(response_json)\n",[681,719,721],{"class":683,"line":720},7,[681,722,723],{},"}\n",[10,725,726],{},"然后在 Electron 中：",[268,728,732],{"className":729,"code":730,"language":731,"meta":277,"style":277},"language-javascript shiki shiki-themes github-light github-dark","const { gateway_request } = require('.\u002Fnative');\nconst response = gateway_request(url, opts);\n","javascript",[275,733,734,767],{"__ignoreMap":277},[681,735,736,740,744,747,750,753,757,760,764],{"class":683,"line":684},[681,737,739],{"class":738},"szBVR","const",[681,741,743],{"class":742},"sVt8B"," { ",[681,745,375],{"class":746},"sj4cs",[681,748,749],{"class":742}," } ",[681,751,752],{"class":738},"=",[681,754,756],{"class":755},"sScJk"," require",[681,758,759],{"class":742},"(",[681,761,763],{"class":762},"sZZnC","'.\u002Fnative'",[681,765,766],{"class":742},");\n",[681,768,769,771,774,777,780],{"class":683,"line":690},[681,770,739],{"class":738},[681,772,773],{"class":746}," response",[681,775,776],{"class":738}," =",[681,778,779],{"class":755}," gateway_request",[681,781,782],{"class":742},"(url, opts);\n",[10,784,785],{},"这样可以在 Electron 中调用 Rust 代码。但成本包括：",[39,787,788,794,800,810,816],{},[42,789,790,793],{},[170,791,792],{},"构建脚本复杂度提升 3 倍","：需要 node-gyp、MSVC 工具链配置、目标 triple 管理",[42,795,796,799],{},[170,797,798],{},"分发体积增加","：预编译的 .node 文件（Node Native Extension）体积大（通常 5-10MB），且不同 Node 版本不兼容，需要发布多个版本到 npm",[42,801,802,805,806,809],{},[170,803,804],{},"开发反馈慢","：改一行 Rust 代码都要重新 ",[275,807,808],{},"npm run build:native","，速度慢",[42,811,812,815],{},[170,813,814],{},"错误报告不清楚","：JSON 序列化\u002F反序列化过程中容易丢失类型信息或堆栈跟踪",[42,817,818,820],{},[170,819,509],{},"：任何 Node 版本升级都需要重新编译",[10,822,823],{},"Tauri 的 invoke 机制绕过了所有这些问题。WebView JS 与 Rust 的通道由 Tauri 框架管理，类型通过 serde derive 自动序列化，错误处理一致。",[14,825,827],{"id":826},"理由三更新包体积更小","理由三：更新包体积更小",[28,829,830],{"id":830},"更新流程中的成本",[10,832,833],{},"便携应用的更新路径有两种：",[10,835,836],{},[170,837,838],{},"路径 A：物理 U 盘",[10,840,841],{},"用户从官网下载新版本到 U 盘，拷贝覆盖旧版本。成本是：",[39,843,844,847,850],{},[42,845,846],{},"用户的网络带宽（下载新版本）",[42,848,849],{},"用户的等待时间",[42,851,852],{},"官网的服务器流量",[10,854,855],{},[170,856,857],{},"路径 B：应用内网络更新",[10,859,860],{},"应用内检测更新，自动下载最新版本。成本是：",[39,862,863,866,869],{},[42,864,865],{},"用户的网络带宽（同上）",[42,867,868],{},"开发者的 CDN 费用（流量计费）",[42,870,871],{},"服务器存储成本（保存多个历史版本）",[10,873,874],{},"无论哪种路径，包体积都直接影响成本。60MB vs 20MB，相差整整 3 倍。",[10,876,877,547],{},[170,878,879],{},"成本计算（假设 1000 活跃用户，月更新一次）",[74,881,882,895],{},[77,883,884],{},[80,885,886,888,890,892],{},[83,887,187],{},[83,889,190],{},[83,891,193],{},[83,893,894],{},"成本差",[93,896,897,911,925],{},[80,898,899,902,905,908],{},[98,900,901],{},"用户网络（下载 1000 次 60MB）",[98,903,904],{},"60 GB 月",[98,906,907],{},"20 GB 月",[98,909,910],{},"省 40 GB",[80,912,913,916,919,922],{},[98,914,915],{},"CDN 流量费（$0.1\u002FGB）",[98,917,918],{},"$600\u002F月",[98,920,921],{},"$200\u002F月",[98,923,924],{},"省 $400\u002F月",[80,926,927,930,933,936],{},[98,928,929],{},"年度成本",[98,931,932],{},"$7200",[98,934,935],{},"$2400",[98,937,938],{},[170,939,940],{},"省 $4800\u002F年",[10,942,943],{},"对于商业应用，省钱就是收益。对于个人开发者，更新不了的快速迭代也是成本。",[28,945,946],{"id":946},"分层配置的增量优势",[10,948,949],{},"进一步优化的策略是配置文件分层。这个项目将配置拆成两个 YAML：",[10,951,952],{},[170,953,954],{},"user.yml（本体配置，改动触发 Gateway reload）",[10,956,957],{},"这个文件存储用户真正配置的内容：API Key、模型选择、渠道连接状态等。改动时需要通知 Gateway 重新加载。",[268,959,963],{"className":960,"code":961,"language":962,"meta":277,"style":277},"language-yaml shiki shiki-themes github-light github-dark","schema_version: 1\nbrand:\n  name: \"openclaw\"\n\nmodels:\n  providers:\n    deepseek:\n      base_url: \"https:\u002F\u002Fapi.deepseek.com\u002Fv1\"\n      api_key: \"sk-xxx...\"\n      models:\n        - id: \"deepseek-chat\"\n          name: \"DeepSeek Chat\"\n          context_window: 64000\n          max_tokens: 8192\n\nchannels:\n  feishu: { enabled: false }\n  webchat: { enabled: true }\n\nplugins:\n  allow: [\"skill-basic-chat\"]\n","yaml",[275,964,965,977,985,995,1001,1008,1015,1022,1033,1044,1052,1066,1077,1088,1099,1104,1112,1132,1149,1154,1162],{"__ignoreMap":277},[681,966,967,971,974],{"class":683,"line":684},[681,968,970],{"class":969},"s9eBZ","schema_version",[681,972,973],{"class":742},": ",[681,975,976],{"class":746},"1\n",[681,978,979,982],{"class":683,"line":690},[681,980,981],{"class":969},"brand",[681,983,984],{"class":742},":\n",[681,986,987,990,992],{"class":683,"line":696},[681,988,989],{"class":969},"  name",[681,991,973],{"class":742},[681,993,994],{"class":762},"\"openclaw\"\n",[681,996,997],{"class":683,"line":702},[681,998,1000],{"emptyLinePlaceholder":999},true,"\n",[681,1002,1003,1006],{"class":683,"line":708},[681,1004,1005],{"class":969},"models",[681,1007,984],{"class":742},[681,1009,1010,1013],{"class":683,"line":714},[681,1011,1012],{"class":969},"  providers",[681,1014,984],{"class":742},[681,1016,1017,1020],{"class":683,"line":720},[681,1018,1019],{"class":969},"    deepseek",[681,1021,984],{"class":742},[681,1023,1025,1028,1030],{"class":683,"line":1024},8,[681,1026,1027],{"class":969},"      base_url",[681,1029,973],{"class":742},[681,1031,1032],{"class":762},"\"https:\u002F\u002Fapi.deepseek.com\u002Fv1\"\n",[681,1034,1036,1039,1041],{"class":683,"line":1035},9,[681,1037,1038],{"class":969},"      api_key",[681,1040,973],{"class":742},[681,1042,1043],{"class":762},"\"sk-xxx...\"\n",[681,1045,1047,1050],{"class":683,"line":1046},10,[681,1048,1049],{"class":969},"      models",[681,1051,984],{"class":742},[681,1053,1055,1058,1061,1063],{"class":683,"line":1054},11,[681,1056,1057],{"class":742},"        - ",[681,1059,1060],{"class":969},"id",[681,1062,973],{"class":742},[681,1064,1065],{"class":762},"\"deepseek-chat\"\n",[681,1067,1069,1072,1074],{"class":683,"line":1068},12,[681,1070,1071],{"class":969},"          name",[681,1073,973],{"class":742},[681,1075,1076],{"class":762},"\"DeepSeek Chat\"\n",[681,1078,1080,1083,1085],{"class":683,"line":1079},13,[681,1081,1082],{"class":969},"          context_window",[681,1084,973],{"class":742},[681,1086,1087],{"class":746},"64000\n",[681,1089,1091,1094,1096],{"class":683,"line":1090},14,[681,1092,1093],{"class":969},"          max_tokens",[681,1095,973],{"class":742},[681,1097,1098],{"class":746},"8192\n",[681,1100,1102],{"class":683,"line":1101},15,[681,1103,1000],{"emptyLinePlaceholder":999},[681,1105,1107,1110],{"class":683,"line":1106},16,[681,1108,1109],{"class":969},"channels",[681,1111,984],{"class":742},[681,1113,1115,1118,1121,1124,1126,1129],{"class":683,"line":1114},17,[681,1116,1117],{"class":969},"  feishu",[681,1119,1120],{"class":742},": { ",[681,1122,1123],{"class":969},"enabled",[681,1125,973],{"class":742},[681,1127,1128],{"class":746},"false",[681,1130,1131],{"class":742}," }\n",[681,1133,1135,1138,1140,1142,1144,1147],{"class":683,"line":1134},18,[681,1136,1137],{"class":969},"  webchat",[681,1139,1120],{"class":742},[681,1141,1123],{"class":969},[681,1143,973],{"class":742},[681,1145,1146],{"class":746},"true",[681,1148,1131],{"class":742},[681,1150,1152],{"class":683,"line":1151},19,[681,1153,1000],{"emptyLinePlaceholder":999},[681,1155,1157,1160],{"class":683,"line":1156},20,[681,1158,1159],{"class":969},"plugins",[681,1161,984],{"class":742},[681,1163,1165,1168,1171,1174],{"class":683,"line":1164},21,[681,1166,1167],{"class":969},"  allow",[681,1169,1170],{"class":742},": [",[681,1172,1173],{"class":762},"\"skill-basic-chat\"",[681,1175,1176],{"class":742},"]\n",[10,1178,1179],{},[170,1180,1181],{},"app-prefs.yml（桌面偏好，改动不触发 reload）",[10,1183,1184],{},"这个文件存储用户界面的临时状态：主题、窗口大小、上次访问的页面等。改动完全是本地的，不影响 Gateway。",[268,1186,1188],{"className":960,"code":1187,"language":962,"meta":277,"style":277},"schema_version: 1\n\nui:\n  theme: dark\n  language: zh-CN\n  \nwindow:\n  x: 120\n  y: 80\n  w: 1280\n  h: 800\n  maximized: false\n\nlast_selected:\n  model: \"deepseek\u002Fdeepseek-chat\"\n  page: \"\u002Fhome\"\n",[275,1189,1190,1198,1202,1209,1219,1229,1234,1241,1251,1261,1271,1281,1291,1295,1302,1312],{"__ignoreMap":277},[681,1191,1192,1194,1196],{"class":683,"line":684},[681,1193,970],{"class":969},[681,1195,973],{"class":742},[681,1197,976],{"class":746},[681,1199,1200],{"class":683,"line":690},[681,1201,1000],{"emptyLinePlaceholder":999},[681,1203,1204,1207],{"class":683,"line":696},[681,1205,1206],{"class":969},"ui",[681,1208,984],{"class":742},[681,1210,1211,1214,1216],{"class":683,"line":702},[681,1212,1213],{"class":969},"  theme",[681,1215,973],{"class":742},[681,1217,1218],{"class":762},"dark\n",[681,1220,1221,1224,1226],{"class":683,"line":708},[681,1222,1223],{"class":969},"  language",[681,1225,973],{"class":742},[681,1227,1228],{"class":762},"zh-CN\n",[681,1230,1231],{"class":683,"line":714},[681,1232,1233],{"class":742},"  \n",[681,1235,1236,1239],{"class":683,"line":720},[681,1237,1238],{"class":969},"window",[681,1240,984],{"class":742},[681,1242,1243,1246,1248],{"class":683,"line":1024},[681,1244,1245],{"class":969},"  x",[681,1247,973],{"class":742},[681,1249,1250],{"class":746},"120\n",[681,1252,1253,1256,1258],{"class":683,"line":1035},[681,1254,1255],{"class":746},"  y",[681,1257,973],{"class":742},[681,1259,1260],{"class":746},"80\n",[681,1262,1263,1266,1268],{"class":683,"line":1046},[681,1264,1265],{"class":969},"  w",[681,1267,973],{"class":742},[681,1269,1270],{"class":746},"1280\n",[681,1272,1273,1276,1278],{"class":683,"line":1054},[681,1274,1275],{"class":969},"  h",[681,1277,973],{"class":742},[681,1279,1280],{"class":746},"800\n",[681,1282,1283,1286,1288],{"class":683,"line":1068},[681,1284,1285],{"class":969},"  maximized",[681,1287,973],{"class":742},[681,1289,1290],{"class":746},"false\n",[681,1292,1293],{"class":683,"line":1079},[681,1294,1000],{"emptyLinePlaceholder":999},[681,1296,1297,1300],{"class":683,"line":1090},[681,1298,1299],{"class":969},"last_selected",[681,1301,984],{"class":742},[681,1303,1304,1307,1309],{"class":683,"line":1101},[681,1305,1306],{"class":969},"  model",[681,1308,973],{"class":742},[681,1310,1311],{"class":762},"\"deepseek\u002Fdeepseek-chat\"\n",[681,1313,1314,1317,1319],{"class":683,"line":1106},[681,1315,1316],{"class":969},"  page",[681,1318,973],{"class":742},[681,1320,1321],{"class":762},"\"\u002Fhome\"\n",[10,1323,1324],{},[170,1325,1326],{},"为什么要分离？",[10,1328,1329],{},"这两类数据的更新频率和影响范围完全不同：",[39,1331,1332,1335],{},[42,1333,1334],{},"user.yml 改动很少见（用户配置 API Key 时、添加新模型时），但一旦改动需要通知 Gateway 重新加载配置。用户可能几个月都不碰这个文件。",[42,1336,1337],{},"app-prefs.yml 改动很频繁（每次调整窗口大小、切换页面、改主题），但完全是本地状态，不影响 Gateway。",[10,1339,1340],{},"如果没有这个分层，每次用户调整窗口大小（1280×800 → 1920×1080）都会被记录到 user.yml，然后在下一次产品更新时被打包进去。这意味着文件内容频繁变化，增量更新时无法有效压缩（diff 算法看到的是文件内容变化，难以识别哪些是有意义的改动）。",[10,1342,1343],{},"分层后，只有 user.yml 的变化才需要在更新包中传输。假设统计 10000 个用户：",[39,1345,1346,1349,1352],{},[42,1347,1348],{},"其中 9500 个用户的 user.yml 一个月都没改（API Key 没变、模型配置没变）",[42,1350,1351],{},"只有 500 个用户改过配置",[42,1353,1354],{},"在这 500 个人中，改动大小平均只有 2-3KB（比如加一个新模型、禁用一个渠道）",[10,1356,1357],{},"增量更新时，这 9500 个用户的包甚至可以是 0 字节（user.yml 部分完全无变化）。前端资源（Vue.js + CSS）倒是会经常改动，但总量只有 250-80 = 330KB，即使完整传输也很小。",[28,1359,1360],{"id":1360},"分发成本的数字对比",[74,1362,1363,1377],{},[77,1364,1365],{},[80,1366,1367,1369,1372,1375],{},[83,1368,187],{},[83,1370,1371],{},"Electron 策略",[83,1373,1374],{},"Tauri 策略",[83,1376,568],{},[93,1378,1379,1396,1412,1428,1442],{},[80,1380,1381,1387,1390,1393],{},[98,1382,1383,1386],{},[170,1384,1385],{},"场景 1：前端 UI 改动","（改首页样式、加按钮）",[98,1388,1389],{},"完整 60 MB",[98,1391,1392],{},"前端资源 Δ ≈ 50 KB",[98,1394,1395],{},"-99.9%",[80,1397,1398,1404,1406,1409],{},[98,1399,1400,1403],{},[170,1401,1402],{},"场景 2：模型列表更新","（加新 provider）",[98,1405,1389],{},[98,1407,1408],{},"Rust Δ ≈ 0-2 MB",[98,1410,1411],{},"-96%",[80,1413,1414,1420,1422,1425],{},[98,1415,1416,1419],{},[170,1417,1418],{},"场景 3：Bug 修复","（修复一个 config 读写 bug）",[98,1421,1389],{},[98,1423,1424],{},"Rust Δ ≈ 100 KB",[98,1426,1427],{},"-99.8%",[80,1429,1430,1435,1437,1440],{},[98,1431,1432],{},[170,1433,1434],{},"场景 4：前端 + 后端同时改",[98,1436,1389],{},[98,1438,1439],{},"50 KB + 2 MB = 2 MB",[98,1441,1411],{},[80,1443,1444,1447,1449,1452],{},[98,1445,1446],{},"用户装 3 个版本（总成本）",[98,1448,220],{},[98,1450,1451],{},"20 + 0.05 + 2 = 22 MB",[98,1453,1454],{},"-87%",[10,1456,1457],{},"这里的 Tauri 增量假设采用了 differential patching（只传输二进制的变化，而非完整重新下载）。这个项目在 Plan 03（MVP）阶段还没有实装自动增量（技术债务在 Plan 04 的 debt-03-F 中记录），但架构上完全支持这种优化。",[10,1459,1460,1463],{},[170,1461,1462],{},"实装策略","（后续）：",[10,1465,1466,1467,1470,1471,1474],{},"用 ",[275,1468,1469],{},"diffpatch"," 或 ",[275,1472,1473],{},"rsync"," 算法生成二进制 delta 文件。即使 18MB 的 Rust binary 只改了 2MB 的代码，可以只传输那 2MB 的差异。对前端资源采用 Vite 的资源 hash 策略，只重新下载改动过的 JS\u002FCSS 文件。",[10,1476,1477],{},[170,1478,669],{},[10,1480,1481],{},"考虑过为 Electron 实装类似的增量更新（electron-updater 支持 delta 更新），但有两个问题：",[457,1483,1484,1490],{},[42,1485,1486,1489],{},[170,1487,1488],{},"基数过大","：即使差异只有 5-10%，增量也是 3-6MB。维护增量分发流程（计算 patch、上传到 CDN、验证签名）的工程成本相对收益不大。而 Tauri 的增量通常在 50KB-2MB 之间，工程复杂度一样，收益却 10 倍。",[42,1491,1492,1495],{},[170,1493,1494],{},"更新可靠性","：增量更新依赖于用户已有的旧版本文件完整无损。一旦用户的本地文件被损坏（磁盘错误、杀毒软件误删、手动修改等），增量更新就会失败，还得回退到完整下载。Tauri + 分层配置的方案更简单：完整下载一次 18MB（秒级），之后 95% 的更新都是资源级别的小补丁。",[14,1497,1499],{"id":1498},"理由四windows-系统集成更直接","理由四：Windows 系统集成更直接",[28,1501,1503],{"id":1502},"需要的-windows-能力清单","需要的 Windows 能力清单",[10,1505,1506],{},"配置中心需要与 Windows 操作系统进行多种交互，每一个都涉及底层 API：",[10,1508,1509],{},[170,1510,1511],{},"1. 进程管理与单实例约束",[10,1513,1514],{},"用户不应该能同时打开两个配置中心窗口。实现这个需要创建一个全局互斥量（named mutex），在进程启动时尝试获取，失败说明已有实例在运行：",[268,1516,1518],{"className":675,"code":1517,"language":677,"meta":277,"style":277},"use windows::Win32::System::Threading::CreateMutexW;\nuse windows::core::HSTRING;\n\nlet mutex_name = format!(\"Global\\\\openclaw-tauri-singleton-{}\", &usb_hash[..8]);\nlet mutex = CreateMutexW(\n    None,                          \u002F\u002F lpMutexAttributes\n    false,                         \u002F\u002F bInitialOwner (not initially owned)\n    &HSTRING::from(mutex_name)\n)?;\n\nmatch GetLastError() {\n    0 => {\n        \u002F\u002F 互斥量创建成功，说明这是第一个实例\n        \u002F\u002F 创建命名管道监听其他实例的信号\n    }\n    ERROR_ALREADY_EXISTS => {\n        \u002F\u002F 互斥量已存在，说明有实例在运行\n        \u002F\u002F 连接管道，发送 \"RAISE\" 信号给已有实例\n        \u002F\u002F 然后退出\n    }\n}\n",[275,1519,1520,1525,1530,1534,1539,1544,1549,1554,1559,1564,1568,1573,1578,1583,1588,1593,1598,1603,1608,1613,1617],{"__ignoreMap":277},[681,1521,1522],{"class":683,"line":684},[681,1523,1524],{},"use windows::Win32::System::Threading::CreateMutexW;\n",[681,1526,1527],{"class":683,"line":690},[681,1528,1529],{},"use windows::core::HSTRING;\n",[681,1531,1532],{"class":683,"line":696},[681,1533,1000],{"emptyLinePlaceholder":999},[681,1535,1536],{"class":683,"line":702},[681,1537,1538],{},"let mutex_name = format!(\"Global\\\\openclaw-tauri-singleton-{}\", &usb_hash[..8]);\n",[681,1540,1541],{"class":683,"line":708},[681,1542,1543],{},"let mutex = CreateMutexW(\n",[681,1545,1546],{"class":683,"line":714},[681,1547,1548],{},"    None,                          \u002F\u002F lpMutexAttributes\n",[681,1550,1551],{"class":683,"line":720},[681,1552,1553],{},"    false,                         \u002F\u002F bInitialOwner (not initially owned)\n",[681,1555,1556],{"class":683,"line":1024},[681,1557,1558],{},"    &HSTRING::from(mutex_name)\n",[681,1560,1561],{"class":683,"line":1035},[681,1562,1563],{},")?;\n",[681,1565,1566],{"class":683,"line":1046},[681,1567,1000],{"emptyLinePlaceholder":999},[681,1569,1570],{"class":683,"line":1054},[681,1571,1572],{},"match GetLastError() {\n",[681,1574,1575],{"class":683,"line":1068},[681,1576,1577],{},"    0 => {\n",[681,1579,1580],{"class":683,"line":1079},[681,1581,1582],{},"        \u002F\u002F 互斥量创建成功，说明这是第一个实例\n",[681,1584,1585],{"class":683,"line":1090},[681,1586,1587],{},"        \u002F\u002F 创建命名管道监听其他实例的信号\n",[681,1589,1590],{"class":683,"line":1101},[681,1591,1592],{},"    }\n",[681,1594,1595],{"class":683,"line":1106},[681,1596,1597],{},"    ERROR_ALREADY_EXISTS => {\n",[681,1599,1600],{"class":683,"line":1114},[681,1601,1602],{},"        \u002F\u002F 互斥量已存在，说明有实例在运行\n",[681,1604,1605],{"class":683,"line":1134},[681,1606,1607],{},"        \u002F\u002F 连接管道，发送 \"RAISE\" 信号给已有实例\n",[681,1609,1610],{"class":683,"line":1151},[681,1611,1612],{},"        \u002F\u002F 然后退出\n",[681,1614,1615],{"class":683,"line":1156},[681,1616,1592],{},[681,1618,1619],{"class":683,"line":1164},[681,1620,723],{},[10,1622,1623],{},"如果用 Electron 做这个，需要一个原生模块。Node.js 本身没有直接的 CreateMutexW 绑定。有几种选择：",[39,1625,1626,1633,1636],{},[42,1627,1628,1629,1632],{},"npm 包 ",[275,1630,1631],{},"windows-mutex","（社区维护，成熟度不明）",[42,1634,1635],{},"node-gyp + C++ 自己写（编译复杂，维护成本高）",[42,1637,1638],{},"node-ffi（动态调用 DLL，运行时性能损失）",[10,1640,1641],{},"都不如直接 Rust 调用来得清爽。",[10,1643,1644],{},[170,1645,1646],{},"2. 进程间通信（IPC）",[10,1648,1649],{},"当用户第二次启动配置中心时（实例已存在），新进程需要通知旧进程：\"嘿，用户又点了一次，请把窗口拉到前台\"。实现这个用 Windows 命名管道（Named Pipe）：",[268,1651,1653],{"className":675,"code":1652,"language":677,"meta":277,"style":277},"use windows::Win32::Storage::FileSystem::{CreateNamedPipeW, ConnectNamedPipe};\nuse windows::core::HSTRING;\n\nlet pipe_name = format!(\"\\\\\\\\?\\\\pipe\\\\openclaw-tauri-{}\", &usb_hash[..8]);\n\n\u002F\u002F 主实例：创建管道并监听\nlet pipe = CreateNamedPipeW(\n    &HSTRING::from(&pipe_name),\n    PIPE_ACCESS_DUPLEX | FILE_FLAG_OVERLAPPED,  \u002F\u002F 双向 + 异步\n    PIPE_TYPE_BYTE,\n    4,                                           \u002F\u002F maxInstances\n    0, 0, None\n)?;\n\n\u002F\u002F 在 tokio task 中持续监听\ntokio::spawn(async move {\n    loop {\n        if ConnectNamedPipe(pipe, None).is_ok() {\n            \u002F\u002F 有其他实例连接了，读取它的消息\n            \u002F\u002F 消息是 \"RAISE\"，表示要求拉起窗口\n            \n            \u002F\u002F 通过 Tauri AppHandle 调用前端事件\n            app_handle.emit_all(\"window-raise-requested\", ())?;\n            \n            \u002F\u002F 前端收到事件后调用 webview.set_focus() 和 webview.show()\n        }\n    }\n});\n\n\u002F\u002F 二次实例：连接管道并发送信号\nConnectNamedPipeW(\n    &HSTRING::from(&pipe_name),\n    GENERIC_WRITE,\n    None\n)?;\nWriteFile(pipe, b\"RAISE\", None)?;\n\u002F\u002F 然后退出进程\n",[275,1654,1655,1660,1664,1668,1673,1677,1682,1687,1692,1697,1702,1707,1712,1716,1720,1725,1730,1735,1740,1745,1750,1755,1761,1767,1772,1778,1784,1789,1795,1800,1806,1812,1817,1823,1829,1834,1840],{"__ignoreMap":277},[681,1656,1657],{"class":683,"line":684},[681,1658,1659],{},"use windows::Win32::Storage::FileSystem::{CreateNamedPipeW, ConnectNamedPipe};\n",[681,1661,1662],{"class":683,"line":690},[681,1663,1529],{},[681,1665,1666],{"class":683,"line":696},[681,1667,1000],{"emptyLinePlaceholder":999},[681,1669,1670],{"class":683,"line":702},[681,1671,1672],{},"let pipe_name = format!(\"\\\\\\\\?\\\\pipe\\\\openclaw-tauri-{}\", &usb_hash[..8]);\n",[681,1674,1675],{"class":683,"line":708},[681,1676,1000],{"emptyLinePlaceholder":999},[681,1678,1679],{"class":683,"line":714},[681,1680,1681],{},"\u002F\u002F 主实例：创建管道并监听\n",[681,1683,1684],{"class":683,"line":720},[681,1685,1686],{},"let pipe = CreateNamedPipeW(\n",[681,1688,1689],{"class":683,"line":1024},[681,1690,1691],{},"    &HSTRING::from(&pipe_name),\n",[681,1693,1694],{"class":683,"line":1035},[681,1695,1696],{},"    PIPE_ACCESS_DUPLEX | FILE_FLAG_OVERLAPPED,  \u002F\u002F 双向 + 异步\n",[681,1698,1699],{"class":683,"line":1046},[681,1700,1701],{},"    PIPE_TYPE_BYTE,\n",[681,1703,1704],{"class":683,"line":1054},[681,1705,1706],{},"    4,                                           \u002F\u002F maxInstances\n",[681,1708,1709],{"class":683,"line":1068},[681,1710,1711],{},"    0, 0, None\n",[681,1713,1714],{"class":683,"line":1079},[681,1715,1563],{},[681,1717,1718],{"class":683,"line":1090},[681,1719,1000],{"emptyLinePlaceholder":999},[681,1721,1722],{"class":683,"line":1101},[681,1723,1724],{},"\u002F\u002F 在 tokio task 中持续监听\n",[681,1726,1727],{"class":683,"line":1106},[681,1728,1729],{},"tokio::spawn(async move {\n",[681,1731,1732],{"class":683,"line":1114},[681,1733,1734],{},"    loop {\n",[681,1736,1737],{"class":683,"line":1134},[681,1738,1739],{},"        if ConnectNamedPipe(pipe, None).is_ok() {\n",[681,1741,1742],{"class":683,"line":1151},[681,1743,1744],{},"            \u002F\u002F 有其他实例连接了，读取它的消息\n",[681,1746,1747],{"class":683,"line":1156},[681,1748,1749],{},"            \u002F\u002F 消息是 \"RAISE\"，表示要求拉起窗口\n",[681,1751,1752],{"class":683,"line":1164},[681,1753,1754],{},"            \n",[681,1756,1758],{"class":683,"line":1757},22,[681,1759,1760],{},"            \u002F\u002F 通过 Tauri AppHandle 调用前端事件\n",[681,1762,1764],{"class":683,"line":1763},23,[681,1765,1766],{},"            app_handle.emit_all(\"window-raise-requested\", ())?;\n",[681,1768,1770],{"class":683,"line":1769},24,[681,1771,1754],{},[681,1773,1775],{"class":683,"line":1774},25,[681,1776,1777],{},"            \u002F\u002F 前端收到事件后调用 webview.set_focus() 和 webview.show()\n",[681,1779,1781],{"class":683,"line":1780},26,[681,1782,1783],{},"        }\n",[681,1785,1787],{"class":683,"line":1786},27,[681,1788,1592],{},[681,1790,1792],{"class":683,"line":1791},28,[681,1793,1794],{},"});\n",[681,1796,1798],{"class":683,"line":1797},29,[681,1799,1000],{"emptyLinePlaceholder":999},[681,1801,1803],{"class":683,"line":1802},30,[681,1804,1805],{},"\u002F\u002F 二次实例：连接管道并发送信号\n",[681,1807,1809],{"class":683,"line":1808},31,[681,1810,1811],{},"ConnectNamedPipeW(\n",[681,1813,1815],{"class":683,"line":1814},32,[681,1816,1691],{},[681,1818,1820],{"class":683,"line":1819},33,[681,1821,1822],{},"    GENERIC_WRITE,\n",[681,1824,1826],{"class":683,"line":1825},34,[681,1827,1828],{},"    None\n",[681,1830,1832],{"class":683,"line":1831},35,[681,1833,1563],{},[681,1835,1837],{"class":683,"line":1836},36,[681,1838,1839],{},"WriteFile(pipe, b\"RAISE\", None)?;\n",[681,1841,1843],{"class":683,"line":1842},37,[681,1844,1845],{},"\u002F\u002F 然后退出进程\n",[10,1847,1848],{},"Electron 中要实现同样的效果，又要用到原生模块。Windows Named Pipe 不在 Node.js 的标准库中。通常的做法是：",[39,1850,1851,1857,1863],{},[42,1852,1628,1853,1856],{},[275,1854,1855],{},"node-ipc","（跨平台，但主要用 TCP socket，不用 Named Pipe）",[42,1858,1628,1859,1862],{},[275,1860,1861],{},"named-pipe","（仅 Windows，但星数少，维护度不明）",[42,1864,1865],{},"node-gyp 自己写（复杂）",[10,1867,1868],{},"而且很难保证这些包与 Tauri 的兼容性和性能。Tauri 用 Rust 后端的好处就是不用担心这些。",[10,1870,1871],{},[170,1872,1873],{},"3. WebView2 运行时检测与版本管理",[10,1875,1876],{},"在启动前检查系统是否安装了 WebView2，以及版本号是否足够新（≥ v90）。这需要查询 Windows 注册表：",[268,1878,1880],{"className":675,"code":1879,"language":677,"meta":277,"style":277},"use windows::Win32::System::Registry::*;\nuse windows::core::*;\n\n\u002F\u002F 打开注册表键\nlet hkey_local_machine = HKEY_LOCAL_MACHINE;\nlet subkey = w!(\"Software\\\\WOW6432Node\\\\Microsoft\\\\EdgeUpdate\\\\ClientState\\\\{F3017226-FE2A-4295-8BDF-00C3A9A7E4C5}\");\n\nlet mut key: HKEY = Default::default();\nRegOpenKeyExW(hkey_local_machine, subkey, 0, KEY_QUERY_VALUE, &mut key)?;\n\n\u002F\u002F 读取版本号\nlet mut version = [0u16; 256];\nlet mut size = std::mem::size_of_val(&version) as u32;\nRegQueryValueExW(key, w!(\"pv\"), None, None, Some(version.as_mut_ptr() as *mut _), Some(&mut size))?;\n\n\u002F\u002F 将 UTF-16 转换为字符串，解析版本号\nlet version_str = String::from_utf16(&version[..size as usize \u002F 2])\n    .unwrap_or_default();\n\nlet major_version = version_str.split('.').next()\n    .and_then(|s| s.parse::\u003Ci32>().ok())\n    .unwrap_or(0);\n\nif major_version \u003C 90 {\n    \u002F\u002F 版本太旧，提示用户下载\n}\n",[275,1881,1882,1887,1892,1896,1901,1906,1911,1915,1920,1925,1929,1934,1939,1944,1949,1953,1958,1963,1968,1972,1977,1982,1987,1991,1996,2001],{"__ignoreMap":277},[681,1883,1884],{"class":683,"line":684},[681,1885,1886],{},"use windows::Win32::System::Registry::*;\n",[681,1888,1889],{"class":683,"line":690},[681,1890,1891],{},"use windows::core::*;\n",[681,1893,1894],{"class":683,"line":696},[681,1895,1000],{"emptyLinePlaceholder":999},[681,1897,1898],{"class":683,"line":702},[681,1899,1900],{},"\u002F\u002F 打开注册表键\n",[681,1902,1903],{"class":683,"line":708},[681,1904,1905],{},"let hkey_local_machine = HKEY_LOCAL_MACHINE;\n",[681,1907,1908],{"class":683,"line":714},[681,1909,1910],{},"let subkey = w!(\"Software\\\\WOW6432Node\\\\Microsoft\\\\EdgeUpdate\\\\ClientState\\\\{F3017226-FE2A-4295-8BDF-00C3A9A7E4C5}\");\n",[681,1912,1913],{"class":683,"line":720},[681,1914,1000],{"emptyLinePlaceholder":999},[681,1916,1917],{"class":683,"line":1024},[681,1918,1919],{},"let mut key: HKEY = Default::default();\n",[681,1921,1922],{"class":683,"line":1035},[681,1923,1924],{},"RegOpenKeyExW(hkey_local_machine, subkey, 0, KEY_QUERY_VALUE, &mut key)?;\n",[681,1926,1927],{"class":683,"line":1046},[681,1928,1000],{"emptyLinePlaceholder":999},[681,1930,1931],{"class":683,"line":1054},[681,1932,1933],{},"\u002F\u002F 读取版本号\n",[681,1935,1936],{"class":683,"line":1068},[681,1937,1938],{},"let mut version = [0u16; 256];\n",[681,1940,1941],{"class":683,"line":1079},[681,1942,1943],{},"let mut size = std::mem::size_of_val(&version) as u32;\n",[681,1945,1946],{"class":683,"line":1090},[681,1947,1948],{},"RegQueryValueExW(key, w!(\"pv\"), None, None, Some(version.as_mut_ptr() as *mut _), Some(&mut size))?;\n",[681,1950,1951],{"class":683,"line":1101},[681,1952,1000],{"emptyLinePlaceholder":999},[681,1954,1955],{"class":683,"line":1106},[681,1956,1957],{},"\u002F\u002F 将 UTF-16 转换为字符串，解析版本号\n",[681,1959,1960],{"class":683,"line":1114},[681,1961,1962],{},"let version_str = String::from_utf16(&version[..size as usize \u002F 2])\n",[681,1964,1965],{"class":683,"line":1134},[681,1966,1967],{},"    .unwrap_or_default();\n",[681,1969,1970],{"class":683,"line":1151},[681,1971,1000],{"emptyLinePlaceholder":999},[681,1973,1974],{"class":683,"line":1156},[681,1975,1976],{},"let major_version = version_str.split('.').next()\n",[681,1978,1979],{"class":683,"line":1164},[681,1980,1981],{},"    .and_then(|s| s.parse::\u003Ci32>().ok())\n",[681,1983,1984],{"class":683,"line":1757},[681,1985,1986],{},"    .unwrap_or(0);\n",[681,1988,1989],{"class":683,"line":1763},[681,1990,1000],{"emptyLinePlaceholder":999},[681,1992,1993],{"class":683,"line":1769},[681,1994,1995],{},"if major_version \u003C 90 {\n",[681,1997,1998],{"class":683,"line":1774},[681,1999,2000],{},"    \u002F\u002F 版本太旧，提示用户下载\n",[681,2002,2003],{"class":683,"line":1780},[681,2004,723],{},[10,2006,2007],{},"Electron 中查注册表也要用原生模块或 node-gyp 脚本。而且 Node.js 访问注册表的方式都不够优雅。",[10,2009,2010],{},[170,2011,2012],{},"4. 外部程序启动（浏览器打开）",[10,2014,2015],{},"用户点\"打开 WebUI\"按钮，应该用系统默认浏览器打开 Gateway 的地址。在 Windows 上用 ShellExecuteW：",[268,2017,2019],{"className":675,"code":2018,"language":677,"meta":277,"style":277},"use windows::Win32::System::Com::ShellExecuteW;\nuse windows::core::*;\n\nShellExecuteW(\n    None,                                           \u002F\u002F hwnd\n    w!(\"open\"),                                     \u002F\u002F lpOperation\n    w!(\"http:\u002F\u002F127.0.0.1:53721\"),                  \u002F\u002F lpFile (URL)\n    None,                                           \u002F\u002F lpParameters\n    None,                                           \u002F\u002F lpDirectory\n    windows::Win32::System::Com::SW_SHOW\n)?;\n",[275,2020,2021,2026,2030,2034,2039,2044,2049,2054,2059,2064,2069],{"__ignoreMap":277},[681,2022,2023],{"class":683,"line":684},[681,2024,2025],{},"use windows::Win32::System::Com::ShellExecuteW;\n",[681,2027,2028],{"class":683,"line":690},[681,2029,1891],{},[681,2031,2032],{"class":683,"line":696},[681,2033,1000],{"emptyLinePlaceholder":999},[681,2035,2036],{"class":683,"line":702},[681,2037,2038],{},"ShellExecuteW(\n",[681,2040,2041],{"class":683,"line":708},[681,2042,2043],{},"    None,                                           \u002F\u002F hwnd\n",[681,2045,2046],{"class":683,"line":714},[681,2047,2048],{},"    w!(\"open\"),                                     \u002F\u002F lpOperation\n",[681,2050,2051],{"class":683,"line":720},[681,2052,2053],{},"    w!(\"http:\u002F\u002F127.0.0.1:53721\"),                  \u002F\u002F lpFile (URL)\n",[681,2055,2056],{"class":683,"line":1024},[681,2057,2058],{},"    None,                                           \u002F\u002F lpParameters\n",[681,2060,2061],{"class":683,"line":1035},[681,2062,2063],{},"    None,                                           \u002F\u002F lpDirectory\n",[681,2065,2066],{"class":683,"line":1046},[681,2067,2068],{},"    windows::Win32::System::Com::SW_SHOW\n",[681,2070,2071],{"class":683,"line":1054},[681,2072,1563],{},[10,2074,2075],{},"这会调用系统的 URL 处理器，用用户设置的默认浏览器打开链接。非常可靠。",[10,2077,2078,2079,2082,2083,2086],{},"Electron 中可以用 ",[275,2080,2081],{},"child_process.exec('start http:\u002F\u002F...')","，但这是调用系统 shell，不如直接 ShellExecuteW 可靠。更好的方案是 npm 包 ",[275,2084,2085],{},"open","，但又多了一个依赖，还要担心它的维护状态。",[28,2088,2090],{"id":2089},"tauri-的系统集成优势","Tauri 的系统集成优势",[10,2092,2093],{},"用 Tauri + Rust 后端的好处是：所有这些 Windows API 都直接可用，通过 windows-rs crate。无需额外的原生模块编译、无需第三方包的兼容性顾虑。",[268,2095,2099],{"className":2096,"code":2097,"language":2098,"meta":277,"style":277},"language-toml shiki shiki-themes github-light github-dark","[dependencies]\nwindows = { version = \"0.52\", features = [\n    \"Win32_System_Threading\",      \u002F\u002F CreateMutexW\n    \"Win32_Storage_FileSystem\",    \u002F\u002F CreateNamedPipeW\n    \"Win32_System_Registry\",       \u002F\u002F RegOpenKeyExW\n    \"Win32_System_Com\"             \u002F\u002F ShellExecuteW\n] }\n","toml",[275,2100,2101,2106,2111,2119,2127,2135,2143],{"__ignoreMap":277},[681,2102,2103],{"class":683,"line":684},[681,2104,2105],{},"[dependencies]\n",[681,2107,2108],{"class":683,"line":690},[681,2109,2110],{},"windows = { version = \"0.52\", features = [\n",[681,2112,2113,2116],{"class":683,"line":696},[681,2114,2115],{},"    \"Win32_System_Threading\",",[681,2117,2118],{},"      \u002F\u002F CreateMutexW\n",[681,2120,2121,2124],{"class":683,"line":702},[681,2122,2123],{},"    \"Win32_Storage_FileSystem\",",[681,2125,2126],{},"    \u002F\u002F CreateNamedPipeW\n",[681,2128,2129,2132],{"class":683,"line":708},[681,2130,2131],{},"    \"Win32_System_Registry\",",[681,2133,2134],{},"       \u002F\u002F RegOpenKeyExW\n",[681,2136,2137,2140],{"class":683,"line":714},[681,2138,2139],{},"    \"Win32_System_Com\"",[681,2141,2142],{},"             \u002F\u002F ShellExecuteW\n",[681,2144,2145],{"class":683,"line":720},[681,2146,2147],{},"] }\n",[10,2149,2150],{},"一个 Cargo.toml 的配置，所有 Win32 API 都在手边。调用时就是普通的 Rust 代码，IDE 有完整的类型检查和代码补全。性能上也没有任何中间层开销——直接调用系统 API。",[10,2152,2153],{},"windows-rs 是 Microsoft 官方维护的库，API coverage 和更新速度都很快。比依赖社区维护的小 npm 包可靠得多。",[28,2155,2156],{"id":2156},"调试和维护的成本差异",[10,2158,2159],{},"遇到 Windows 系统级的 Bug 时，两种方案的成本差异很大：",[10,2161,2162,2165],{},[170,2163,2164],{},"Tauri 方案","（问题快速修复）：",[457,2167,2168,2178,2181,2184,2191],{},[42,2169,2170,2171,1470,2174,2177],{},"在 ",[275,2172,2173],{},"single_instance.rs",[275,2175,2176],{},"gateway_proxy.rs"," 中加 debug 日志",[42,2179,2180],{},"用 eprintln! 或 log crate 打印详细信息",[42,2182,2183],{},"本地测试或连接远程 Windows 机器调试（RDP）",[42,2185,2186,2187,2190],{},"修改 Rust 代码，",[275,2188,2189],{},"cargo build"," 重新编译（秒级）",[42,2192,2193],{},"0 额外依赖，0 兼容性问题",[10,2195,2196,2199],{},[170,2197,2198],{},"Electron + native module 方案","（修复慢且容易出问题）：",[457,2201,2202,2205,2208,2211,2214,2217,2220],{},[42,2203,2204],{},"在 C++\u002FRust 代码中加日志",[42,2206,2207],{},"用 node-gyp rebuild 重新编译（分钟级）",[42,2209,2210],{},"检查 npm 版本、Node 版本是否匹配",[42,2212,2213],{},"本地测试通过后，上传新的 prebuilt binary 到 npm registry",[42,2215,2216],{},"通知用户升级 npm 依赖，等待 npm install 下载新版本",[42,2218,2219],{},"可能需要等待用户反馈是否真的修复了",[42,2221,2222],{},"如果问题涉及 Node 或 Electron 版本，可能需要多个版本的 binary",[10,2224,2225],{},"调试一个互斥量或管道相关的 Windows Bug，Tauri 方案可能 1-2 小时搞定，Electron 方案可能要 1-2 天（包括编译、上传、用户反馈周期）。",[14,2227,2228],{"id":2228},"代价与现实",[10,2230,2231],{},"选择 Tauri 不是银弹。三个代价必须正视、明确记录：",[28,2233,2235],{"id":2234},"代价一前端生态成熟度低于-electron社区库工具文档","代价一：前端生态成熟度低于 Electron（社区库、工具、文档）",[10,2237,2238],{},"Electron 有 10+ 年的积累，社区工具、UI 库、启动模板、学习资料极其丰富。Tauri 是后来者，虽然发展快速（v2.0 在 2024 年发布），但生态仍在追赶阶段。",[10,2240,2241,547],{},[170,2242,2243],{},"具体体现",[39,2245,2246,2252,2258,2264],{},[42,2247,2248,2251],{},[170,2249,2250],{},"UI 组件库","：Electron 生态有 electron-react-boilerplate、electron-vue 等现成启动项目，开箱即用。Tauri 则需要自己选择前端框架（React \u002F Vue \u002F Svelte），然后手工集成 Tauri 框架。",[42,2253,2254,2257],{},[170,2255,2256],{},"设计系统","：基于 Electron 的大型应用（VS Code、Slack、Figma）都有完整的设计系统文档公开。Tauri 应用还相对少。",[42,2259,2260,2263],{},[170,2261,2262],{},"问题解答","：Stack Overflow 搜 Electron 问题，通常有现成解答。搜 Tauri 问题时经常找不到人遇过同样的坑。",[42,2265,2266,2269],{},[170,2267,2268],{},"第三方集成","：很多 SaaS 工具（Sentry、Segment、Logrocket）都有 Electron 集成，Tauri 支持相对较弱。",[10,2271,2272],{},"这个项目的应对策略是自建组件库，不依赖第三方 UI 框架。具体规划是：",[268,2274,2277],{"className":2275,"code":2276,"language":273},[271],"自建 16 个组件库\n├─ 基础控件（AppButton、AppInput、AppToggle、AppRadio）\n├─ 反馈组件（AppStatusBadge、AppStatusDot、AppToast、AppModal）\n├─ 容器组件（AppCard、AppGlassPanel、AppPageHeader）\n└─ 布局组件（AppBentoGrid、AppSectionGroup）\n\n工作量估算：\n├─ 设计 token 体系（Tailwind + Material Design 3）：2 天\n├─ 16 个组件实装 + 单测：6 天\n├─ 5 个业务页面集成这些组件：3 天\n└─ 总计：11 天（占 Plan 03 总工期 25 天的 44%）\n",[275,2278,2276],{"__ignoreMap":277},[10,2280,2281],{},"优点：",[39,2283,2284,2287,2290,2293],{},[42,2285,2286],{},"完全掌控 UI 外观和行为",[42,2288,2289],{},"与 Tauri 的集成 100% 可靠（没有第三方包的兼容性问题）",[42,2291,2292],{},"日后维护和修改只需改自己的代码",[42,2294,2295],{},"可以为特定功能优化（比如 Material Design 3 的暗黑主题优化）",[10,2297,2298],{},"缺点：",[39,2300,2301,2304,2307],{},[42,2302,2303],{},"工作量大（44% 的工期用在组件库）",[42,2305,2306],{},"不能复用成熟的设计系统（比如 Ant Design、shadcn\u002Fui）",[42,2308,2309],{},"团队成员需要熟悉 Vue 3 + Tailwind，学习曲线更陡",[10,2311,2312,547],{},[170,2313,2314],{},"适用场景",[39,2316,2317,2320],{},[42,2318,2319],{},"小到中等规模应用（5-20 个页面）：自建组件库是合理的投资",[42,2321,2322],{},"大型应用（50+ 页面、复杂交互）：可能需要重新评估，找现成的 Tauri-compatible UI 库或投入更多资源",[28,2324,2326],{"id":2325},"代价二webview2-版本差异带来的兼容性问题","代价二：WebView2 版本差异带来的兼容性问题",[10,2328,2329],{},"Electron 内置特定版本的 Chromium，所有用户体验完全一致。WebView2 则取决于用户机器上安装的版本。这引入了环境差异：",[10,2331,2332,547],{},[170,2333,2334],{},"版本时间线与分布",[39,2336,2337,2340,2343],{},[42,2338,2339],{},"Windows 11：内置 WebView2（版本通常是最新的或接近最新）",[42,2341,2342],{},"Windows 10：需要从 Microsoft 下载（版本取决于用户是否启用 auto-update）",[42,2344,2345],{},"非常旧的 Windows 版本（Win7、Win8）：不支持 WebView2",[10,2347,2348,547],{},[170,2349,2350],{},"兼容性风险",[10,2352,2353],{},"不同版本的 WebView2 对新 CSS 特性、JavaScript API、性能特征的支持差异很大。例如：",[74,2355,2356,2369],{},[77,2357,2358],{},[80,2359,2360,2363,2366],{},[83,2361,2362],{},"特性",[83,2364,2365],{},"最低版本",[83,2367,2368],{},"常见问题",[93,2370,2371,2386,2397,2411,2427],{},[80,2372,2373,2380,2383],{},[98,2374,2375,2376,2379],{},"CSS Grid ",[275,2377,2378],{},"gap"," 属性",[98,2381,2382],{},"v87",[98,2384,2385],{},"v86 及以下需要用 margin\u002Fpadding",[80,2387,2388,2391,2394],{},[98,2389,2390],{},"CSS Subgrid",[98,2392,2393],{},"v101",[98,2395,2396],{},"对于复杂布局有 workaround",[80,2398,2399,2405,2408],{},[98,2400,2401,2402],{},"JS ",[275,2403,2404],{},"Promise.allSettled()",[98,2406,2407],{},"v85",[98,2409,2410],{},"老版本需要 polyfill",[80,2412,2413,2418,2421],{},[98,2414,2401,2415],{},[275,2416,2417],{},"globalThis",[98,2419,2420],{},"v83",[98,2422,2423,2424,2426],{},"v82 及以下用 ",[275,2425,1238],{}," 代替",[80,2428,2429,2432,2435],{},[98,2430,2431],{},"SharedArrayBuffer",[98,2433,2434],{},"v91",[98,2436,2437],{},"Web Worker 中的高性能共享内存",[10,2439,2440],{},"这个项目设置的最低版本是 v90，意味着：",[39,2442,2443,2446,2449],{},[42,2444,2445],{},"可以用绝大多数现代 CSS 特性",[42,2447,2448],{},"可以用 ES2021 特性",[42,2450,2451],{},"但要避免最新的 v120+ 才有的特性",[10,2453,2454,547],{},[170,2455,2456],{},"对 CSS\u002FJS 开发的影响",[457,2458,2459,2462,2465,2468],{},[42,2460,2461],{},"需要在构建流程中加 polyfill 和 transpile 步骤",[42,2463,2464],{},"某些新 CSS 特性需要 fallback（比如 CSS Anchor Positioning 在 v125+ 才支持，需要降级方案）",[42,2466,2467],{},"测试矩阵需要覆盖多个 WebView2 版本",[42,2469,2470],{},"用户首次启动时可能遇到环境问题，需要友好的错误提示",[10,2472,2473],{},[170,2474,2475],{},"示例：最小兼容性处理",[268,2477,2481],{"className":2478,"code":2479,"language":2480,"meta":277,"style":277},"language-typescript shiki shiki-themes github-light github-dark","\u002F\u002F services\u002Fcompatibility.ts\nexport const features = {\n  cssGap: () => {\n    \u002F\u002F 检测浏览器是否支持 CSS Gap\n    const el = document.createElement('div');\n    el.style.gap = '1px';\n    return el.style.gap !== '';\n  },\n  \n  promiseAllSettled: () => {\n    return typeof Promise.allSettled === 'function';\n  }\n};\n\n\u002F\u002F 在应用启动时检查\nif (!features.cssGap()) {\n  \u002F\u002F 加载 polyfill 或使用 margin-based layout\n  document.body.classList.add('no-css-gap');\n}\n","typescript",[275,2482,2483,2489,2505,2518,2523,2546,2559,2575,2580,2584,2595,2616,2621,2626,2630,2635,2655,2660,2675],{"__ignoreMap":277},[681,2484,2485],{"class":683,"line":684},[681,2486,2488],{"class":2487},"sJ8bj","\u002F\u002F services\u002Fcompatibility.ts\n",[681,2490,2491,2494,2497,2500,2502],{"class":683,"line":690},[681,2492,2493],{"class":738},"export",[681,2495,2496],{"class":738}," const",[681,2498,2499],{"class":746}," features",[681,2501,776],{"class":738},[681,2503,2504],{"class":742}," {\n",[681,2506,2507,2510,2513,2516],{"class":683,"line":696},[681,2508,2509],{"class":755},"  cssGap",[681,2511,2512],{"class":742},": () ",[681,2514,2515],{"class":738},"=>",[681,2517,2504],{"class":742},[681,2519,2520],{"class":683,"line":702},[681,2521,2522],{"class":2487},"    \u002F\u002F 检测浏览器是否支持 CSS Gap\n",[681,2524,2525,2528,2531,2533,2536,2539,2541,2544],{"class":683,"line":708},[681,2526,2527],{"class":738},"    const",[681,2529,2530],{"class":746}," el",[681,2532,776],{"class":738},[681,2534,2535],{"class":742}," document.",[681,2537,2538],{"class":755},"createElement",[681,2540,759],{"class":742},[681,2542,2543],{"class":762},"'div'",[681,2545,766],{"class":742},[681,2547,2548,2551,2553,2556],{"class":683,"line":714},[681,2549,2550],{"class":742},"    el.style.gap ",[681,2552,752],{"class":738},[681,2554,2555],{"class":762}," '1px'",[681,2557,2558],{"class":742},";\n",[681,2560,2561,2564,2567,2570,2573],{"class":683,"line":720},[681,2562,2563],{"class":738},"    return",[681,2565,2566],{"class":742}," el.style.gap ",[681,2568,2569],{"class":738},"!==",[681,2571,2572],{"class":762}," ''",[681,2574,2558],{"class":742},[681,2576,2577],{"class":683,"line":1024},[681,2578,2579],{"class":742},"  },\n",[681,2581,2582],{"class":683,"line":1035},[681,2583,1233],{"class":742},[681,2585,2586,2589,2591,2593],{"class":683,"line":1046},[681,2587,2588],{"class":755},"  promiseAllSettled",[681,2590,2512],{"class":742},[681,2592,2515],{"class":738},[681,2594,2504],{"class":742},[681,2596,2597,2599,2602,2605,2608,2611,2614],{"class":683,"line":1054},[681,2598,2563],{"class":738},[681,2600,2601],{"class":738}," typeof",[681,2603,2604],{"class":746}," Promise",[681,2606,2607],{"class":742},".allSettled ",[681,2609,2610],{"class":738},"===",[681,2612,2613],{"class":762}," 'function'",[681,2615,2558],{"class":742},[681,2617,2618],{"class":683,"line":1068},[681,2619,2620],{"class":742},"  }\n",[681,2622,2623],{"class":683,"line":1079},[681,2624,2625],{"class":742},"};\n",[681,2627,2628],{"class":683,"line":1090},[681,2629,1000],{"emptyLinePlaceholder":999},[681,2631,2632],{"class":683,"line":1101},[681,2633,2634],{"class":2487},"\u002F\u002F 在应用启动时检查\n",[681,2636,2637,2640,2643,2646,2649,2652],{"class":683,"line":1106},[681,2638,2639],{"class":738},"if",[681,2641,2642],{"class":742}," (",[681,2644,2645],{"class":738},"!",[681,2647,2648],{"class":742},"features.",[681,2650,2651],{"class":755},"cssGap",[681,2653,2654],{"class":742},"()) {\n",[681,2656,2657],{"class":683,"line":1114},[681,2658,2659],{"class":2487},"  \u002F\u002F 加载 polyfill 或使用 margin-based layout\n",[681,2661,2662,2665,2668,2670,2673],{"class":683,"line":1134},[681,2663,2664],{"class":742},"  document.body.classList.",[681,2666,2667],{"class":755},"add",[681,2669,759],{"class":742},[681,2671,2672],{"class":762},"'no-css-gap'",[681,2674,766],{"class":742},[681,2676,2677],{"class":683,"line":1151},[681,2678,723],{"class":742},[10,2680,2681],{},"这个项目中的策略是：",[39,2683,2684,2687,2690,2693],{},[42,2685,2686],{},"用 Vite + TypeScript 做编译时检查",[42,2688,2689],{},"用 Tailwind CSS 的兼容性模式",[42,2691,2692],{},"用 @vitejs\u002Fplugin-legacy 处理 ES 新特性",[42,2694,2695],{},"在运行时检查某些关键特性，不支持时给出明确提示",[28,2697,2699],{"id":2698},"代价三社区方案远少于-electron依赖库第三方集成","代价三：社区方案远少于 Electron（依赖库、第三方集成）",[10,2701,2702],{},"Electron 生态中有大量现成的工具和库。需要用户认证？有 electron-oauth2。需要自动更新？有 electron-updater。需要加密存储敏感信息？有 keytar。",[10,2704,2705],{},"Tauri 也有 plugin 系统和逐渐增长的生态，但数量和成熟度都差一截。官方维护的 plugin 包括：",[74,2707,2708,2721],{},[77,2709,2710],{},[80,2711,2712,2715,2718],{},[83,2713,2714],{},"Plugin",[83,2716,2717],{},"功能",[83,2719,2720],{},"成熟度",[93,2722,2723,2734,2745,2755,2765],{},[80,2724,2725,2728,2731],{},[98,2726,2727],{},"tauri-plugin-updater",[98,2729,2730],{},"应用自动更新",[98,2732,2733],{},"⭐⭐⭐⭐",[80,2735,2736,2739,2742],{},[98,2737,2738],{},"tauri-plugin-global-shortcut",[98,2740,2741],{},"全局快捷键",[98,2743,2744],{},"⭐⭐⭐",[80,2746,2747,2750,2753],{},[98,2748,2749],{},"tauri-plugin-shell",[98,2751,2752],{},"执行系统命令",[98,2754,2733],{},[80,2756,2757,2760,2763],{},[98,2758,2759],{},"tauri-plugin-clipboard",[98,2761,2762],{},"剪贴板访问",[98,2764,2744],{},[80,2766,2767,2770,2773],{},[98,2768,2769],{},"tauri-plugin-http",[98,2771,2772],{},"HTTP 请求",[98,2774,2733],{},[10,2776,2777],{},"与 Electron 的生态相比，覆盖面积大约是 40-50%。某些需求可能需要自己实现。",[10,2779,2780,547],{},[170,2781,2782],{},"这个项目中的例子",[10,2784,2785],{},[170,2786,2787],{},"自更新（tauri-plugin-updater）",[10,2789,2790],{},"tauri-plugin-updater 支持检查更新、下载、验证签名、安装，整个流程都有。但文档相对基础，没有像 Electron Updater 那样完整的例子。第一次使用时需要研究源码才能理解：",[39,2792,2793,2796,2799,2802],{},[42,2794,2795],{},"manifest 文件的确切格式",[42,2797,2798],{},"签名验证的密钥配置",[42,2800,2801],{},"不同 platform 的版本管理",[42,2803,2804],{},"回滚和降级的策略",[10,2806,2807],{},"Electron Updater 在这些方面文档更详尽。",[10,2809,2810],{},[170,2811,2812],{},"关键词搜索的缺失",[10,2814,2815],{},"想做个全局快捷键（比如 Ctrl+Shift+A 快速打开配置中心）？Electron 有 electron-shortcut。Tauri 有 tauri-plugin-global-shortcut，功能也不差。但如果想要更复杂的行为（快捷键冲突检测、快捷键绑定记录、动态解绑等），生态里没有现成的轮子。需要自己在插件基础上二次开发。",[10,2817,2818],{},[170,2819,2820],{},"后台进程管理",[10,2822,2823],{},"想让配置中心在后台运行时保持某些定时任务（比如每分钟检查一次 Gateway 健康状态）？Electron 可以用 ipcMain 和 Node.js 的 setInterval。Tauri 中这些能力也有（tokio task、async\u002Fawait），但需要对 Rust async 编程有一定理解。如果前端开发者不熟悉 Rust，这会成为障碍。",[10,2825,2826,547],{},[170,2827,2828],{},"影响评估",[10,2830,2831],{},"这个项目的范围相对有限（5 个主页面 + 首启引导 + 托盘菜单），所以社区方案少带来的影响有限。",[10,2833,2834],{},"若规模变大：",[39,2836,2837,2840,2843],{},[42,2838,2839],{},"50+ 页面的应用：社区工具的缺失会成为显著的痛点",[42,2841,2842],{},"需要复杂的权限系统、日志收集、错误追踪的应用：生态支持不足会推高开发成本",[42,2844,2845],{},"需要跨多个平台的应用（Windows\u002FMac\u002FLinux）：Tauri 的优势最大（WebView 差异多）",[14,2847,2848],{"id":2848},"数字汇总与成本效益",[74,2850,2851,2866],{},[77,2852,2853],{},[80,2854,2855,2858,2861,2863],{},[83,2856,2857],{},"指标",[83,2859,2860],{},"Electron 方案",[83,2862,2164],{},[83,2864,2865],{},"收益",[93,2867,2868,2885,2903,2921,2938,2956,2974,2992],{},[80,2869,2870,2875,2878,2880],{},[98,2871,2872],{},[170,2873,2874],{},"应用体积",[98,2876,2877],{},"60-80 MB",[98,2879,103],{},[98,2881,2882],{},[170,2883,2884],{},"-75% 体积",[80,2886,2887,2892,2895,2898],{},[98,2888,2889],{},[170,2890,2891],{},"首次启动时间",[98,2893,2894],{},"~2-3 秒",[98,2896,2897],{},"≤ 1 秒",[98,2899,2900],{},[170,2901,2902],{},"-67% 启动",[80,2904,2905,2910,2913,2916],{},[98,2906,2907],{},[170,2908,2909],{},"运行时内存",[98,2911,2912],{},"300-500 MB（单实例）",[98,2914,2915],{},"30-50 MB",[98,2917,2918],{},[170,2919,2920],{},"-90% 内存",[80,2922,2923,2928,2930,2933],{},[98,2924,2925],{},[170,2926,2927],{},"版本更新包（前端改动）",[98,2929,206],{},[98,2931,2932],{},"50-300 KB",[98,2934,2935],{},[170,2936,2937],{},"-99% 增量",[80,2939,2940,2945,2948,2951],{},[98,2941,2942],{},[170,2943,2944],{},"代码复用率（与 Launcher）",[98,2946,2947],{},"~15%（需要 native module）",[98,2949,2950],{},"~60%（直接导入）",[98,2952,2953],{},[170,2954,2955],{},"+45% 复用",[80,2957,2958,2963,2966,2969],{},[98,2959,2960],{},[170,2961,2962],{},"前端编译时间",[98,2964,2965],{},"~60s",[98,2967,2968],{},"~10s",[98,2970,2971],{},[170,2972,2973],{},"-83% 编译",[80,2975,2976,2981,2984,2987],{},[98,2977,2978],{},[170,2979,2980],{},"开发依赖数",[98,2982,2983],{},"400+ npm packages",[98,2985,2986],{},"180+ npm + 50 cargo",[98,2988,2989],{},[170,2990,2991],{},"-40% 总依赖",[80,2993,2994,2999,3002,3005],{},[98,2995,2996],{},[170,2997,2998],{},"系统 API 调用",[98,3000,3001],{},"需要原生模块 + npm 包",[98,3003,3004],{},"直接 windows-rs",[98,3006,3007],{},[170,3008,3009],{},"0 中间层",[10,3011,3012,3015],{},[170,3013,3014],{},"成本效益计算","（针对 1000 活跃用户的产品）：",[74,3017,3018,3033],{},[77,3019,3020],{},[80,3021,3022,3025,3028,3031],{},[83,3023,3024],{},"维度",[83,3026,3027],{},"Electron 年成本",[83,3029,3030],{},"Tauri 年成本",[83,3032,568],{},[93,3034,3035,3051,3067,3083,3099],{},[80,3036,3037,3040,3043,3046],{},[98,3038,3039],{},"网络流量（CDN）",[98,3041,3042],{},"$7,200",[98,3044,3045],{},"$2,400",[98,3047,3048],{},[170,3049,3050],{},"$4,800",[80,3052,3053,3056,3059,3062],{},[98,3054,3055],{},"服务器存储（版本历史）",[98,3057,3058],{},"$1,200",[98,3060,3061],{},"$400",[98,3063,3064],{},[170,3065,3066],{},"$800",[80,3068,3069,3072,3075,3078],{},[98,3070,3071],{},"开发维护（系统集成 Bug）",[98,3073,3074],{},"$6,000（3 人月）",[98,3076,3077],{},"$2,000（1 人月）",[98,3079,3080],{},[170,3081,3082],{},"$4,000",[80,3084,3085,3088,3091,3094],{},[98,3086,3087],{},"编译 + 构建时间成本",[98,3089,3090],{},"$3,000（150 小时）",[98,3092,3093],{},"$500（25 小时）",[98,3095,3096],{},[170,3097,3098],{},"$2,500",[80,3100,3101,3104,3109,3114],{},[98,3102,3103],{},"总成本",[98,3105,3106],{},[170,3107,3108],{},"$17,400",[98,3110,3111],{},[170,3112,3113],{},"$5,300",[98,3115,3116],{},[170,3117,3118],{},"$12,100（69% 节省）",[10,3120,3121],{},"假设开发人员时薪 $100\u002F小时，这个节省对小团队来说很显著。",[14,3123,3125],{"id":3124},"决策总结约束驱动的选择","决策总结：约束驱动的选择",[10,3127,3128],{},"这不是\"Tauri 比 Electron 更好\"的宣言，而是\"在这个具体约束集合下，Tauri 的权衡更优\"的决策记录。",[10,3130,3131,547],{},[170,3132,3133],{},"约束集合",[457,3135,3136,3139,3142,3145],{},[42,3137,3138],{},"便携产品，U 盘交付，体积敏感（约束 A）",[42,3140,3141],{},"已有 Rust 后端（Launcher），代码复用机会大（约束 B）",[42,3143,3144],{},"Windows 系统集成需求明确（约束 C）",[42,3146,3147],{},"团队熟悉 Rust，学习新框架成本可控（约束 D）",[10,3149,3150,547],{},[170,3151,3152],{},"若约束变化，决策也会变化",[39,3154,3155,3161,3167,3173],{},[42,3156,3157,3160],{},[170,3158,3159],{},"约束 A 不再存在","（比如改为云应用，不再需要便携）：Electron 的价值中立化（不需要考虑体积）。此时代价一（前端生态）会变成主导因素，Electron 重新成为首选。",[42,3162,3163,3166],{},[170,3164,3165],{},"约束 B 变弱","（比如后端改用 Node.js）：代码复用的优势消失。此时需要重新评估 Electron 的前端生态优势 vs Tauri 的内存\u002F性能优势。",[42,3168,3169,3172],{},[170,3170,3171],{},"约束 C 减弱","（比如 Linux 支持变重要）：Windows API 的优势边际化。Tauri 的跨平台 WebView 差异会变成劣势（需要在三个平台上调试兼容性）。Electron 自带 Chromium 的\"一致性\"优势会相对凸显。",[42,3174,3175,3178],{},[170,3176,3177],{},"约束 D 变化","（比如加入前端工程师，Rust 不熟悉）：自建组件库的成本会上升，Electron 的成熟生态会更有吸引力。",[14,3180,3181],{"id":3181},"后续债务与演进规划",[10,3183,3184,547],{},[170,3185,3186],{},"Plan 03（当前）实装",[39,3188,3189,3192,3195],{},[42,3190,3191],{},"Tauri 2 MVP（5 主页 + 首启引导）",[42,3193,3194],{},"基础系统集成（托盘、WebView2 检测、单实例）",[42,3196,3197],{},"自建 16 组件库",[10,3199,3200,547],{},[170,3201,3202],{},"Plan 04（后续债务）",[39,3204,3205,3208],{},[42,3206,3207],{},"应用自更新真实集成（tauri-plugin-updater + KMS 签名，debt-03-F）",[42,3209,3210],{},"Gateway 状态事件化（Launcher NamedPipe 推送 vs 轮询，debt-03-E）",[10,3212,3213,547],{},[170,3214,3215],{},"Plan 08（更远期）",[39,3217,3218,3221],{},[42,3219,3220],{},"Gateway 重启能力（GatewayController 状态机，debt-08-G）",[42,3222,3223],{},"更多 Windows 系统集成（文件关联、快捷方式等）",[3225,3226,3227],"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 .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 pre.shiki code .sj4cs, html code.shiki .sj4cs{--shiki-default:#005CC5;--shiki-dark:#79B8FF}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 pre.shiki code .sJ8bj, html code.shiki .sJ8bj{--shiki-default:#6A737D;--shiki-dark:#6A737D}html pre.shiki code .s9eBZ, html code.shiki .s9eBZ{--shiki-default:#22863A;--shiki-dark:#85E89D}",{"title":277,"searchDepth":690,"depth":690,"links":3229},[3230,3231,3236,3242,3247,3252,3257,3258,3259],{"id":16,"depth":690,"text":16},{"id":25,"depth":690,"text":26,"children":3232},[3233,3234,3235],{"id":30,"depth":696,"text":31},{"id":65,"depth":696,"text":66},{"id":259,"depth":696,"text":260},{"id":286,"depth":690,"text":287,"children":3237},[3238,3239,3240,3241],{"id":290,"depth":696,"text":291},{"id":333,"depth":696,"text":333},{"id":436,"depth":696,"text":437},{"id":531,"depth":696,"text":532},{"id":826,"depth":690,"text":827,"children":3243},[3244,3245,3246],{"id":830,"depth":696,"text":830},{"id":946,"depth":696,"text":946},{"id":1360,"depth":696,"text":1360},{"id":1498,"depth":690,"text":1499,"children":3248},[3249,3250,3251],{"id":1502,"depth":696,"text":1503},{"id":2089,"depth":696,"text":2090},{"id":2156,"depth":696,"text":2156},{"id":2228,"depth":690,"text":2228,"children":3253},[3254,3255,3256],{"id":2234,"depth":696,"text":2235},{"id":2325,"depth":696,"text":2326},{"id":2698,"depth":696,"text":2699},{"id":2848,"depth":690,"text":2848},{"id":3124,"depth":690,"text":3125},{"id":3181,"depth":690,"text":3181},"Agent 平台","2026-04-21","md",null,{},"\u002F2026-04-21-tauri2electron",{"title":5,"description":12},"2026-04-21-Tauri2而不是Electron四个理由","便携 AI 助手的配置中心为什么选 Tauri 2 而不是默认答案 Electron，涉及包体积、工程复用、更新成本、系统集成四个决策点与代价分析。",[193,190,3270,3271,3272,3273],"桌面应用","Rust","Windows","架构设计","G5mnvD-EG-eLRR2FJTVU-hlbFbJuzRNxZK8sF78qxfM",[3276,3965,4609],{"id":3277,"title":3278,"body":3279,"column":3260,"date":3950,"description":3283,"extension":3262,"hero_image":3263,"meta":3951,"navigation":999,"path":3952,"seo":3953,"series_id":3263,"severity":3263,"stem":3954,"summary":3955,"tags":3956,"__hash__":3964},"posts\u002F2026-08-31-阈值不能推只能量.md","阈值不能推，只能量",{"type":7,"value":3280,"toc":3941},[3281,3284,3287,3291,3294,3300,3303,3306,3312,3315,3318,3323,3326,3337,3362,3369,3372,3376,3387,3390,3396,3399,3402,3405,3410,3413,3416,3471,3474,3477,3534,3537,3542,3546,3549,3555,3558,3561,3565,3568,3574,3577,3583,3586,3589,3595,3601,3604,3607,3613,3616,3659,3662,3666,3669,3672,3678,3681,3684,3688,3694,3756,3759,3851,3854,3857,3905,3908,3912,3915,3925,3928,3935,3938],[10,3282,3283],{},"知识库的检索参数有三组：分块阈值、召回阈值、精排阈值。这一天把三组都动了一遍，每一组都留下了实测数据。",[10,3285,3286],{},"起因是一个看不太出来的现象：知识库好像没被用上。",[14,3288,3290],{"id":3289},"一分块中位数-212-字","一、分块：中位数 212 字",[10,3292,3293],{},"先量现状。生产库里的分块统计：",[3295,3296,3297],"blockquote",{},[10,3298,3299],{},"13244 个分块中位数只有 212 字，而目标 2400 字，62% 的块不足 300 字。",[10,3301,3302],{},"目标块大小是 2400 字，实际交付的是 212。差了十倍。",[10,3304,3305],{},"分块器原来的逻辑是「一个标题一个块」。这在正常文档上没问题，但清单型、模板型文档里，几乎每一行列表项都会被标题识别逻辑认成标题：",[268,3307,3310],{"className":3308,"code":3309,"language":273},[271],"1. 原本想达成什么？\n",[275,3311,3309],{"__ignoreMap":277},[10,3313,3314],{},"这一行被当成标题，于是自己成了一个块。一篇文档被切成几十个几十字的碎片，注入给模型的全是碎片。",[10,3316,3317],{},"更严重的情况在连续列表项之间没有正文时。老实现让标题行只活在「面包屑」元数据里，不写进块正文。于是被误判成标题的那一行文字直接消失：",[3295,3319,3320],{},[10,3321,3322],{},"标题行只活在面包屑里，整行文字直接丢失（那类文档的四个核心问题在索引里根本不存在）。",[10,3324,3325],{},"两处改动：",[268,3327,3331],{"className":3328,"code":3329,"language":3330,"meta":277,"style":277},"language-ts shiki shiki-themes github-light github-dark","\u002F\u002F 攒够了才在这里切：标题是「首选切点」，不是「强制切点」。\n","ts",[275,3332,3333],{"__ignoreMap":277},[681,3334,3335],{"class":683,"line":684},[681,3336,3329],{"class":2487},[268,3338,3340],{"className":3328,"code":3339,"language":3330,"meta":277,"style":277},"\u002F\u002F 不变量：每一行输入都要落进某个块的正文，面包屑只是附加元数据。\n\u002F\u002F 老实现让标题行只活在面包屑里，于是被 detectHeading 误判成标题的列表项\n\u002F\u002F （「1. 原本想达成什么？」这类）整行文字就没了——连续几个列表项时只留得住最后一条。\n\u002F\u002F 与面包屑重复一次可以接受，丢字不行。\n",[275,3341,3342,3347,3352,3357],{"__ignoreMap":277},[681,3343,3344],{"class":683,"line":684},[681,3345,3346],{"class":2487},"\u002F\u002F 不变量：每一行输入都要落进某个块的正文，面包屑只是附加元数据。\n",[681,3348,3349],{"class":683,"line":690},[681,3350,3351],{"class":2487},"\u002F\u002F 老实现让标题行只活在面包屑里，于是被 detectHeading 误判成标题的列表项\n",[681,3353,3354],{"class":683,"line":696},[681,3355,3356],{"class":2487},"\u002F\u002F （「1. 原本想达成什么？」这类）整行文字就没了——连续几个列表项时只留得住最后一条。\n",[681,3358,3359],{"class":683,"line":702},[681,3360,3361],{"class":2487},"\u002F\u002F 与面包屑重复一次可以接受，丢字不行。\n",[10,3363,3364,3365,3368],{},"标题从「强制切点」降为「首选切点」：缓冲区不足阈值时，标题并入当前块，不切。同时标题行本身写进正文，成为一条不变量——",[170,3366,3367],{},"每一行输入都要落进某个块的正文","。",[10,3370,3371],{},"实测效果：一篇文档从 3 块 52\u002F67\u002F100 字（四个核心问题全丢）变成 1 块 235 字，内容完整。",[14,3373,3375],{"id":3374},"二按比例推算推出了全场最差点","二、按比例推算，推出了全场最差点",[10,3377,3378,3379,3382,3383,3386],{},"第一版把阈值定成 ",[275,3380,3381],{},"maxChars × 0.6","。生产 ",[275,3384,3385],{},"maxChars"," 是 2400，算出来是 1440。",[10,3388,3389],{},"结果整篇文档并成一个块。上线后实测检索：",[268,3391,3394],{"className":3392,"code":3393,"language":273},[271],"5 篇文档 6 个查询，每篇取最佳命中分再平均\n  阈值 0（纯按小节切） 0.7046\n  阈值 250            0.6750\n  阈值 1440（线上）    0.4978\n",[275,3395,3393],{"__ignoreMap":277},[10,3397,3398],{},"1440 正好是最差的那个。",[10,3400,3401],{},"同一篇文档对「核心四问」这个查询，切成小节时得分 0.77，并成整块时只有 0.33——在全库 113 块里排到第 109 名。内容修好了，却再也检索不到。",[10,3403,3404],{},"根因在生产用的向量模型上：",[3295,3406,3407],{},[10,3408,3409],{},"生产 embedding 模型对「主题聚焦的小段」打分远高于「整篇文档」。",[10,3411,3412],{},"原来那个「一个标题一个块」的设计，主题纯度是对的。推翻它是错的判断。真正的缺陷只有一条——标题行被丢弃。",[10,3414,3415],{},"最终取值 250：",[268,3417,3419],{"className":3328,"code":3418,"language":3330,"meta":277,"style":277},"\u002F**\n * 缺省小节合并阈值。250 是在生产 embedding 模型上实测标定的，不是拍脑袋：\n * 5 篇文档 6 个查询，取每篇的最佳命中分做平均——\n *   阈值 0（纯按小节切）0.7046 \u002F 250 → 0.6750 \u002F 1440（= maxChars×0.6）→ 0.4978\n * 这个模型对「主题聚焦的小段」打分远高于「整篇文档」……\n * 所以阈值必须小，千万别再按 maxChars 的比例去推——那样在生产的 2400 上会算出 1440，\n * 正好是最差点。\n * 取 250 而不是 0：排序只差 4%，但块从几十字变成 250~330 字，\n * 同样召回 6 段能多喂两三倍的正文。\n *\u002F\n",[275,3420,3421,3426,3431,3436,3441,3446,3451,3456,3461,3466],{"__ignoreMap":277},[681,3422,3423],{"class":683,"line":684},[681,3424,3425],{"class":2487},"\u002F**\n",[681,3427,3428],{"class":683,"line":690},[681,3429,3430],{"class":2487}," * 缺省小节合并阈值。250 是在生产 embedding 模型上实测标定的，不是拍脑袋：\n",[681,3432,3433],{"class":683,"line":696},[681,3434,3435],{"class":2487}," * 5 篇文档 6 个查询，取每篇的最佳命中分做平均——\n",[681,3437,3438],{"class":683,"line":702},[681,3439,3440],{"class":2487}," *   阈值 0（纯按小节切）0.7046 \u002F 250 → 0.6750 \u002F 1440（= maxChars×0.6）→ 0.4978\n",[681,3442,3443],{"class":683,"line":708},[681,3444,3445],{"class":2487}," * 这个模型对「主题聚焦的小段」打分远高于「整篇文档」……\n",[681,3447,3448],{"class":683,"line":714},[681,3449,3450],{"class":2487}," * 所以阈值必须小，千万别再按 maxChars 的比例去推——那样在生产的 2400 上会算出 1440，\n",[681,3452,3453],{"class":683,"line":720},[681,3454,3455],{"class":2487}," * 正好是最差点。\n",[681,3457,3458],{"class":683,"line":1024},[681,3459,3460],{"class":2487}," * 取 250 而不是 0：排序只差 4%，但块从几十字变成 250~330 字，\n",[681,3462,3463],{"class":683,"line":1035},[681,3464,3465],{"class":2487}," * 同样召回 6 段能多喂两三倍的正文。\n",[681,3467,3468],{"class":683,"line":1046},[681,3469,3470],{"class":2487}," *\u002F\n",[10,3472,3473],{},"取 250 而不是 0 的理由是这段话里第二重要的部分：排序只差 4%，但每个块从几十字变成两三百字，同样召回 6 段能多喂几倍的正文。",[10,3475,3476],{},"代码里还留了一条兜底：",[268,3478,3480],{"className":3328,"code":3479,"language":3330,"meta":277,"style":277},"\u002F\u002F 取 min：maxChars 很小的配置（测试里 300）不能让阈值反超块大小本身\nconst minChunkChars =\n  opts.minChunkChars ?? Math.min(DEFAULT_MIN_CHUNK_CHARS, Math.floor(maxChars * 0.6));\n",[275,3481,3482,3487,3497],{"__ignoreMap":277},[681,3483,3484],{"class":683,"line":684},[681,3485,3486],{"class":2487},"\u002F\u002F 取 min：maxChars 很小的配置（测试里 300）不能让阈值反超块大小本身\n",[681,3488,3489,3491,3494],{"class":683,"line":690},[681,3490,739],{"class":738},[681,3492,3493],{"class":746}," minChunkChars",[681,3495,3496],{"class":738}," =\n",[681,3498,3499,3502,3505,3508,3511,3513,3516,3519,3522,3525,3528,3531],{"class":683,"line":696},[681,3500,3501],{"class":742},"  opts.minChunkChars ",[681,3503,3504],{"class":738},"??",[681,3506,3507],{"class":742}," Math.",[681,3509,3510],{"class":755},"min",[681,3512,759],{"class":742},[681,3514,3515],{"class":746},"DEFAULT_MIN_CHUNK_CHARS",[681,3517,3518],{"class":742},", Math.",[681,3520,3521],{"class":755},"floor",[681,3523,3524],{"class":742},"(maxChars ",[681,3526,3527],{"class":738},"*",[681,3529,3530],{"class":746}," 0.6",[681,3532,3533],{"class":742},"));\n",[10,3535,3536],{},"以及一条守卫测试，专门防「有人再推一遍公式」：",[3295,3538,3539],{},[10,3540,3541],{},"加一条守卫测试钉死生产口径（阈值退回按比例推算即变红），防止将来有人再推一遍公式又回到 1440。",[14,3543,3545],{"id":3544},"三召回阈值会把整轮清零","三、召回阈值：会把整轮清零",[10,3547,3548],{},"分块改大之后，绝对余弦分整体下移。而召回阈值还是旧值 0.5：",[268,3550,3553],{"className":3551,"code":3552,"language":273},[271],"正确命中落在 0.43~0.63，旧值会把最高分 0.488 的查询整轮清零——\n检索到了正确文档却被门槛全部丢弃，用户看到的就是「知识库没被使用」。\n",[275,3554,3552],{"__ignoreMap":277},[10,3556,3557],{},"这条解释了我一开始看到的那个现象。检索其实命中了，只是分数没过门槛，于是整轮被丢掉，模型什么也没拿到。",[10,3559,3560],{},"改成 0.35。",[14,3562,3564],{"id":3563},"四精排从-912-到-1212","四、精排：从 9\u002F12 到 12\u002F12",[10,3566,3567],{},"同一天启用了 cross-encoder 精排。A\u002FB 实测：",[268,3569,3572],{"className":3570,"code":3571,"language":273},[271],"12 条查询 A\u002FB 实测：\n  纯向量  命中 9\u002F12，整轮零注入 1 次\n  开精排  命中 12\u002F12，整轮零注入 0 次，目标文档 10 条排第 1\n",[275,3573,3571],{"__ignoreMap":277},[10,3575,3576],{},"修好的都是「换了说法」的查询——向量检索的固有短板。举一个例子：",[268,3578,3581],{"className":3579,"code":3580,"language":273},[271],"「我想要复刻爆款视频…使用画布」注入 0 段 → 4 段\n（相关文档被向量埋在后面，精排提到第 2 名）\n",[275,3582,3580],{"__ignoreMap":277},[10,3584,3585],{},"精排启用时把两个阈值也一起改了。这是当天最曲折的一处。",[10,3587,3588],{},"第一版把精排阈值设成 0.15。发版后跑完整测试，出现随机漏召。",[10,3590,3591,3592,547],{},"原因是精排的",[170,3593,3594],{},"绝对分不稳定",[268,3596,3599],{"className":3597,"code":3598,"language":273},[271],"同一查询同一候选集，「金字塔原理怎么做到结论先行」两次实测 0.251 与 0.135——\n排名都稳定第 1，只是分数漂了近一半。0.15 卡在中间，于是第二次整轮零注入。\n",[275,3600,3598],{"__ignoreMap":277},[10,3602,3603],{},"排名是稳定的，分数会漂。所以不能用绝对分当门槛去「把关质量」。",[10,3605,3606],{},"阈值扫描：",[268,3608,3611],{"className":3609,"code":3610,"language":273},[271],"0.02~0.10 均 12\u002F12 零丢弃，0.15\u002F0.20 → 11\u002F12 丢 1 次\n",[275,3612,3610],{"__ignoreMap":277},[10,3614,3615],{},"最终取 0.05，并把职责写清楚：",[268,3617,3619],{"className":3328,"code":3618,"language":3330,"meta":277,"style":277},"\u002F**\n * 精排阈值。**它的职责只是扔掉垃圾，不是把关质量**——质量由排序保证：\n * 12 条查询里精排把正确目标全部放进了前 3 名（10 条第 1）。\n *\n * 取 0.05 而不是更高，是因为**精排的绝对分不稳定**……\n * 观测到的正确目标最低分 0.131，取 0.05 留约 60% 余量；\n * 垃圾档在 0.014~0.025，仍被干净滤掉。\n *\u002F\n",[275,3620,3621,3625,3630,3635,3640,3645,3650,3655],{"__ignoreMap":277},[681,3622,3623],{"class":683,"line":684},[681,3624,3425],{"class":2487},[681,3626,3627],{"class":683,"line":690},[681,3628,3629],{"class":2487}," * 精排阈值。**它的职责只是扔掉垃圾，不是把关质量**——质量由排序保证：\n",[681,3631,3632],{"class":683,"line":696},[681,3633,3634],{"class":2487}," * 12 条查询里精排把正确目标全部放进了前 3 名（10 条第 1）。\n",[681,3636,3637],{"class":683,"line":702},[681,3638,3639],{"class":2487}," *\n",[681,3641,3642],{"class":683,"line":708},[681,3643,3644],{"class":2487}," * 取 0.05 而不是更高，是因为**精排的绝对分不稳定**……\n",[681,3646,3647],{"class":683,"line":714},[681,3648,3649],{"class":2487}," * 观测到的正确目标最低分 0.131，取 0.05 留约 60% 余量；\n",[681,3651,3652],{"class":683,"line":720},[681,3653,3654],{"class":2487}," * 垃圾档在 0.014~0.025，仍被干净滤掉。\n",[681,3656,3657],{"class":683,"line":1024},[681,3658,3470],{"class":2487},[10,3660,3661],{},"观测到的垃圾档在 0.014~0.025，正确目标最低 0.131。0.05 落在两者之间，两边都有余量。",[14,3663,3665],{"id":3664},"五超时静默降级最贵","五、超时：静默降级最贵",[10,3667,3668],{},"同一批里还修了超时。",[10,3670,3671],{},"精排失败会自动降级回向量序，不阻断聊天。这个设计是对的——但有代价：",[268,3673,3676],{"className":3674,"code":3675,"language":273},[271],"法律知识库 30 块共 4.5 万字，冷调用实测 2263ms，只剩 737ms 余量。\n完整测试里有一条查询当场超时降级。\n超时是静默的，日志不报错，用户侧表现为「有时准有时不准」，极难归因。\n",[275,3677,3675],{"__ignoreMap":277},[10,3679,3680],{},"「有时准有时不准」这句话在这一天的上下文里已经出现第二次了——早上是参考图，这里是检索。",[10,3682,3683],{},"超时从 3000 毫秒提到 8000，给冷调用 3.5 倍余量。真卡死仍有降级兜底。",[14,3685,3687],{"id":3686},"六三个阈值放在一起","六、三个阈值放在一起",[10,3689,3690,3691],{},"三条修正的形式不同，结论是同一条：",[170,3692,3693],{},"阈值不能推导，只能测量。",[74,3695,3696,3712],{},[77,3697,3698],{},[80,3699,3700,3703,3706,3709],{},[83,3701,3702],{},"参数",[83,3704,3705],{},"推导值",[83,3707,3708],{},"实测值",[83,3710,3711],{},"推导错在哪",[93,3713,3714,3728,3742],{},[80,3715,3716,3719,3722,3725],{},[98,3717,3718],{},"分块合并阈值",[98,3720,3721],{},"1440（按比例 0.6）",[98,3723,3724],{},"250",[98,3726,3727],{},"假设「块越大越好」，而该模型偏好主题聚焦的小段",[80,3729,3730,3733,3736,3739],{},[98,3731,3732],{},"召回阈值",[98,3734,3735],{},"0.5（惯例值）",[98,3737,3738],{},"0.35",[98,3740,3741],{},"分块改动后分数分布整体下移，旧门槛把命中全丢",[80,3743,3744,3747,3750,3753],{},[98,3745,3746],{},"精排阈值",[98,3748,3749],{},"0.15（留余量）",[98,3751,3752],{},"0.05",[98,3754,3755],{},"绝对分本身会漂近一半，拿漂移量当门槛必然随机漏召",[10,3757,3758],{},"三个值都有实测数字支撑，也都配了守卫测试。其中两条测试写的是「不许回到某个值」，而不是「必须等于某值」：",[268,3760,3762],{"className":3328,"code":3761,"language":3330,"meta":277,"style":277},"it(\"向量阈值不得回到会整轮清零的 0.5\", () => {\n  expect(DEFAULT_KB_MIN_SCORE).toBeLessThanOrEqual(0.4);\n});\n\nit(\"精排阈值不得高到会随分数漂移漏召——观测最低正确分 0.131\", () => {\n  expect(DEFAULT_KB_RERANK_MIN_SCORE).toBeLessThanOrEqual(0.1);\n});\n",[275,3763,3764,3781,3804,3808,3812,3827,3847],{"__ignoreMap":277},[681,3765,3766,3769,3771,3774,3777,3779],{"class":683,"line":684},[681,3767,3768],{"class":755},"it",[681,3770,759],{"class":742},[681,3772,3773],{"class":762},"\"向量阈值不得回到会整轮清零的 0.5\"",[681,3775,3776],{"class":742},", () ",[681,3778,2515],{"class":738},[681,3780,2504],{"class":742},[681,3782,3783,3786,3788,3791,3794,3797,3799,3802],{"class":683,"line":690},[681,3784,3785],{"class":755},"  expect",[681,3787,759],{"class":742},[681,3789,3790],{"class":746},"DEFAULT_KB_MIN_SCORE",[681,3792,3793],{"class":742},").",[681,3795,3796],{"class":755},"toBeLessThanOrEqual",[681,3798,759],{"class":742},[681,3800,3801],{"class":746},"0.4",[681,3803,766],{"class":742},[681,3805,3806],{"class":683,"line":696},[681,3807,1794],{"class":742},[681,3809,3810],{"class":683,"line":702},[681,3811,1000],{"emptyLinePlaceholder":999},[681,3813,3814,3816,3818,3821,3823,3825],{"class":683,"line":708},[681,3815,3768],{"class":755},[681,3817,759],{"class":742},[681,3819,3820],{"class":762},"\"精排阈值不得高到会随分数漂移漏召——观测最低正确分 0.131\"",[681,3822,3776],{"class":742},[681,3824,2515],{"class":738},[681,3826,2504],{"class":742},[681,3828,3829,3831,3833,3836,3838,3840,3842,3845],{"class":683,"line":714},[681,3830,3785],{"class":755},[681,3832,759],{"class":742},[681,3834,3835],{"class":746},"DEFAULT_KB_RERANK_MIN_SCORE",[681,3837,3793],{"class":742},[681,3839,3796],{"class":755},[681,3841,759],{"class":742},[681,3843,3844],{"class":746},"0.1",[681,3846,766],{"class":742},[681,3848,3849],{"class":683,"line":720},[681,3850,1794],{"class":742},[10,3852,3853],{},"这样写是因为真正的风险不是「有人改了数值」，而是「有人又推了一遍公式」。测试防的是后者。",[10,3855,3856],{},"还有一条测试值得抄下来：",[268,3858,3860],{"className":3328,"code":3859,"language":3330,"meta":277,"style":277},"it(\"精排阈值必须低于向量阈值——两者不是同一个量纲\", () => {\n  \u002F\u002F 精排是 cross-encoder 分，向量是余弦分，拿同一个数去卡两边必然有一边错\n  expect(DEFAULT_KB_RERANK_MIN_SCORE).toBeLessThan(DEFAULT_KB_MIN_SCORE);\n});\n",[275,3861,3862,3877,3882,3901],{"__ignoreMap":277},[681,3863,3864,3866,3868,3871,3873,3875],{"class":683,"line":684},[681,3865,3768],{"class":755},[681,3867,759],{"class":742},[681,3869,3870],{"class":762},"\"精排阈值必须低于向量阈值——两者不是同一个量纲\"",[681,3872,3776],{"class":742},[681,3874,2515],{"class":738},[681,3876,2504],{"class":742},[681,3878,3879],{"class":683,"line":690},[681,3880,3881],{"class":2487},"  \u002F\u002F 精排是 cross-encoder 分，向量是余弦分，拿同一个数去卡两边必然有一边错\n",[681,3883,3884,3886,3888,3890,3892,3895,3897,3899],{"class":683,"line":696},[681,3885,3785],{"class":755},[681,3887,759],{"class":742},[681,3889,3835],{"class":746},[681,3891,3793],{"class":742},[681,3893,3894],{"class":755},"toBeLessThan",[681,3896,759],{"class":742},[681,3898,3790],{"class":746},[681,3900,766],{"class":742},[681,3902,3903],{"class":683,"line":702},[681,3904,1794],{"class":742},[10,3906,3907],{},"同一个数量级、看起来可比，实际是两个不同的量纲。这种错觉在阈值调参里很常见，能用一条断言拦下来比写在注释里可靠。",[14,3909,3911],{"id":3910},"七评测集的缺口","七、评测集的缺口",[10,3913,3914],{},"这一轮所有数字来自临时扫的查询集：5 篇文档 6 个查询、12 条查询。",[10,3916,3917,3918,3921,3922,3368],{},"仓库里确实有一个召回评测脚本（用户问题、期望命中的文档名、recall@K \u002F precision@K \u002F MRR 双模式），但它的标注集还是占位内容——两条用例，",[275,3919,3920],{},"kbIds"," 的值是字面量 ",[275,3923,3924],{},"\u003C替换为真实 kbId>",[10,3926,3927],{},"也就是说，这一轮的标定用的数据集没有入库。数字留在代码注释、配置和环境变量说明里，跑分的脚本和查询集没有留下。",[10,3929,3930,3931,3934],{},"这是这次工作里最该补上的一环。",[170,3932,3933],{},"阈值有实测依据，但依据本身不可复现。"," 下次有人想调整，只能重新扫一遍查询。",[10,3936,3937],{},"补的做法是明确的：把这一轮用的查询集和期望结果补进评测脚本的标注集，让它成为下一次调参的起点。参数变更时先跑评测再改值，改完之后把新的分数写进注释——注释里的数字应该来自一次可复现的运行，而不是一次性的手测。",[3225,3939,3940],{},"html pre.shiki code .sJ8bj, html code.shiki .sJ8bj{--shiki-default:#6A737D;--shiki-dark:#6A737D}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 .sj4cs, html code.shiki .sj4cs{--shiki-default:#005CC5;--shiki-dark:#79B8FF}html pre.shiki code .sVt8B, html code.shiki .sVt8B{--shiki-default:#24292E;--shiki-dark:#E1E4E8}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}",{"title":277,"searchDepth":690,"depth":690,"links":3942},[3943,3944,3945,3946,3947,3948,3949],{"id":3289,"depth":690,"text":3290},{"id":3374,"depth":690,"text":3375},{"id":3544,"depth":690,"text":3545},{"id":3563,"depth":690,"text":3564},{"id":3664,"depth":690,"text":3665},{"id":3686,"depth":690,"text":3687},{"id":3910,"depth":690,"text":3911},"2026-08-31",{},"\u002F2026-08-31",{"title":3278,"description":3283},"2026-08-31-阈值不能推只能量","知识库检索质量的三次修正：分块阈值按比例推算算出了全场最差点，精排阈值设高了会随分数漂移随机漏召，超时余量不够会静默降级。三次都靠实测数据定值。",[3957,3958,3959,3960,3961,3962,3963],"知识库","RAG","分块","检索","cross-encoder","阈值标定","评测","VbN-ARGFFeAmQMRZf2jG-4SfdtN22uzMLyCrc3dCuA8",{"id":3966,"title":3967,"body":3968,"column":3260,"date":4595,"description":3972,"extension":3262,"hero_image":3263,"meta":4596,"navigation":999,"path":4597,"seo":4598,"series_id":3263,"severity":3263,"stem":4599,"summary":4600,"tags":4601,"__hash__":4608},"posts\u002F2026-08-20-画布这一步深链运行历史与续跑.md","画布这一步：深链、运行历史与续跑",{"type":7,"value":3969,"toc":4585},[3970,3973,3976,3980,3983,3986,3992,3995,4009,4023,4026,4030,4033,4039,4042,4049,4052,4061,4064,4068,4071,4076,4107,4110,4115,4135,4142,4145,4149,4152,4241,4244,4247,4251,4254,4272,4281,4284,4287,4291,4294,4297,4317,4320,4325,4328,4360,4363,4457,4460,4466,4473,4477,4480,4483,4521,4528,4531,4537,4540,4545,4549,4552,4561,4567,4573,4579,4582],[10,3971,3972],{},"画布是这套平台里最复杂的界面。节点、连线、参数、批量框、运行状态，任何一件事都要在同一个视口里表达清楚。",[10,3974,3975],{},"这一天补齐八项操作。每一项都不难，难的是它们之间不能互相打架。",[14,3977,3979],{"id":3978},"一url-是唯一事实来源","一、URL 是唯一事实来源",[10,3981,3982],{},"原先画布是个单页状态机：打开就是列表，点进去切到编辑器，刷新回列表。",[10,3984,3985],{},"改成路由：",[268,3987,3990],{"className":3988,"code":3989,"language":273},[271],"\u002Fcanvas            列表\n\u002Fcanvas\u002F:id        编辑器\n",[275,3991,3989],{"__ignoreMap":277},[10,3993,3994],{},"四个行为一起对齐：",[39,3996,3997,4000,4003,4006],{},[42,3998,3999],{},"深链直达编辑器。",[42,4001,4002],{},"刷新不回列表。",[42,4004,4005],{},"后退回列表，而不是退出画布模块。",[42,4007,4008],{},"打开画布写入地址栏。",[10,4010,4011,4014,4015,4018,4019,4022],{},[275,4012,4013],{},"popstate"," 监听已有的解析函数，",[275,4016,4017],{},"pushState"," \u002F ",[275,4020,4021],{},"replaceState"," 写入。",[10,4024,4025],{},"这条改动看起来只是加了个路由，实际改变的是状态的归属：画布 ID 从组件内的 ref 变成了 URL 的一部分。后面几项都依赖它——运行历史要能链到具体的运行，批量框要能被分享，都要求「当前在看哪张画布」是一个可以从外部确定的量。",[14,4027,4029],{"id":4028},"二运行历史","二、运行历史",[10,4031,4032],{},"一个新面板，两个接口：",[268,4034,4037],{"className":4035,"code":4036,"language":273},[271],"GET \u002Fapi\u002Fcanvas-flow\u002Fruns?flowId=…      列表\nGET \u002Fapi\u002Fcanvas-flow\u002Fruns\u002F:id           快照\n",[275,4038,4036],{"__ignoreMap":277},[10,4040,4041],{},"列表默认取 10 条。点开某一条，用它的快照推导出画布上每个节点的状态，覆盖显示。",[10,4043,4044,4045,4048],{},"这个面板的数据来源和实时运行状态用的是同一套 ",[275,4046,4047],{},"nodeStates","——历史记录不是另一条平行显示，而是把画布切到那一次运行的视角。",[10,4050,4051],{},"登录态走单一来源：",[268,4053,4055],{"className":3328,"code":4054,"language":3330,"meta":277,"style":277},"\u002F\u002F 登录态只有 authToken() 一个来源（有守卫测试盯着，别直读 localStorage）\n",[275,4056,4057],{"__ignoreMap":277},[681,4058,4059],{"class":683,"line":684},[681,4060,4054],{"class":2487},[10,4062,4063],{},"这条注释是守卫测试抓出来的结果，不是提前的设计。",[14,4065,4067],{"id":4066},"三复制粘贴","三、复制粘贴",[10,4069,4070],{},"复制粘贴里有两个决定值得记。",[10,4072,4073],{},[170,4074,4075],{},"用应用内剪贴板，不碰系统剪贴板。",[268,4077,4079],{"className":3328,"code":4078,"language":3330,"meta":277,"style":277},"let clipboard: FlowClipboardPayload | null = null;\n",[275,4080,4081],{"__ignoreMap":277},[681,4082,4083,4086,4089,4092,4095,4098,4101,4103,4105],{"class":683,"line":684},[681,4084,4085],{"class":738},"let",[681,4087,4088],{"class":742}," clipboard",[681,4090,4091],{"class":738},":",[681,4093,4094],{"class":755}," FlowClipboardPayload",[681,4096,4097],{"class":738}," |",[681,4099,4100],{"class":746}," null",[681,4102,776],{"class":738},[681,4104,4100],{"class":746},[681,4106,2558],{"class":742},[10,4108,4109],{},"粘贴的内容是节点和边，格式带版本号。走系统剪贴板意味着把画布的 JSON 写进用户的剪贴板，用户去别处粘贴会看到一堆结构数据。应用内的模块级变量没有这个问题。",[10,4111,4112],{},[170,4113,4114],{},"没选节点时不拦截。",[268,4116,4118],{"className":3328,"code":4117,"language":3330,"meta":277,"style":277},"\u002F\u002F Cmd\u002FCtrl+C：复制选中的节点。\n\u002F\u002F 没选节点就不拦截——用户可能正在复制节点产物里的文字，抢了就是坏默认行为\n\u002F\u002F Cmd\u002FCtrl+V：粘贴。应用内剪贴板为空时同样放行系统默认行为\n",[275,4119,4120,4125,4130],{"__ignoreMap":277},[681,4121,4122],{"class":683,"line":684},[681,4123,4124],{"class":2487},"\u002F\u002F Cmd\u002FCtrl+C：复制选中的节点。\n",[681,4126,4127],{"class":683,"line":690},[681,4128,4129],{"class":2487},"\u002F\u002F 没选节点就不拦截——用户可能正在复制节点产物里的文字，抢了就是坏默认行为\n",[681,4131,4132],{"class":683,"line":696},[681,4133,4134],{"class":2487},"\u002F\u002F Cmd\u002FCtrl+V：粘贴。应用内剪贴板为空时同样放行系统默认行为\n",[10,4136,4137,4138,4141],{},"这两条合起来是一个原则：",[170,4139,4140],{},"快捷键只在它有明确意图时生效","。用户按 Cmd+C 时可能是想复制提示词里的文字，此时抢过来是损失。",[10,4143,4144],{},"粘贴到画布时重新生成 ID（节点 ID 最多重试 20 次防碰撞），偏移 24 像素，粘贴的结果进入撤销栈并成为新的选中项。",[14,4146,4148],{"id":4147},"四一键整理布局","四、一键整理布局",[10,4150,4151],{},"按依赖深度分层的纯函数，不引布局引擎：",[268,4153,4155],{"className":3328,"code":4154,"language":3330,"meta":277,"style":277},"\u002F**\n * 一键整理布局：按依赖深度分层的纯函数。\n *\n * 不引 dagre\u002Felk——画布的图是小规模 DAG（上限 100 节点），\n * 「上游在左、下游在右、同层竖排」这一条规则就够读顺一张乱图，\n * 引一个布局引擎为它的边缘能力买单不值。\n *\u002F\nconst COLUMN_GAP = 380;\nconst ROW_GAP = 240;\nconst ORIGIN = { x: 40, y: 60 };\n",[275,4156,4157,4161,4166,4170,4175,4180,4185,4189,4203,4217],{"__ignoreMap":277},[681,4158,4159],{"class":683,"line":684},[681,4160,3425],{"class":2487},[681,4162,4163],{"class":683,"line":690},[681,4164,4165],{"class":2487}," * 一键整理布局：按依赖深度分层的纯函数。\n",[681,4167,4168],{"class":683,"line":696},[681,4169,3639],{"class":2487},[681,4171,4172],{"class":683,"line":702},[681,4173,4174],{"class":2487}," * 不引 dagre\u002Felk——画布的图是小规模 DAG（上限 100 节点），\n",[681,4176,4177],{"class":683,"line":708},[681,4178,4179],{"class":2487}," * 「上游在左、下游在右、同层竖排」这一条规则就够读顺一张乱图，\n",[681,4181,4182],{"class":683,"line":714},[681,4183,4184],{"class":2487}," * 引一个布局引擎为它的边缘能力买单不值。\n",[681,4186,4187],{"class":683,"line":720},[681,4188,3470],{"class":2487},[681,4190,4191,4193,4196,4198,4201],{"class":683,"line":1024},[681,4192,739],{"class":738},[681,4194,4195],{"class":746}," COLUMN_GAP",[681,4197,776],{"class":738},[681,4199,4200],{"class":746}," 380",[681,4202,2558],{"class":742},[681,4204,4205,4207,4210,4212,4215],{"class":683,"line":1035},[681,4206,739],{"class":738},[681,4208,4209],{"class":746}," ROW_GAP",[681,4211,776],{"class":738},[681,4213,4214],{"class":746}," 240",[681,4216,2558],{"class":742},[681,4218,4219,4221,4224,4226,4229,4232,4235,4238],{"class":683,"line":1046},[681,4220,739],{"class":738},[681,4222,4223],{"class":746}," ORIGIN",[681,4225,776],{"class":738},[681,4227,4228],{"class":742}," { x: ",[681,4230,4231],{"class":746},"40",[681,4233,4234],{"class":742},", y: ",[681,4236,4237],{"class":746},"60",[681,4239,4240],{"class":742}," };\n",[10,4242,4243],{},"同列保持用户原有的上下相对顺序。这一条是这类功能能不能用的分界线——整理完之后用户还得能认出自己的图。",[10,4245,4246],{},"节点上限 100，单批最大 50 项，展开后的总任务上限 200。这几个数决定了上面那个判断成立：在这个规模内，一条规则够用。",[14,4248,4250],{"id":4249},"五便签不能做成节点","五、便签不能做成节点",[10,4252,4253],{},"画布上要有地方写注释。最自然的做法是加一个「便签节点」，但它的实现方式正好相反：",[268,4255,4257],{"className":3328,"code":4256,"language":3330,"meta":277,"style":277},"\u002F**\n * 画布便签：纯注释，不参与调度、连线与计费。\n *\u002F\n",[275,4258,4259,4263,4268],{"__ignoreMap":277},[681,4260,4261],{"class":683,"line":684},[681,4262,3425],{"class":2487},[681,4264,4265],{"class":683,"line":690},[681,4266,4267],{"class":2487}," * 画布便签：纯注释，不参与调度、连线与计费。\n",[681,4269,4270],{"class":683,"line":696},[681,4271,3470],{"class":2487},[268,4273,4275],{"className":3328,"code":4274,"language":3330,"meta":277,"style":277},"\u002F\u002F 不是 xyflow 节点——它不参与连线、调度与计费，做成节点要在注册表、校验器、执行器三处逐一开豁免，成本远高于一张自绘卡片。\n",[275,4276,4277],{"__ignoreMap":277},[681,4278,4279],{"class":683,"line":684},[681,4280,4274],{"class":2487},[10,4282,4283],{},"做成节点意味着它要在三处地方被显式排除。而排除逻辑每加一处，将来就多一处要维护的例外。做成一张画在流坐标系里的自绘卡片，这些例外一个都不需要。",[10,4285,4286],{},"契约里限制条数 100、单条 2000 字。",[14,4288,4290],{"id":4289},"六批量框","六、批量框",[10,4292,4293],{},"批量框是「框内的子图跑 N 遍」。它带来的第一件事是边界规则：",[10,4295,4296],{},"框内节点的输出不能接到框外，反过来可以。",[268,4298,4300],{"className":3328,"code":4299,"language":3330,"meta":277,"style":277},"\u002F\u002F 批量框边界规则：框内节点的输出不能接到框外（或另一个框）。\n\u002F\u002F 框内子图每份各跑一遍，往外接意味着下游要收 N 份——运行时不支持这种收束；\n\u002F\u002F 反方向（框外 → 框内）合法：同一上游共享给每份拷贝。\n",[275,4301,4302,4307,4312],{"__ignoreMap":277},[681,4303,4304],{"class":683,"line":684},[681,4305,4306],{"class":2487},"\u002F\u002F 批量框边界规则：框内节点的输出不能接到框外（或另一个框）。\n",[681,4308,4309],{"class":683,"line":690},[681,4310,4311],{"class":2487},"\u002F\u002F 框内子图每份各跑一遍，往外接意味着下游要收 N 份——运行时不支持这种收束；\n",[681,4313,4314],{"class":683,"line":696},[681,4315,4316],{"class":2487},"\u002F\u002F 反方向（框外 → 框内）合法：同一上游共享给每份拷贝。\n",[10,4318,4319],{},"拒绝时的文案要给下一步：",[3295,4321,4322],{},[10,4323,4324],{},"批量框内的节点不能连到框外：框内每份各跑一遍，产物会直接进素材库。要串联处理就把目标节点也拖进框里。",[10,4326,4327],{},"第二件事是删除节点的连带处理。节点被删掉之后，批量框里会留下幽灵成员，而运行创建时会拒绝整张图：",[268,4329,4331],{"className":3328,"code":4330,"language":3330,"meta":277,"style":277},"\u002F**\n * 从所有批量框成员里剔除已删除的节点；成员清空的框一并删除。\n *\n * 删除节点必须同步清理：残留的幽灵成员会让 run-create 直接拒绝整张画布\n * （「批量框引用了图中不存在的节点」），用户面对的是一张再也跑不起来的图。\n *\u002F\n",[275,4332,4333,4337,4342,4346,4351,4356],{"__ignoreMap":277},[681,4334,4335],{"class":683,"line":684},[681,4336,3425],{"class":2487},[681,4338,4339],{"class":683,"line":690},[681,4340,4341],{"class":2487}," * 从所有批量框成员里剔除已删除的节点；成员清空的框一并删除。\n",[681,4343,4344],{"class":683,"line":696},[681,4345,3639],{"class":2487},[681,4347,4348],{"class":683,"line":702},[681,4349,4350],{"class":2487}," * 删除节点必须同步清理：残留的幽灵成员会让 run-create 直接拒绝整张画布\n",[681,4352,4353],{"class":683,"line":708},[681,4354,4355],{"class":2487}," * （「批量框引用了图中不存在的节点」），用户面对的是一张再也跑不起来的图。\n",[681,4357,4358],{"class":683,"line":714},[681,4359,3470],{"class":2487},[10,4361,4362],{},"第三件事是撤销栈。批量框要进快照：",[268,4364,4366],{"className":3328,"code":4365,"language":3330,"meta":277,"style":277},"export interface GraphSnapshot {\n  readonly nodes: readonly CanvasFlowNode[];\n  readonly edges: readonly CanvasFlowEdge[];\n  readonly selected: readonly string[];\n  \u002F** 批量框。撤销\u002F重做要连它一起回放，否则撤销删框后节点回来了框没了 *\u002F\n  readonly batchGroups: readonly CanvasFlowBatchGroup[];\n}\n",[275,4367,4368,4380,4400,4416,4432,4437,4453],{"__ignoreMap":277},[681,4369,4370,4372,4375,4378],{"class":683,"line":684},[681,4371,2493],{"class":738},[681,4373,4374],{"class":738}," interface",[681,4376,4377],{"class":755}," GraphSnapshot",[681,4379,2504],{"class":742},[681,4381,4382,4385,4389,4391,4394,4397],{"class":683,"line":690},[681,4383,4384],{"class":738},"  readonly",[681,4386,4388],{"class":4387},"s4XuR"," nodes",[681,4390,4091],{"class":738},[681,4392,4393],{"class":738}," readonly",[681,4395,4396],{"class":755}," CanvasFlowNode",[681,4398,4399],{"class":742},"[];\n",[681,4401,4402,4404,4407,4409,4411,4414],{"class":683,"line":696},[681,4403,4384],{"class":738},[681,4405,4406],{"class":4387}," edges",[681,4408,4091],{"class":738},[681,4410,4393],{"class":738},[681,4412,4413],{"class":755}," CanvasFlowEdge",[681,4415,4399],{"class":742},[681,4417,4418,4420,4423,4425,4427,4430],{"class":683,"line":702},[681,4419,4384],{"class":738},[681,4421,4422],{"class":4387}," selected",[681,4424,4091],{"class":738},[681,4426,4393],{"class":738},[681,4428,4429],{"class":746}," string",[681,4431,4399],{"class":742},[681,4433,4434],{"class":683,"line":708},[681,4435,4436],{"class":2487},"  \u002F** 批量框。撤销\u002F重做要连它一起回放，否则撤销删框后节点回来了框没了 *\u002F\n",[681,4438,4439,4441,4444,4446,4448,4451],{"class":683,"line":714},[681,4440,4384],{"class":738},[681,4442,4443],{"class":4387}," batchGroups",[681,4445,4091],{"class":738},[681,4447,4393],{"class":738},[681,4449,4450],{"class":755}," CanvasFlowBatchGroup",[681,4452,4399],{"class":742},[681,4454,4455],{"class":683,"line":720},[681,4456,723],{"class":742},[10,4458,4459],{},"第四件事是状态聚合。批量框内一个节点对应多行执行状态，显示取哪个：",[268,4461,4464],{"className":4462,"code":4463,"language":273},[271],"failed > running > pending > cancelled > succeeded > idle\n",[275,4465,4463],{"__ignoreMap":277},[10,4467,4468,4469,4472],{},"原先只让 ",[275,4470,4471],{},"failed"," 优先。结果是第一份先成功、第二份还在跑的时候，节点就提前显示成「成功」。",[14,4474,4476],{"id":4475},"七续跑不重复扣费","七、续跑不重复扣费",[10,4478,4479],{},"这一项是这八项里唯一涉及钱的。",[10,4481,4482],{},"失败或被取消的运行，可以续跑。实现方式不是「重跑一遍」：",[268,4484,4486],{"className":3328,"code":4485,"language":3330,"meta":277,"style":277},"\u002F**\n * 单节点重试：给失败\u002F被取消的运行造一个「续跑」运行。成功节点的行原样回填\n * （产物、billingRef、时间戳都保留）——executor 的调度器见到 succeeded 行\n * 会直接当作上游已就绪，不会重新执行，也就不会重复扣费；其余节点\n * （failed \u002F cancelled \u002F pending）重置成全新的 pending 行，正常调度重跑。\n * 不修改原运行：重试是一条新的 CanvasFlowRun，历史记录保持完整。\n *\u002F\n",[275,4487,4488,4492,4497,4502,4507,4512,4517],{"__ignoreMap":277},[681,4489,4490],{"class":683,"line":684},[681,4491,3425],{"class":2487},[681,4493,4494],{"class":683,"line":690},[681,4495,4496],{"class":2487}," * 单节点重试：给失败\u002F被取消的运行造一个「续跑」运行。成功节点的行原样回填\n",[681,4498,4499],{"class":683,"line":696},[681,4500,4501],{"class":2487}," * （产物、billingRef、时间戳都保留）——executor 的调度器见到 succeeded 行\n",[681,4503,4504],{"class":683,"line":702},[681,4505,4506],{"class":2487}," * 会直接当作上游已就绪，不会重新执行，也就不会重复扣费；其余节点\n",[681,4508,4509],{"class":683,"line":708},[681,4510,4511],{"class":2487}," * （failed \u002F cancelled \u002F pending）重置成全新的 pending 行，正常调度重跑。\n",[681,4513,4514],{"class":683,"line":714},[681,4515,4516],{"class":2487}," * 不修改原运行：重试是一条新的 CanvasFlowRun，历史记录保持完整。\n",[681,4518,4519],{"class":683,"line":720},[681,4520,3470],{"class":2487},[10,4522,4523,4524,4527],{},"关键在于复用判定落在行状态上，而不是「这次运行是新是旧」。所以续跑不需要额外的豁免逻辑：被判成功的节点带着原来的 ",[275,4525,4526],{},"billingRef","，调度器看到它就不再执行。",[10,4529,4530],{},"预估只算子集，余额检查同理。接口层有幂等入口（按用户 + 请求 ID 查重）和归属校验。不可重试的两种情形给出明确文案：",[268,4532,4535],{"className":4533,"code":4534,"language":273},[271],"这次运行已全部成功，没有可重试的节点\n运行还没结束，等它终结后再重试\n",[275,4536,4534],{"__ignoreMap":277},[10,4538,4539],{},"界面上的按钮从「重试」改成「重试失败节点」，带一句说明：",[3295,4541,4542],{},[10,4543,4544],{},"成功节点的产物直接沿用，只有失败的节点会重新执行并计费。",[14,4546,4548],{"id":4547},"八八项之间的关系","八、八项之间的关系",[10,4550,4551],{},"单独看每一项，都是常规功能。放在一起时，出现了几条贯穿的取舍：",[10,4553,4554,4557,4558,4560],{},[170,4555,4556],{},"状态的归属要单一。"," 画布 ID 放 URL；运行状态用同一套 ",[275,4559,4047],{},"；撤销栈是唯一的变更入口。三处都收成一个来源之后，「刷新之后看到什么」才有确定答案。",[10,4562,4563,4566],{},[170,4564,4565],{},"例外要少。"," 便签不做成节点，就是为了避免在注册表、校验器、执行器三处开豁免。每开一处例外，就多一处将来会忘记的地方。",[10,4568,4569,4572],{},[170,4570,4571],{},"别抢用户的操作。"," 没选节点时不拦 Cmd+C；整理布局保留同列原有顺序；批量框拒绝时给下一步而不是只报错。",[10,4574,4575,4578],{},[170,4576,4577],{},"涉及钱的判定落在状态上。"," 续跑复用靠行状态，不靠运行的新旧；批量份数由框上显式配置，预估与执行读同一个函数。",[10,4580,4581],{},"最后一条是这一天唯一和故障档案有关的部分。同一天里，批量链路翻出一处行约定矛盾和一处从未命中的推断分支，两处的成因都是「两侧各写一份」。八项补齐之后，取值入口都比之前更集中——这不是巧合，是同一件事的两个方向。",[3225,4583,4584],{},"html pre.shiki code .sJ8bj, html code.shiki .sJ8bj{--shiki-default:#6A737D;--shiki-dark:#6A737D}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 .sVt8B, html code.shiki .sVt8B{--shiki-default:#24292E;--shiki-dark:#E1E4E8}html pre.shiki code .sScJk, html code.shiki .sScJk{--shiki-default:#6F42C1;--shiki-dark:#B392F0}html pre.shiki code .sj4cs, html code.shiki .sj4cs{--shiki-default:#005CC5;--shiki-dark:#79B8FF}html pre.shiki code .s4XuR, html code.shiki .s4XuR{--shiki-default:#E36209;--shiki-dark:#FFAB70}",{"title":277,"searchDepth":690,"depth":690,"links":4586},[4587,4588,4589,4590,4591,4592,4593,4594],{"id":3978,"depth":690,"text":3979},{"id":4028,"depth":690,"text":4029},{"id":4066,"depth":690,"text":4067},{"id":4147,"depth":690,"text":4148},{"id":4249,"depth":690,"text":4250},{"id":4289,"depth":690,"text":4290},{"id":4475,"depth":690,"text":4476},{"id":4547,"depth":690,"text":4548},"2026-08-20",{},"\u002F2026-08-20",{"title":3967,"description":3972},"2026-08-20-画布这一步深链运行历史与续跑","一次补齐八项画布操作：URL 深链、小地图、运行历史、复制粘贴、批量框、一键整理布局、便签，以及失败运行的单节点续跑。",[4602,4603,4604,4605,4606,4607],"画布","工作流","URL状态","撤销重做","批量执行","单节点重试","eA-RfotRTWBXXppeiZFtcguPXjtpnVRWrkWgI5LKfY0",{"id":4610,"title":4611,"body":4612,"column":3260,"date":5641,"description":4616,"extension":3262,"hero_image":3263,"meta":5642,"navigation":999,"path":5643,"seo":5644,"series_id":3263,"severity":3263,"stem":5645,"summary":5646,"tags":5647,"__hash__":5653},"posts\u002F2026-08-08-同一张能力表抄了三份.md","同一张能力表，抄了三份",{"type":7,"value":4613,"toc":5633},[4614,4617,4620,4623,4627,4630,4778,4781,4787,4793,4796,4864,4867,4871,4874,4877,4898,4901,4904,4910,4925,4928,4932,4935,4938,4944,4947,5074,5077,5086,5089,5096,5304,5307,5311,5322,5325,5328,5388,5391,5452,5455,5488,5492,5495,5498,5504,5511,5514,5521,5528,5532,5535,5562,5568,5571,5621,5627,5630],[10,4615,4616],{},"用户反馈：设置里选完模型，分辨率档不对——Seedance-2.5 只有 480P 和 720P；另外 2.5 的上游支持到 29 秒，故事板那边应该跟着放开。",[10,4618,4619],{},"第一句说得对，2.5 确实只有 480p 和 720p。第二句也对，它确实支持 4~29 秒。",[10,4621,4622],{},"两条都对，说明我们发出去的档位和上游支持的对不上。查下去发现，同一份能力表被抄了三份。",[14,4624,4626],{"id":4625},"一三处各自漂移","一、三处各自漂移",[10,4628,4629],{},"后端有一张权威表，写清了每个模型支持哪些分辨率、哪些时长：",[268,4631,4633],{"className":3328,"code":4632,"language":3330,"meta":277,"style":277},"const MODEL_RESOLUTIONS: Record\u003CVideoModel, readonly VideoResolution[]> = {\n  \"seedance-2\": [\"480p\", \"720p\", \"1080p\", \"4k\"],\n  \"seedance-2-fast\": [\"480p\", \"720p\"],\n  \"seedance-2-mini\": [\"480p\", \"720p\"],\n  \"seedance-2.5\": [\"480p\", \"720p\"],\n  \"kling-v3\": [\"720p\", \"1080p\"],\n  \"minimax-h3\": [\"2k\", \"768p\"],\n};\n",[275,4634,4635,4669,4697,4712,4727,4742,4757,4774],{"__ignoreMap":277},[681,4636,4637,4639,4642,4644,4647,4650,4653,4656,4659,4662,4665,4667],{"class":683,"line":684},[681,4638,739],{"class":738},[681,4640,4641],{"class":746}," MODEL_RESOLUTIONS",[681,4643,4091],{"class":738},[681,4645,4646],{"class":755}," Record",[681,4648,4649],{"class":742},"\u003C",[681,4651,4652],{"class":755},"VideoModel",[681,4654,4655],{"class":742},", ",[681,4657,4658],{"class":738},"readonly",[681,4660,4661],{"class":755}," VideoResolution",[681,4663,4664],{"class":742},"[]> ",[681,4666,752],{"class":738},[681,4668,2504],{"class":742},[681,4670,4671,4674,4676,4679,4681,4684,4686,4689,4691,4694],{"class":683,"line":690},[681,4672,4673],{"class":762},"  \"seedance-2\"",[681,4675,1170],{"class":742},[681,4677,4678],{"class":762},"\"480p\"",[681,4680,4655],{"class":742},[681,4682,4683],{"class":762},"\"720p\"",[681,4685,4655],{"class":742},[681,4687,4688],{"class":762},"\"1080p\"",[681,4690,4655],{"class":742},[681,4692,4693],{"class":762},"\"4k\"",[681,4695,4696],{"class":742},"],\n",[681,4698,4699,4702,4704,4706,4708,4710],{"class":683,"line":696},[681,4700,4701],{"class":762},"  \"seedance-2-fast\"",[681,4703,1170],{"class":742},[681,4705,4678],{"class":762},[681,4707,4655],{"class":742},[681,4709,4683],{"class":762},[681,4711,4696],{"class":742},[681,4713,4714,4717,4719,4721,4723,4725],{"class":683,"line":702},[681,4715,4716],{"class":762},"  \"seedance-2-mini\"",[681,4718,1170],{"class":742},[681,4720,4678],{"class":762},[681,4722,4655],{"class":742},[681,4724,4683],{"class":762},[681,4726,4696],{"class":742},[681,4728,4729,4732,4734,4736,4738,4740],{"class":683,"line":708},[681,4730,4731],{"class":762},"  \"seedance-2.5\"",[681,4733,1170],{"class":742},[681,4735,4678],{"class":762},[681,4737,4655],{"class":742},[681,4739,4683],{"class":762},[681,4741,4696],{"class":742},[681,4743,4744,4747,4749,4751,4753,4755],{"class":683,"line":714},[681,4745,4746],{"class":762},"  \"kling-v3\"",[681,4748,1170],{"class":742},[681,4750,4683],{"class":762},[681,4752,4655],{"class":742},[681,4754,4688],{"class":762},[681,4756,4696],{"class":742},[681,4758,4759,4762,4764,4767,4769,4772],{"class":683,"line":720},[681,4760,4761],{"class":762},"  \"minimax-h3\"",[681,4763,1170],{"class":742},[681,4765,4766],{"class":762},"\"2k\"",[681,4768,4655],{"class":742},[681,4770,4771],{"class":762},"\"768p\"",[681,4773,4696],{"class":742},[681,4775,4776],{"class":683,"line":1024},[681,4777,2625],{"class":742},[10,4779,4780],{},"前端有一份手抄的副本。它早就漂了，而且漂在两个方向。",[10,4782,4783,4786],{},[170,4784,4785],{},"多出的档位。"," 设置页的取档逻辑是写死的规则：「H3 两档、其余一律四档」。于是 2.5、fast、mini 这三个只有 480p 和 720p 的模型，界面上给出 1080P 和 4K。用户选了，选中即报未配价，或者直接被上游拒。",[10,4788,4789,4792],{},[170,4790,4791],{},"少掉的档位。"," Kling 只支持 720p 和 1080p，界面给的是四档。反过来的情况也存在：如果某个模型的档位比默认四档更多，界面也显示不出来。",[10,4794,4795],{},"时长那一处漂得更彻底。前端和后端各写了一份 4~15 的夹取：",[268,4797,4799],{"className":3328,"code":4798,"language":3330,"meta":277,"style":277},"\u002F\u002F 前端\nreturn Math.max(4, Math.min(15, value));\n\n\u002F\u002F 后端\nreturn Math.max(4, Math.min(15, value));\n",[275,4800,4801,4806,4833,4837,4842],{"__ignoreMap":277},[681,4802,4803],{"class":683,"line":684},[681,4804,4805],{"class":2487},"\u002F\u002F 前端\n",[681,4807,4808,4811,4813,4816,4818,4821,4823,4825,4827,4830],{"class":683,"line":690},[681,4809,4810],{"class":738},"return",[681,4812,3507],{"class":742},[681,4814,4815],{"class":755},"max",[681,4817,759],{"class":742},[681,4819,4820],{"class":746},"4",[681,4822,3518],{"class":742},[681,4824,3510],{"class":755},[681,4826,759],{"class":742},[681,4828,4829],{"class":746},"15",[681,4831,4832],{"class":742},", value));\n",[681,4834,4835],{"class":683,"line":696},[681,4836,1000],{"emptyLinePlaceholder":999},[681,4838,4839],{"class":683,"line":702},[681,4840,4841],{"class":2487},"\u002F\u002F 后端\n",[681,4843,4844,4846,4848,4850,4852,4854,4856,4858,4860,4862],{"class":683,"line":708},[681,4845,4810],{"class":738},[681,4847,3507],{"class":742},[681,4849,4815],{"class":755},[681,4851,759],{"class":742},[681,4853,4820],{"class":746},[681,4855,3518],{"class":742},[681,4857,3510],{"class":755},[681,4859,759],{"class":742},[681,4861,4829],{"class":746},[681,4863,4832],{"class":742},[10,4865,4866],{},"2.5 传 29 秒，被悄悄砍成 15。用户看不出为什么变短——没有任何提示，界面上显示的就是 15 秒。",[14,4868,4870],{"id":4869},"二被砍掉的秒数后面跟着钱","二、被砍掉的秒数后面跟着钱",[10,4872,4873],{},"时长那一处还不只是显示问题。",[10,4875,4876],{},"故事板按固定秒数切板。代码里是一个常量：",[268,4878,4880],{"className":3328,"code":4879,"language":3330,"meta":277,"style":277},"export const SHOT_SECONDS = 15;\n",[275,4881,4882],{"__ignoreMap":277},[681,4883,4884,4886,4888,4891,4893,4896],{"class":683,"line":684},[681,4885,2493],{"class":738},[681,4887,2496],{"class":738},[681,4889,4890],{"class":746}," SHOT_SECONDS",[681,4892,776],{"class":738},[681,4894,4895],{"class":746}," 15",[681,4897,2558],{"class":742},[10,4899,4900],{},"按 15 秒一切。2.5 一板能放 29 秒，硬按 15 秒切，一集会被切成两倍数量的板。",[10,4902,4903],{},"板数翻倍就是出图与出片的费用翻倍。这不是理论推算——用户选 2.5 的动机就是长板数少切，结果切得和短时长模型一样多，还多花一倍钱。",[10,4905,4906,4909],{},[275,4907,4908],{},"comic-subshot.ts"," 里留着上限常量的注释，写明了它只是「模型未知时的保守默认」：",[268,4911,4913],{"className":3328,"code":4912,"language":3330,"meta":277,"style":277},"\u002F\u002F 上限只是「模型未知时的保守默认」——真正的上限逐模型不同（seedance-2.5 能到 29 秒），\n\u002F\u002F 调用方应把该模型的最大秒数作为 maxBoardSec 传进来。写死 15 的话，选了 2.5 也只切 15 秒一板。\n",[275,4914,4915,4920],{"__ignoreMap":277},[681,4916,4917],{"class":683,"line":684},[681,4918,4919],{"class":2487},"\u002F\u002F 上限只是「模型未知时的保守默认」——真正的上限逐模型不同（seedance-2.5 能到 29 秒），\n",[681,4921,4922],{"class":683,"line":690},[681,4923,4924],{"class":2487},"\u002F\u002F 调用方应把该模型的最大秒数作为 maxBoardSec 传进来。写死 15 的话，选了 2.5 也只切 15 秒一板。\n",[10,4926,4927],{},"调用方没传。",[14,4929,4931],{"id":4930},"三提示词长度四个数一个是实测出来的","三、提示词长度：四个数，一个是实测出来的",[10,4933,4934],{},"同一批里还有一个更贵的限制：提示词长度上限。",[10,4936,4937],{},"上游对提示词有硬上限，超过直接拒。各模型不一样，而这些值在很长一段时间里没有集中维护。表现是一条线上的完整失败：",[268,4939,4942],{"className":4940,"code":4941,"language":273},[271],"模型 seedance-2.5 的提示词不能超过 5000 个字符，当前为 18545 个字符\n",[275,4943,4941],{"__ignoreMap":277},[10,4945,4946],{},"接入本身没问题，是缺了长度约束。补完之后这张表长这样：",[268,4948,4950],{"className":3328,"code":4949,"language":3330,"meta":277,"style":277},"const MODEL_PROMPT_LIMITS: Partial\u003CRecord\u003CVideoModel, number>> = {\n  \"minimax-h3\": 7000,\n  \"seedance-2\": 2500,\n  \"seedance-2-fast\": 2500,\n  \"seedance-2-mini\": 2500,\n  \"seedance-2.5\": 5000,\n  \u002F\u002F 20260809 实测：发 2553 字被硬拒 `prompt: size must be between 0 and 2500`\n  \"kling-v3\": 2500,\n};\nexport const PROMPT_LIMIT_FALLBACK = 50000;\n",[275,4951,4952,4985,4997,5008,5018,5028,5039,5044,5054,5058],{"__ignoreMap":277},[681,4953,4954,4956,4959,4961,4964,4966,4969,4971,4973,4975,4978,4981,4983],{"class":683,"line":684},[681,4955,739],{"class":738},[681,4957,4958],{"class":746}," MODEL_PROMPT_LIMITS",[681,4960,4091],{"class":738},[681,4962,4963],{"class":755}," Partial",[681,4965,4649],{"class":742},[681,4967,4968],{"class":755},"Record",[681,4970,4649],{"class":742},[681,4972,4652],{"class":755},[681,4974,4655],{"class":742},[681,4976,4977],{"class":746},"number",[681,4979,4980],{"class":742},">> ",[681,4982,752],{"class":738},[681,4984,2504],{"class":742},[681,4986,4987,4989,4991,4994],{"class":683,"line":690},[681,4988,4761],{"class":762},[681,4990,973],{"class":742},[681,4992,4993],{"class":746},"7000",[681,4995,4996],{"class":742},",\n",[681,4998,4999,5001,5003,5006],{"class":683,"line":696},[681,5000,4673],{"class":762},[681,5002,973],{"class":742},[681,5004,5005],{"class":746},"2500",[681,5007,4996],{"class":742},[681,5009,5010,5012,5014,5016],{"class":683,"line":702},[681,5011,4701],{"class":762},[681,5013,973],{"class":742},[681,5015,5005],{"class":746},[681,5017,4996],{"class":742},[681,5019,5020,5022,5024,5026],{"class":683,"line":708},[681,5021,4716],{"class":762},[681,5023,973],{"class":742},[681,5025,5005],{"class":746},[681,5027,4996],{"class":742},[681,5029,5030,5032,5034,5037],{"class":683,"line":714},[681,5031,4731],{"class":762},[681,5033,973],{"class":742},[681,5035,5036],{"class":746},"5000",[681,5038,4996],{"class":742},[681,5040,5041],{"class":683,"line":720},[681,5042,5043],{"class":2487},"  \u002F\u002F 20260809 实测：发 2553 字被硬拒 `prompt: size must be between 0 and 2500`\n",[681,5045,5046,5048,5050,5052],{"class":683,"line":1024},[681,5047,4746],{"class":762},[681,5049,973],{"class":742},[681,5051,5005],{"class":746},[681,5053,4996],{"class":742},[681,5055,5056],{"class":683,"line":1035},[681,5057,2625],{"class":742},[681,5059,5060,5062,5064,5067,5069,5072],{"class":683,"line":1046},[681,5061,2493],{"class":738},[681,5063,2496],{"class":738},[681,5065,5066],{"class":746}," PROMPT_LIMIT_FALLBACK",[681,5068,776],{"class":738},[681,5070,5071],{"class":746}," 50000",[681,5073,2558],{"class":742},[10,5075,5076],{},"这几个数的来源不同，注释里写清了哪个是实测的：",[3295,5078,5079],{},[10,5080,5081,5082,5085],{},"seedance-2.5 的 5000 是",[170,5083,5084],{},"实测出来的，文档只字未提","：线上一条 18545 字的脚本被拒，报文写「提示词不能超过 5000 个字符」。同批实测另两条渠道收 16500 字照样 200，所以这是 2.5 独有的限制，不能推广到整个系列。加新渠道前先拿超长 prompt 打一次，别等线上炸。",[10,5087,5088],{},"这段话是这张表里唯一带出处的一条。其余几个数是按上游文档填的，文档没提的只能等线上撞。",[10,5090,5091,5092,5095],{},"长度约束补齐之后，还有一个配套的预算计算。原先只有 H3 有预算，其余模型返回 ",[275,5093,5094],{},"null","，等于完全不限：",[268,5097,5099],{"className":3328,"code":5098,"language":3330,"meta":277,"style":277},"\u002F** 每秒成片对应的脚本篇幅。15 秒 → 7500 字，是人肉审稿与出片效果都合适的密度。 *\u002F\nexport const CHARS_PER_SECOND = 500;\n\u002F** 留给用户自己追加修改的余量：脚本刚好顶满上限时，用户加一句就被上游拒了。 *\u002F\nconst PROMPT_BUDGET_MARGIN = 500;\n\nexport function scriptCharBudget(targetModel: string | undefined, durationSec?: number): number | null {\n  const hardLimit = modelPromptHardLimit(targetModel);\n  if (!durationSec || !Number.isFinite(durationSec) || durationSec \u003C= 0) return hardLimit;\n  const byDuration = Math.round(durationSec * CHARS_PER_SECOND);\n  return hardLimit ? Math.min(byDuration, hardLimit) : byDuration;\n}\n",[275,5100,5101,5106,5122,5127,5140,5144,5192,5208,5254,5277,5300],{"__ignoreMap":277},[681,5102,5103],{"class":683,"line":684},[681,5104,5105],{"class":2487},"\u002F** 每秒成片对应的脚本篇幅。15 秒 → 7500 字，是人肉审稿与出片效果都合适的密度。 *\u002F\n",[681,5107,5108,5110,5112,5115,5117,5120],{"class":683,"line":690},[681,5109,2493],{"class":738},[681,5111,2496],{"class":738},[681,5113,5114],{"class":746}," CHARS_PER_SECOND",[681,5116,776],{"class":738},[681,5118,5119],{"class":746}," 500",[681,5121,2558],{"class":742},[681,5123,5124],{"class":683,"line":696},[681,5125,5126],{"class":2487},"\u002F** 留给用户自己追加修改的余量：脚本刚好顶满上限时，用户加一句就被上游拒了。 *\u002F\n",[681,5128,5129,5131,5134,5136,5138],{"class":683,"line":702},[681,5130,739],{"class":738},[681,5132,5133],{"class":746}," PROMPT_BUDGET_MARGIN",[681,5135,776],{"class":738},[681,5137,5119],{"class":746},[681,5139,2558],{"class":742},[681,5141,5142],{"class":683,"line":708},[681,5143,1000],{"emptyLinePlaceholder":999},[681,5145,5146,5148,5151,5154,5156,5159,5161,5163,5165,5168,5170,5173,5176,5179,5182,5184,5186,5188,5190],{"class":683,"line":714},[681,5147,2493],{"class":738},[681,5149,5150],{"class":738}," function",[681,5152,5153],{"class":755}," scriptCharBudget",[681,5155,759],{"class":742},[681,5157,5158],{"class":4387},"targetModel",[681,5160,4091],{"class":738},[681,5162,4429],{"class":746},[681,5164,4097],{"class":738},[681,5166,5167],{"class":746}," undefined",[681,5169,4655],{"class":742},[681,5171,5172],{"class":4387},"durationSec",[681,5174,5175],{"class":738},"?:",[681,5177,5178],{"class":746}," number",[681,5180,5181],{"class":742},")",[681,5183,4091],{"class":738},[681,5185,5178],{"class":746},[681,5187,4097],{"class":738},[681,5189,4100],{"class":746},[681,5191,2504],{"class":742},[681,5193,5194,5197,5200,5202,5205],{"class":683,"line":720},[681,5195,5196],{"class":738},"  const",[681,5198,5199],{"class":746}," hardLimit",[681,5201,776],{"class":738},[681,5203,5204],{"class":755}," modelPromptHardLimit",[681,5206,5207],{"class":742},"(targetModel);\n",[681,5209,5210,5213,5215,5217,5220,5223,5226,5229,5232,5235,5237,5240,5243,5246,5249,5251],{"class":683,"line":1024},[681,5211,5212],{"class":738},"  if",[681,5214,2642],{"class":742},[681,5216,2645],{"class":738},[681,5218,5219],{"class":742},"durationSec ",[681,5221,5222],{"class":738},"||",[681,5224,5225],{"class":738}," !",[681,5227,5228],{"class":742},"Number.",[681,5230,5231],{"class":755},"isFinite",[681,5233,5234],{"class":742},"(durationSec) ",[681,5236,5222],{"class":738},[681,5238,5239],{"class":742}," durationSec ",[681,5241,5242],{"class":738},"\u003C=",[681,5244,5245],{"class":746}," 0",[681,5247,5248],{"class":742},") ",[681,5250,4810],{"class":738},[681,5252,5253],{"class":742}," hardLimit;\n",[681,5255,5256,5258,5261,5263,5265,5268,5271,5273,5275],{"class":683,"line":1035},[681,5257,5196],{"class":738},[681,5259,5260],{"class":746}," byDuration",[681,5262,776],{"class":738},[681,5264,3507],{"class":742},[681,5266,5267],{"class":755},"round",[681,5269,5270],{"class":742},"(durationSec ",[681,5272,3527],{"class":738},[681,5274,5114],{"class":746},[681,5276,766],{"class":742},[681,5278,5279,5282,5285,5288,5290,5292,5295,5297],{"class":683,"line":1046},[681,5280,5281],{"class":738},"  return",[681,5283,5284],{"class":742}," hardLimit ",[681,5286,5287],{"class":738},"?",[681,5289,3507],{"class":742},[681,5291,3510],{"class":755},[681,5293,5294],{"class":742},"(byDuration, hardLimit) ",[681,5296,4091],{"class":738},[681,5298,5299],{"class":742}," byDuration;\n",[681,5301,5302],{"class":683,"line":1054},[681,5303,723],{"class":742},[10,5305,5306],{},"按 15 秒算出来是 7500 字——上限撤销之后，脚本直接写到 1.8 万字，那也是生成耗时 193 秒的原因。",[14,5308,5310],{"id":5309},"四抄一份是必要的那就测试它","四、抄一份是必要的，那就测试它",[10,5312,5313,5314,5317,5318,5321],{},"前端为什么不能直接读后端那张表：",[275,5315,5316],{},"apps\u002Fweb"," 不能 import ",[275,5319,5320],{},"apps\u002Fapi","。而节点面板必须知道「这个模型支持哪些分辨率、哪些时长」，才能只给出跑得通的选项。",[10,5323,5324],{},"抄一份是必要的。那就把「不许走样」变成断言。",[10,5326,5327],{},"新增的共享包文件头写明了它的性质和守卫：",[268,5329,5331],{"className":3328,"code":5330,"language":3330,"meta":277,"style":277},"\u002F**\n * 生图 \u002F 视频的**上游真实能力**表。\n *\n * 这是 apps\u002Fapi 里两张表的镜像……\n *\n * **为什么要抄一份**：前端（apps\u002Fweb）不能 import apps\u002Fapi，而节点面板必须知道\n * 「这个模型支持哪些分辨率、哪些时长」才能只给出跑得通的选项。\n * 抄一份就有走样的风险，所以 apps\u002Fapi 里有一条对比测试逐项核对两边——\n * 谁改了上游表而没同步这里，测试立刻红。\n *\n * 硬规矩：这里只允许出现上游真支持的取值。多给一个选项，用户就会选到一个必然失败的组合。\n *\u002F\n",[275,5332,5333,5337,5342,5346,5351,5355,5360,5365,5370,5375,5379,5384],{"__ignoreMap":277},[681,5334,5335],{"class":683,"line":684},[681,5336,3425],{"class":2487},[681,5338,5339],{"class":683,"line":690},[681,5340,5341],{"class":2487}," * 生图 \u002F 视频的**上游真实能力**表。\n",[681,5343,5344],{"class":683,"line":696},[681,5345,3639],{"class":2487},[681,5347,5348],{"class":683,"line":702},[681,5349,5350],{"class":2487}," * 这是 apps\u002Fapi 里两张表的镜像……\n",[681,5352,5353],{"class":683,"line":708},[681,5354,3639],{"class":2487},[681,5356,5357],{"class":683,"line":714},[681,5358,5359],{"class":2487}," * **为什么要抄一份**：前端（apps\u002Fweb）不能 import apps\u002Fapi，而节点面板必须知道\n",[681,5361,5362],{"class":683,"line":720},[681,5363,5364],{"class":2487}," * 「这个模型支持哪些分辨率、哪些时长」才能只给出跑得通的选项。\n",[681,5366,5367],{"class":683,"line":1024},[681,5368,5369],{"class":2487}," * 抄一份就有走样的风险，所以 apps\u002Fapi 里有一条对比测试逐项核对两边——\n",[681,5371,5372],{"class":683,"line":1035},[681,5373,5374],{"class":2487}," * 谁改了上游表而没同步这里，测试立刻红。\n",[681,5376,5377],{"class":683,"line":1046},[681,5378,3639],{"class":2487},[681,5380,5381],{"class":683,"line":1054},[681,5382,5383],{"class":2487}," * 硬规矩：这里只允许出现上游真支持的取值。多给一个选项，用户就会选到一个必然失败的组合。\n",[681,5385,5386],{"class":683,"line":1068},[681,5387,3470],{"class":2487},[10,5389,5390],{},"同时把取值这件事收成单点：",[268,5392,5394],{"className":3328,"code":5393,"language":3330,"meta":277,"style":277},"\u002F**\n * 前端渲染面板、后端校验参数都走这一个函数——两边各写一份判断，\n * 迟早出现「界面给得出、服务端不认」的组合。\n *\u002F\nexport function resolveDynamicOptions(kind: \"videoResolution\" | \"videoDuration\", params): readonly { value, label }[]\n",[275,5395,5396,5400,5405,5410,5414],{"__ignoreMap":277},[681,5397,5398],{"class":683,"line":684},[681,5399,3425],{"class":2487},[681,5401,5402],{"class":683,"line":690},[681,5403,5404],{"class":2487}," * 前端渲染面板、后端校验参数都走这一个函数——两边各写一份判断，\n",[681,5406,5407],{"class":683,"line":696},[681,5408,5409],{"class":2487}," * 迟早出现「界面给得出、服务端不认」的组合。\n",[681,5411,5412],{"class":683,"line":702},[681,5413,3470],{"class":2487},[681,5415,5416,5418,5420,5423,5425,5428,5430,5433,5435,5438,5440,5443,5445,5447,5449],{"class":683,"line":708},[681,5417,2493],{"class":738},[681,5419,5150],{"class":738},[681,5421,5422],{"class":755}," resolveDynamicOptions",[681,5424,759],{"class":742},[681,5426,5427],{"class":4387},"kind",[681,5429,4091],{"class":738},[681,5431,5432],{"class":762}," \"videoResolution\"",[681,5434,4097],{"class":738},[681,5436,5437],{"class":762}," \"videoDuration\"",[681,5439,4655],{"class":742},[681,5441,5442],{"class":4387},"params",[681,5444,5181],{"class":742},[681,5446,4091],{"class":738},[681,5448,4393],{"class":738},[681,5450,5451],{"class":742}," { value, label }[]\n",[10,5453,5454],{},"细粒度那一档也保留了两份清单，且是有意不同：",[268,5456,5458],{"className":3328,"code":5457,"language":3330,"meta":277,"style":277},"\u002F**\n * 注意它与下面的 MODEL_DURATION_OPTIONS 是两回事、且**故意不同**：\n * 后者是「按钮行」的精简清单（seedance 只列 7 档，避免一排按钮太长），\n * 而滑块是细粒度选择，应当覆盖服务端真正接受的全部档位——\n * 否则用户拖不到 7\u002F9\u002F11\u002F13\u002F14 秒，白白削掉能力。\n *\u002F\n",[275,5459,5460,5464,5469,5474,5479,5484],{"__ignoreMap":277},[681,5461,5462],{"class":683,"line":684},[681,5463,3425],{"class":2487},[681,5465,5466],{"class":683,"line":690},[681,5467,5468],{"class":2487}," * 注意它与下面的 MODEL_DURATION_OPTIONS 是两回事、且**故意不同**：\n",[681,5470,5471],{"class":683,"line":696},[681,5472,5473],{"class":2487}," * 后者是「按钮行」的精简清单（seedance 只列 7 档，避免一排按钮太长），\n",[681,5475,5476],{"class":683,"line":702},[681,5477,5478],{"class":2487}," * 而滑块是细粒度选择，应当覆盖服务端真正接受的全部档位——\n",[681,5480,5481],{"class":683,"line":708},[681,5482,5483],{"class":2487}," * 否则用户拖不到 7\u002F9\u002F11\u002F13\u002F14 秒，白白削掉能力。\n",[681,5485,5486],{"class":683,"line":714},[681,5487,3470],{"class":2487},[14,5489,5491],{"id":5490},"五假绿的七条用例","五、假绿的七条用例",[10,5493,5494],{},"修的过程中还撞到一个测试问题，值得单独记。",[10,5496,5497],{},"改动完成后跑测试，有一组用例报了这个警告：",[268,5499,5502],{"className":5500,"code":5501,"language":273},[271],"This might cause false positive tests\n",[275,5503,5501],{"__ignoreMap":277},[10,5505,5506,5507,5510],{},"追下去发现是真的假绿。供应商迁移之后，配置加载在某些条件下会抛错，而那个抛错发生在 ",[275,5508,5509],{},"try"," 之外，成了未处理的 rejection。用例本身并不感知异常，于是照样通过。",[10,5512,5513],{},"具体是 7 条。",[10,5515,5516,5517,5520],{},"修法是让配置加载把异常收敛成一个明确的错误类型（缺 key 重试没有意义，归为永久失败），并在两个相关测试文件的模块加载阶段注入测试用 key。注入位置也有讲究——放 ",[275,5518,5519],{},"beforeEach"," 会因为跨文件执行顺序失效。",[10,5522,5523,5524,5527],{},"这件事和主题有关：",[170,5525,5526],{},"同一份能力表抄三份会漂，同一份配置在三个地方加载也会。"," 假绿的七条用例，测的是「配置能加载」，而配置在测试环境里根本没被加载。",[14,5529,5531],{"id":5530},"六收口之后","六、收口之后",[10,5533,5534],{},"同一批里还顺手修了两处同源问题：",[39,5536,5537,5548],{},[42,5538,5539,5540,5543,5544,5547],{},"漫剧设置页那份模型列表是前端手写的副本，早已和后端漂开：列出「Seedance-2.0 Pro」和「可灵 V2」两个根本不存在的 ID，而真实的 ID 是 ",[275,5541,5542],{},"seedance-2-fast"," 和 ",[275,5545,5546],{},"kling-v3","。选中即被出片接口的 zod 拒掉。改成读取后端的模型清单接口。",[42,5549,5550,5551,4018,5554,5557,5558,5561],{},"长篇项目的设置页没有把 ",[275,5552,5553],{},"videoModel",[275,5555,5556],{},"videoResolution"," 传给设置组件，",[275,5559,5560],{},"onSave"," 也没往上收。用户选完模型点保存，没有报错，刷新之后回退到原来的值。",[10,5563,5564,5565,3368],{},"三处问题的形式不同，成因是同一个：",[170,5566,5567],{},"同一份事实存在多个副本，而且没有一处是权威",[10,5569,5570],{},"收口之后的结构是三份，每一份都有明确职责：",[74,5572,5573,5586],{},[77,5574,5575],{},[80,5576,5577,5580,5583],{},[83,5578,5579],{},"位置",[83,5581,5582],{},"职责",[83,5584,5585],{},"守卫",[93,5587,5588,5599,5610],{},[80,5589,5590,5593,5596],{},[98,5591,5592],{},"后端能力表",[98,5594,5595],{},"权威来源",[98,5597,5598],{},"被镜像表逐项对比",[80,5600,5601,5604,5607],{},[98,5602,5603],{},"共享包镜像表",[98,5605,5606],{},"跨端取值",[98,5608,5609],{},"对比测试（改一边不改另一边即红）",[80,5611,5612,5615,5618],{},[98,5613,5614],{},"前端 UI 清单",[98,5616,5617],{},"只影响展示形态",[98,5619,5620],{},"值必须来自共享包，不许写字面量",[10,5622,5623,5624],{},"硬规矩只有一条，写在镜像表里：",[170,5625,5626],{},"只允许出现上游真支持的取值。多给一个选项，用户就会选到一个必然失败的组合。",[10,5628,5629],{},"反过来说，少给一个选项的代价同样实在——2.5 的 29 秒被砍到 15，用户看到的是「这个模型没比别的强」，看不到的是我们没把它的能力交出去。",[3225,5631,5632],{},"html pre.shiki code .szBVR, html code.shiki .szBVR{--shiki-default:#D73A49;--shiki-dark:#F97583}html pre.shiki code .sj4cs, html code.shiki .sj4cs{--shiki-default:#005CC5;--shiki-dark:#79B8FF}html pre.shiki code .sScJk, html code.shiki .sScJk{--shiki-default:#6F42C1;--shiki-dark:#B392F0}html pre.shiki code .sVt8B, html code.shiki .sVt8B{--shiki-default:#24292E;--shiki-dark:#E1E4E8}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 .sJ8bj, html code.shiki .sJ8bj{--shiki-default:#6A737D;--shiki-dark:#6A737D}html pre.shiki code .s4XuR, html code.shiki .s4XuR{--shiki-default:#E36209;--shiki-dark:#FFAB70}",{"title":277,"searchDepth":690,"depth":690,"links":5634},[5635,5636,5637,5638,5639,5640],{"id":4625,"depth":690,"text":4626},{"id":4869,"depth":690,"text":4870},{"id":4930,"depth":690,"text":4931},{"id":5309,"depth":690,"text":5310},{"id":5490,"depth":690,"text":5491},{"id":5530,"depth":690,"text":5531},"2026-08-08",{},"\u002F2026-08-08",{"title":4611,"description":4616},"2026-08-08-同一张能力表抄了三份","上游模型的分辨率、时长、提示词长度散落在三处各自维护，各自漂移。一次用户反馈把三处一起翻出来：多出的档位、被砍掉的秒数、翻倍的板数。",[5648,5649,5650,5651,5652],"模型能力","配置漂移","契约","前端后端一致","提示词长度","xrC9lTC00ghKpLq5ZuDyYhLFe2fpcs3SgCXkd9QeigE",1789212573295]