A failed startup removed the status hint and left a frozen window, with the error only in the log. Module loading in init.js and firstLoadInit now show one modal dialog with the error, stage and stack, a native clipboard copy and reload, and still throw the original error. In init.js the dialog module loads off the error path, since a module-loading failure can take the dialog's own imports down with it. The dialog renders in a shadow root, and its host resets inherited styles inline because page rules outrank :host. It reopens when closed instead of cancelling the close request, because Chromium ignores preventDefault() on a close request without user activation. Refs #332
12 KiB
开屏启动优化(Stage-based Boot)现状
本文档描述 当前已经落地 的开屏启动优化实现:它解决什么问题、端到端链路如何工作、阶段语义与契约是什么、以及后续开发最容易误改的边界。
规划与设计背景见:
docs/StartupOptimizationPlan.md(本文只写“现在怎么跑”)。
1. 范围与结论
当前开屏优化的核心落点是把启动从“一个巨大的串行 init”拆成 可见可点(Shell)→ 核心可用(Core)→ 主应用可交互(Full / APP_READY),并同时降低首屏 JS/库解析负担。
已落地的关键点:
- Shell 先到:
#preloader在firstLoadInit()的 Shell 阶段就移除,主页可以尽早可见可点。 - Host Ready 显式等待:在首次
/api/*访问前等待__TAURITAVERN_MAIN_READY__,确保拦截器/路由已安装,且 Tauri 后端已进入可接收AppState命令的 readiness 状态。 - 启动并发读取:设置与启动元数据并行读取、按顺序应用,并复用本次结果。
- 扩展启动分层:扩展发现可提前后台启动;系统扩展仍在 Full 阶段完成,local/global third-party 扩展延后到
APP_READY后串行激活,从启动关键路径移出。 - lib.bundle 拆分为 core/optional:
highlight.js通过lib.js的 async helper 按需加载;上游同步/lib.jsABI 保留在 core bundle。 - 重任务后移:
initTokenizers()、initScrapers()在APP_READY后、两次 paint 之后后台启动。
2. 端到端链路(从 index.html 到 APP_READY)
2.1 HTML 入口与预加载遮罩
src/index.html- 首屏遮罩:
<div id="preloader"></div> - 模块入口:
<script type="module" src="init.js"></script> - 约束:不使用 modulepreload(移动端 WebView 上曾有缓存/预加载失败问题)。
- 首屏遮罩:
2.2 Bootloader:分层 import + Android import 重试
src/init.js- 设置:
window.__TAURI_RUNNING__ = true,并计算globalThis.__TAURITAVERN_PERF_ENABLED__ - 通过
importWithRetry()依次加载:./lib.js(静态依赖src/dist/lib.core.bundle.js)./tauri-main.js(安装 Host Kernel:路由/拦截器/ABI)./script.js(SillyTavern 主前端)
- 目的:在 Android WebView 首启 I/O 抖动场景下提高模块加载确定性。
- 设置:
2.3 Host Kernel:把同源 /api 变成可路由端点
src/tauri/main/bootstrap.js- 安装
window.__TAURITAVERN__(稳定 Host ABI):invoke broker / 资源路径 helpers 等 - Patch:
fetch拦截(同源、命中路由表则走router.handle(...))jQuery.ajax拦截(同源、命中路由表则转发)- same-origin iframe/window 的补丁(保证扩展/脚本在 iframe 内也能命中路由/下载桥)
- 设置 readiness:
window.__TAURITAVERN_MAIN_READY__ = readyPromise- 前端用
waitForTauriMainReady()等它,确保首次/api/*调用前 Host 已就绪,且 RustBackendReadiness已完成。
- Host Ready 后只加载不会传递导入主应用的模块。设置入口先注册同步/配对监听,事件呈现与设置 UI 共用一个
APP_READYPromise;请求参数面板本身依赖主应用,直接在APP_READY后导入。
- 安装
2.4 前端启动编排:Shell → Core → Full(保持 APP_READY 语义)
src/script.js:firstLoadInit()- Shell 阶段
initLocales()先于任何可见界面:语言数据是静态资源,不依赖 Host 就绪;首帧界面与启动状态文案都已是用户语言removePreloader()(src/scripts/loader.js):移除#preloader- 初始化纯前端 UI/DOM handler、基础 patch(不会做
/api/*)
- Core 阶段
await waitForTauriMainReady({ failFast: true }):保证 Host 拦截器与 Rust backend readiness 就绪/csrf-token+ 并发启动/api/bootstrap与/api/settings/getinitSecrets()+primeSecretStateSnapshot(...)+readSecretState()- 默认 slash commands、模型/设置等核心模块初始化
- Full 阶段
- 按设置、角色、群组、头像的顺序应用读取结果。
- 扩展(如果启用):
- 后台:
startOfflineExtensionsDiscovery()(可在 Core 阶段提前启动) - Full:
activateStartupSystemExtensions({ parallelism })只激活系统扩展,并在需要时提前完成 Extras auto-connect
- 后台:
- 对外事件保持原语义:
event_types.APP_INITIALIZED→event_types.APP_READY
- Post-ready(后台)
initTokenizers()、initScrapers():在APP_READY后异步执行(避免阻塞首屏)。- 若存在启用中的 third-party 扩展:
activateDeferredThirdPartyExtensions({ parallelism: 1 })在首屏完成后后台激活,并在完成后 emitEXTENSION_SETTINGS_LOADED。
- Shell 阶段
3. 阶段语义与全局信号(契约)
3.1 内部阶段(UI 体验)
Shell:允许 UI 可见/可点;不允许依赖扩展已激活。Core:允许进行首次/api/*与核心数据加载;扩展仍可能未激活。Full:完成主应用可交互所需初始化与系统扩展激活;third-party 扩展可能仍在后台补全。
当前暴露的信号:
globalThis.__TAURITAVERN_STARTUP_STAGE__:由firstLoadInit()写入(shell/core/full)event_types.APP_READY:作为“主应用可交互”的稳定语义;晚加载的扩展依赖其 auto-fire 行为完成 ready 钩子
3.2 Host 就绪(拦截器、路由与 BackendReadiness)
window.__TAURITAVERN_MAIN_READY__:在src/tauri/main/bootstrap.js设置waitForTauriMainReady():前端轮询等待该 promise 注册并 resolve(src/scripts/extensions/runtime/tauri-ready.js)- Tauri runtime 下,Host 初始化链会在任何需要
AppState的首批工作前调用wait_for_backend_ready,避免依赖 Tauri 的 “state not managed” 错误文本重试。
4. 设置与 bootstrap 元数据读取
/api/settings/get 独立读取设置;/api/bootstrap 提供角色、群组、头像和密钥状态等启动元数据。前端并发获取,再按既有顺序应用;本次启动复用读取结果,避免重复请求。
设置与角色是启动必需数据,失败会阻止启动;群组、头像或密钥状态失败时保留可见错误并仅禁用对应能力,不阻止 APP_READY。
启动失败只有两个出口:src/init.js 加载应用模块失败,或 firstLoadInit() 抛错。两处都调用 showStartupFailure(error) 显示不可关闭的模态失败对话框(错误、阶段与调用栈,可复制或重新加载),并继续抛出原错误,日志照常记录。展示不能替换原错误:init.js 的失败可能来自模块加载本身,所以对话框模块在错误路径之外加载,加载失败时作为第二个错误上报。对话框在 Shadow DOM 中渲染,不受主题与自定义 CSS 影响。
后端采样入口见 bootstrap_commands.rs。
5. 扩展加载(离线发现 + 激活批处理)现状
详细扩展兼容链路见:
docs/CurrentState/ThirdPartyExtensions.md
当前策略要点:
- 发现:
startOfflineExtensionsDiscovery()等待 Host Ready 后调用/api/extensions/discover,并加载各扩展manifest.json。 - 系统扩展激活:
activateStartupSystemExtensions({ parallelism })- 同
loading_order的扩展分组;组内按parallelism分块Promise.all并在 chunk 间delay(0)主动让出事件循环。 - Android 设备默认
parallelism = 1;其他默认parallelism = 2(见src/script.js)。
- 同
- third-party 激活:
activateDeferredThirdPartyExtensions({ parallelism: 1 })- 仅处理
local/global扩展,默认在APP_READY后执行,避免把大体量 third-party 模块求值放进启动关键路径。
- 仅处理
- third-party 兼容(hljs):
- third-party 扩展激活前会
await getHljs(),确保window.hljs存在(src/scripts/extensions.js)。
- third-party 扩展激活前会
6. lib.core / lib.optional(按需加载)现状
6.1 产物与入口
- Rspack 入口:
rspack.config.jslib.core→src/lib-bundle-core.js→src/dist/lib.core.bundle.jslib.optional→src/lib-bundle-optional.js→src/dist/lib.optional.bundle.js
6.2 前端门面(lib.js)
src/lib.js- 静态 import core bundle,并 re-export 上游常用库
- 可选库按需加载:
getHljs():动态 import optional bundle;并注册stscript语言(src/scripts/slash-commands/stscript-hljs-language.js)
initLibraryShims():将少量库挂到window(用于第三方扩展兼容);其中window._ = lodash是正式 ABI,需先于 third-party 扩展模块求值完成
6.3 代码高亮(延迟执行)
src/scripts/tauri/perf/code-highlight-coordinator.js- 通过
IntersectionObserver+requestIdleCallback做“近视区”高亮 - 内部通过
getHljs()拉起可选 bundle(不进入 Shell/Core 静态依赖图)
- 通过
7. Panel Runtime 的扩展 DOM “停车”与兼容白名单
Panel Runtime 会在 APP_READY 后安装,用于在抽屉关闭时把部分面板 DOM 子树 park 到 DocumentFragment,减少低端设备的 DOM/observer 压力。
已知兼容点:
src/tauri/main/adapters/panel-runtime/extensions-subtree-gates.js- 当前对扩展设置容器启用“子树 gate + park/hydrate”
- 白名单永远保持连接:
regex_container、qr_container- 目的:避免 SPresets 等脚本在抽屉关闭时找不到
#saved_regex_scripts触发MutationObserver.observe(target not Node)。
- 目的:避免 SPresets 等脚本在抽屉关闭时找不到
src/tauri/main/adapters/panel-runtime/top-settings-panel-parking.js- 左栏 pinned 锚点分两类,判据不同:
LEFT_NAV_REQUIRED_ANCHORS(所有档位,正确性):面板外代码读取的节点保持在线,包括onModelChange读取边界值的#range_block_openai,以及助手读取支持来源的#openai_reasoning_effort_block。LEFT_NAV_COMPAT_ANCHORS(仅compat,兼容让步):#openai_api-presets、#completion_prompt_manager,让第三方脚本在抽屉关闭时仍能选中。
- 世界书抽屉:
compat只 park 条目列表#world_popup_entries_list(世界书 DOM 的主体),世界书下拉框、分页和编辑按钮保持在线;aggressive仍 park 整个#wi-holder。世界书模块持有列表节点,正常渲染与停放期间的更新使用同一个目标;重新打开只挂回原节点,不额外刷新。离屏更新仍有渲染计算,但停放期间的重型条目不连接 document,保留 DOM 裁剪收益;列表的分页后测量与条目定位跳过离屏节点。
- 左栏 pinned 锚点分两类,判据不同:
8. 可观测性(perf + 状态)
- Perf 开关:
localStorage tt:perf = '1'或 URL?ttPerf=1 - 标记(部分):
src/init.js:tt:init:*src/tauri/main/bootstrap.js:tt:tauri:*src/script.js:tt:startup:shell/core/full+tt:startup:ready
- 运行时提示:
src/scripts/tauri/startup/startup-status-overlay.js:右下角非阻塞启动状态 overlay(APP_READY后移除)与启动失败对话框(见 §4)
pnpm run test:browser构建 vendor bundle 并运行tests/browser/*.test.mjs。其中startup-order.test.mjs验证 Host-ready 模块先加载时主应用仍能正常初始化、早到的事件等待应用就绪后呈现,以及启动失败时对话框显示错误且错误继续上报。该测试组包含在pnpm test和pnpm run check中。
9. 明确支持 / 不支持边界
支持:
- 主页尽早可见可点(Shell 先到)。
APP_READY表示主应用已可交互;late-loaded third-party 扩展依赖其 auto-fire 语义继续完成初始化。- third-party 扩展资源同源加载,并提供
window._(lodash)与window.hljs兼容(见上)。
不保证(需要按需扩展白名单/契约):
- 任何第三方脚本在 扩展抽屉关闭 时都能访问到其期望的“扩展设置 DOM”(Panel Runtime 可能 park 子树;目前只对白名单容器强保证)。