文档助手与测试操作入口
文档助手将 GitBook 的 IPilot 对话面板嵌入 MyIP,并提供读取当前诊断结果、重新运行四项核心测试的工具入口。本页聚焦从打开助手到命令执行及结果回传的前端控制链路。
目的与范围
本文覆盖助手加载与访问条件、面板交互、get_my_test_results / run_my_tests 两个工具、参数归一化、命令总线衔接以及错误与并发边界。适合维护助手集成、排查工具调用失败和扩展可运行测试的开发者。
测试本身的网络探测算法、诊断报告导出与分享、登录服务以及配额实现不属于本页范围;这里只描述它们与助手的接口。当前上下文未提供兄弟页面的确切地址,因此不构造未验证的 Wiki 链接。外部 GitBook 的服务端对话存储、模型执行及确认 UI 的内部实现也不在已读取源码中。
概述
助手不是独立的诊断引擎:读取操作复用 useCollectedReport() 提供的结果,运行操作通过已有应用命令触发测试。这样,同一份页面诊断数据既可用于报告,也可用于助手问答,无需再建立一套采集流程。
主要使用方式如下:
- 询问文档或提交问题:
askDocs(query)按需加载嵌入脚本,打开面板;非空问题会作为用户消息发送。 - 解释当前测试结果:
get_my_test_results返回当前收集到的结果,并列出缺失的报告区块。 - 刷新核心诊断:
run_my_tests可触发 IP 查询、连通性、WebRTC 与 DNS 泄漏测试,完成后返回汇总快照。 - 交互降级:加载或打开失败时尝试在新标签页打开文档站点。
源码注释将入口定位为官方部署的登录权益:构建时提供 VITE_DOCS_URL,导航入口按 configs.originalSite 限制,打开动作检查登录状态。需要区分:askDocs() 本身只检查文档地址和 store.isSignedIn === true,并不再次检查 originalSite。导航组件的完整显示逻辑未在本次摘录中核实。
来源:use-docs-assistant.js、use-docs-assistant.js。
架构
Sources: use-docs-assistant.js、use-docs-assistant.js、DocsAssistant.vue、app-commands.js。
三个层次各有明确职责:
- 展示层:
DocsAssistant.vue负责浮动按钮、触屏遮罩、滚动锁定、Escape 关闭及面板状态观察。 - 编排层:
useDocsAssistant()负责脚本加载、登录提示、GitBook 配置、工具注册与结果整理。 - 执行适配层:
normalizeRunSections()将不可信工具参数转为允许的区块列表;命令总线等待测试所有者注册并调用处理器。
打开、加载与降级
1. 统一打开入口
askDocs(query) 按以下顺序执行:
- 未配置文档地址时直接返回,不加载任何助手脚本。
- 未严格满足
store.isSignedIn === true时调用store.setAlert(...)提示登录并返回。 - 对问题执行
(query || '').trim()。 - 若当前 composable 实例的
isOpening已为真,则忽略本次打开请求。 - 等待脚本加载,配置助手、工具、欢迎语与建议问题。
- 打开面板并记录用户意图时间;若问题非空,再调用
navigateToAssistant和postUserMessage。 - 捕获加载、配置或打开阶段异常,输出警告并执行
openDocsSite();最终重置isOpening。
query 是未声明静态类型的 JavaScript 参数,实际按可执行 trim() 的字符串使用。真值非字符串会在进入 try 之前失败,不能将此接口描述为接受任意 JSON 输入。
2. 脚本只在用户请求时加载
DOCS_URL 去除末尾斜杠后形成 ${DOCS_URL}/~gitbook/embed/script.js。加载前设置 window.gitbookSettings.siteURL,将 iframe 的站点指向文档站,而不是当前应用站点。
模块级 loadPromise 合并多个调用者的加载请求。成功时设置共享 isLoaded;脚本加载失败则删除脚本节点、清空缓存 Promise,允许下次重试。
1const loadEmbedScript = () => {
2 if (loadPromise) return loadPromise;
3 loadPromise = new Promise((resolve, reject) => {
4 // The embed script derives its siteURL from the embedding page's
5 // origin, which points the iframe at this site instead of the docs.
6 // gitbookSettings is merged last there, so it pins the docs origin.
7 window.gitbookSettings = { ...(window.gitbookSettings || {}), siteURL: `${DOCS_URL}/` };
8 const script = document.createElement('script');
9 script.src = EMBED_SRC;
10 script.async = true;
11 script.onload = () => {
12 isLoaded.value = true;
13 resolve();
14 };
15 script.onerror = () => {
16 loadPromise = null; // allow a retry on the next attempt
17 script.remove();
18 reject(new Error('GitBook embed script failed to load'));
19 };
20 document.head.appendChild(script);
21 });
22 return loadPromise;
23};Source: use-docs-assistant.js。
这里没有脚本加载超时计时器,因此“脚本长期悬而未决”不等于 onerror,不能保证自动降级。成功加载后若 window.GitBook 配置调用失败,虽然仍会降级打开文档站,但不会清空已成功的 loadPromise。
3. 每次打开重新配置
配置将名称设置为 IPilot,只启用 tabs: ['assistant'],关闭 trademark,并提供欢迎语、建议问题以及打开文档站的侧栏操作。docsQuestions() 使用 tm('nav.DocsQuestions') 和 rt() 转换本地化消息;重新打开时重新配置,因此语言切换可在下一次打开时生效。
这不代表 GitBook 的全部界面均被本地化:源码说明嵌入组件没有单独的语言选项,输入提示等剩余界面保持英文。
来源:use-docs-assistant.js、use-docs-assistant.js。
工具协议与结果数据
读取结果:get_my_test_results
该工具的输入 schema 是无必填字段的对象。注册项携带 confirmation,图标为 eye,提示文案来自 nav.DocsToolConfirm。执行时返回 output 和用于展示的 summary。
快照不是直接返回 Vue 响应式对象,而是对各区块进行 JSON 深拷贝,并添加本地化名称:
1const buildResultsSnapshot = () => {
2 const available = Object.keys(sections);
3 const sectionName = (id) => t(SECTION_TITLE_KEYS[id]);
4 return {
5 results: Object.fromEntries(available.map((id) => [id, {
6 name: sectionName(id),
7 ...JSON.parse(JSON.stringify(sections[id])),
8 }])),
9 missingSections: REPORT_SECTION_IDS.filter((id) => !available.includes(id))
10 .map((id) => ({ id, name: sectionName(id) })),
11 };
12};Source: use-docs-assistant.js。
| 字段 | 结构 | 含义 |
|---|---|---|
results | 以区块 ID 为键的对象 | 当前 collector 中存在的区块;每项添加本地化 name 并合并快照 |
missingSections | { id, name } 数组 | REPORT_SECTION_IDS 中尚未出现在 collector 的区块 |
summary | { icon, text } | 工具完成后展示的简要信息,不是诊断结论 |
对象键承担区块 ID 的角色;构建器没有额外保证向每个区块值注入 id 字段。由于对象展开位于 name 后,如果原快照含有同名属性,原值会覆盖新添加的名称。
源码说明 collector 采用报告白名单,排除 whois 等查询工具;本页没有重新审计 collector 内部过滤规则。助手端的快照函数自身不做 IP 脱敏,且注释明确结果可能包含 IP 与地理位置,因此两个工具都注册了用户确认提示。确认交互由外部嵌入组件实现,不能将该配置等同于服务端授权验证。
运行测试:run_my_tests
此工具接受可选的 sections 数组,schema 中的枚举由 RUNNABLE_SECTION_IDS 生成。能够读取的结果范围与能够启动的测试范围不同:例如工具描述提及速度测试结果,但运行入口只支持下列四项。
1export const RUNNABLE_SECTION_COMMANDS = {
2 ipinfo: { command: 'ipinfo:refresh', payload: {} },
3 connectivity: { command: 'connectivity:run', payload: { trigger: 'manual' } },
4 webrtc: { command: 'webrtc:run', payload: { isRefresh: true } },
5 dnsleak: { command: 'dnsleak:run', payload: { isRefresh: true } },
6};Source: docs-run-tests.js。
这些载荷刻意使用手动或刷新语义,以便重新触发已经在页面启动时运行过的测试,而不只是首次初始化。实际网络测试由命令所有者完成,不由助手自行发起探测。
参数归一化边界
normalizeRunSections(args) 并不只信任 JSON schema,而是再次做运行时处理:
| 输入情况 | requested | unknown |
|---|---|---|
未传参数、缺少 sections、sections 不是数组 | 全部四项 | 空数组 |
sections 为空数组 | 全部四项 | 空数组 |
| 含合法区块 ID | 按首次出现顺序保留、去重 | 其他元素转字符串后去重 |
| 非空数组但全部未知 | 空数组,即不执行测试 | 全部未知标签 |
这保留了“未指定就运行全部”与“明确指定但不认识就不运行”的区别,防止把拼写错误的具体请求扩大成完整诊断。
1export const normalizeRunSections = (args) => {
2 const sections = Array.isArray(args?.sections) ? args.sections : [];
3 if (!sections.length) return { requested: [...RUNNABLE_SECTION_IDS], unknown: [] };
4 const requested = [];
5 const unknown = [];
6 for (const entry of sections) {
7 if (RUNNABLE_SECTION_IDS.includes(entry)) {
8 if (!requested.includes(entry)) requested.push(entry);
9 } else {
10 const label = String(entry);
11 if (!unknown.includes(label)) unknown.push(label);
12 }
13 }
14 return { requested, unknown };
15};Source: docs-run-tests.js。
核心执行流程
Sources: use-docs-assistant.js、app-commands.js。
导航与所有者就绪
测试命令的所有者只在首页挂载,因此执行器先检查 route.name,必要时 await router.push('/')。随后为每项测试调用 waitForAppCommand(),避免导航完成但组件还没注册命令时直接执行。
即使参数全部未知、requested 为空,导航语句仍会执行。路由导航位于 Promise.allSettled 之外;如果导航 Promise 拒绝,工具整体拒绝,不会得到逐项 failed 汇总。
并行与部分失败
实际编排代码如下:
1execute: async (args) => {
2 const { requested, unknown } = normalizeRunSections(args);
3 if (route.name !== 'home') await router.push('/');
4 const settled = await Promise.allSettled(requested.map(async (id) => {
5 const { command, payload } = RUNNABLE_SECTION_COMMANDS[id];
6 await waitForAppCommand(command, { timeoutMs: RUN_TEST_TIMEOUT });
7 await dispatchAppCommand(command, payload, { timeoutMs: RUN_TEST_TIMEOUT });
8 }));
9 const ran = [];
10 const failed = [];
11 settled.forEach((result, i) => {
12 if (result.status === 'fulfilled') {
13 ran.push(requested[i]);
14 } else {
15 const code = result.reason?.code;
16 const reason = code === 'timeout' || code === 'unavailable' ? code : 'error';
17 failed.push({ id: requested[i], reason });
18 }
19 });
20 const output = { ...buildResultsSnapshot(), ran, failed };
21 if (unknown.length) output.unknownSections = unknown;
22 return {
23 output,
24 summary: { icon: 'play', text: t('nav.DocsToolRunSummary') },
25 };
26},Source: use-docs-assistant.js。
- 每个区块内部依次等待注册、执行命令;不同区块之间并行。
Promise.allSettled保留正常测试的完成信息,不会因为一项失败中断整组等待。ran表示对应命令链 fulfilled,而不是网络检测得到了“健康”结论。failed只保留timeout、unavailable;其他错误一律归为error。- 命令处理器的返回值未被直接放入工具输出,实际返回内容在所有命令 settled 后重新从 collector 读取。
- 快照包含当时全部已收集区块,不仅是本次请求的区块。失败区块的旧结果没有在此处清除;判断结果新鲜度时必须结合
ran与failed,不能只看results中是否存在该键。
运行工具在读取快照之外增加以下字段:
| 字段 | 结构 | 返回规则 |
|---|---|---|
ran | 区块 ID 数组 | 本次命令链成功完成的区块 |
failed | { id, reason } 数组 | 本次命令链失败的区块 |
unknownSections | 字符串数组 | 仅在存在未知输入时附加 |
面板状态、移动端与生命周期
面板状态不能只由 MyIP 的按钮决定:外部嵌入组件也可能改变面板。因此 isOpen 既会在本地主动打开/关闭时更新,也会由 DOM 观察结果修正。
可见性同步
DocsAssistant.vue 在 isLoaded 变真后启动每 300ms 一次的轮询。panelIsVisible() 查询 #gitbook-widget-window,要求元素宽高大于零且 visibility 不为 hidden。它不检查 opacity、屏幕内位置或 iframe 内部业务状态。
本地打开与关闭会调用 markIntent();随后 700ms 内 isSettling() 为真,轮询暂不覆盖状态,避免动画中的旧尺寸把按钮图标翻回去。
来源:DocsAssistant.vue、use-docs-assistant.js。
操作入口与清理
- 浮动按钮只在
isLoaded后出现,打开时展示关闭图标,否则展示助手图标;首次加载之前需通过其他askDocs调用入口进入。 toggleDocs()根据共享isOpen选择closeDocs()或askDocs('')。- 触屏条件使用
store.isMobile;打开时添加可点击遮罩,并设置body.style.overflow = 'hidden'、overscrollBehavior = 'none'。 - 桌面不锁定页面滚动;Escape 可以关闭已打开面板。
- 退出登录时若面板打开则关闭,已加载的浮动按钮保留,再次打开会提示登录。
- 卸载组件时释放滚动锁、清除轮询计时器、移除键盘监听。这里没有删除 GitBook 脚本、销毁外部会话或重置模块级加载状态。
滚动解锁将样式设为空字符串,而不是恢复之前保存的值。若扩展时加入其他模态框,应检查多个组件同时修改 body 样式的相互影响。
来源:DocsAssistant.vue、DocsAssistant.vue。
配置与固定参数
以下区分构建配置、运行时状态和源码常量;不是所有条目都是环境变量。
| 名称 | 类型 | 默认值或当前值 | 作用 |
|---|---|---|---|
VITE_DOCS_URL | 构建时字符串 | 空字符串 | 文档站地址;空值使 askDocs() 直接返回;移除末尾斜杠 |
isDocsConfigured | 派生布尔值 | !!DOCS_URL | 表示地址是否非空,不验证 URL 是否有效 |
store.isSignedIn | 运行时状态 | 此处不定义默认值 | 严格等于 true 才允许打开 |
configs.originalSite | 运行时状态 | 此处不定义默认值 | 源码注释声明用于官方部署导航入口限制,非 askDocs 内检查 |
ASSISTANT_NAME | 字符串常量 | IPilot | 外部面板名称;注释说明嵌入组件限制为 32 字符 |
RUN_TEST_TIMEOUT | 毫秒数常量 | 60000 | 分别用于等待注册与命令执行 |
SETTLE_MS | 毫秒数常量 | 700 | 本地开关后的动画保护窗口 |
SYNC_INTERVAL | 毫秒数常量 | 300 | 加载后面板可见性轮询周期 |
PANEL_SELECTOR | 字符串常量 | #gitbook-widget-window | 与 GitBook 外部 DOM 结构耦合的选择器 |
DOCK_INSET | CSS 字符串常量 | max(18px, calc((100vw - 1600px) / 2 + 18px)) | 以 1600px 内容区和 18px 间距定位左侧入口 |
来源:use-docs-assistant.js、DocsAssistant.vue。
API 参考
这些是前端 JavaScript 函数与工具注册协议,不是 HTTP 路由。源码没有 TypeScript 类型声明,下表以实际参数与返回行为说明契约。
| 接口 | 参数与返回 | 错误或注意事项 |
|---|---|---|
useDocsAssistant() | 无参数;返回 askDocs、closeDocs、setOpen、isOpen、isLoaded、isOpening、docsQuestions | 依赖 Vue、i18n、router 和 store 上下文 |
askDocs(query) | 异步;query 按字符串处理;无业务返回数据 | 配置缺失或未登录直接返回;加载/打开阶段错误降级;非字符串问题可能在 try 前拒绝 |
closeDocs() | 无参数;记录关闭意图并令 isOpen 为假 | 仅当 window.GitBook 是函数时调用 close;调用异常未单独捕获 |
setOpen(value) | 直接写入共享 isOpen | 未做类型校验,设计上供面板观察器传入布尔值 |
docsQuestions() | 返回本地化建议问题数组 | 依赖 nav.DocsQuestions 的消息结构 |
isSettling() | 返回距最后打开/关闭意图是否少于 700ms | 模块级共享时钟 |
normalizeRunSections(args) | 返回 { requested, unknown } | 对 args?.sections 做数组判定、白名单筛选与去重 |
waitForAppCommand(name, { timeoutMs } = {}) | 返回所有者注册后 resolve 的 Promise | 超时拒绝,error.code = 'timeout' |
dispatchAppCommand(name, payload = {}, { timeoutMs } = {}) | 返回包装处理器结果的 Promise | 未注册为 unavailable;超时为 timeout;处理器异常透传 |
来源:use-docs-assistant.js、docs-run-tests.js、app-commands.js。
失败模式、并发与运行注意事项
超时不是取消
命令总线通过 Promise.race([result, timeout]) 返回超时,并在最终清除计时器,没有向处理器发送中断信号。因此工具返回 timeout 后,原测试仍可能继续并更新页面结果。
等待注册和执行各有独立的 60 秒限额,不是整个工具共用 60 秒:若注册等待接近限额后才成功,随后执行还可再等待约 60 秒。导航耗时不包含在这两个计时器中。工具描述中的“约 10–60 秒”不能视为严格的整体超时保证。
来源:app-commands.js、use-docs-assistant.js。
并发边界
- 共享加载,局部打开锁:
loadPromise、isOpen、isLoaded为模块级;isOpening在每次useDocsAssistant()中创建。因此不同调用实例可以共享脚本加载,却仍可能先后配置、打开面板并发送消息。 - 单次请求去重,不跨请求去重:归一化只去除当前
sections中的重复项;工具执行器没有全局运行锁。 - 命令所有者唯一,但执行不串行化:总线为一个名称保存一个处理器,并不限制同一处理器被同时调用。处理器自身是否防重入,在本次读取范围内未核实。
- 注册替换保护:重复注册会警告并覆盖旧处理器;旧注销函数只在注册表仍指向自身时才删除,避免卸载旧组件误删新所有者。
- 就绪检查与执行不是事务:等待成功之后所有者仍可能被卸载,此时派发会返回
unavailable。
来源:use-docs-assistant.js、use-docs-assistant.js、app-commands.js。
权限与错误信息
登录校验发生在 askDocs(),两个工具的 execute 未再次读取登录状态。退出登录会关闭 UI,但没有显式取消进行中的命令,也没有在此处注销工具。不能仅凭面板关闭推断正在执行的诊断已经停止。
命令总线约定可返回 auth、quota、input 等错误码,但助手聚合时将它们统一折叠为 error。排查时应进一步检查处理器拒绝原因,而不能只依赖助手输出区分登录、配额与输入错误。
来源:DocsAssistant.vue、app-commands.js、use-docs-assistant.js。
性能与排障顺序
懒加载避免在未使用助手时加载第三方脚本;加载完成后,即使面板关闭,300ms 轮询仍持续到组件卸载。快照使用 JSON 序列化深拷贝,其开销随已收集数据量增加;这里没有缓存,也没有对单个区块的序列化异常做隔离。
建议按以下顺序排障:文档地址是否配置 → 登录状态是否严格为真 → 嵌入脚本是否加载 → window.GitBook 配置调用是否成功 → 首页是否进入 → 命令是否注册 → 命令是否完成 → collector 是否出现预期区块。面板图标异常则单独检查 DOM 选择器、可见性判定和 700ms 动画窗口。
扩展与验证
新增可运行测试
当前实现的明确扩展点是 RUNNABLE_SECTION_COMMANDS。增加条目会自动进入由 Object.keys() 派生的枚举和默认“全部运行”集合,但还需要配套完成:
- 注册对应命令处理器,并保证其 Promise 在诊断工作完成后才结束。
- 确认处理器所在页面与当前“先导航首页”的假设一致。
- 将结果接入 collector,并保证报告 schema 与
SECTION_TITLE_KEYS有对应定义。 - 更新工具描述和输入说明:它们目前明确写死“四项核心测试”。
- 明确超时后继续执行、重复调用以及敏感信息确认的行为。
这些是从当前依赖关系推导出的维护检查项,不代表新增映射即可自动完成整个集成。
测试证据与建议
检索确认存在 docs-run-tests.test.js,文件头说明针对区块到命令总线映射和工具参数防御性归一化。本次受源码读取预算限制,未读取其断言,也未运行测试,因此不宣称具体用例已通过。
建议验证的关键边界包括:缺省/空/错误类型参数运行全部、非空未知集合不运行、重复项去重、部分失败保留成功信息、导航失败、注册等待超时、执行超时后底层仍继续、多个入口同时打开,以及移动端关闭/卸载后的滚动恢复。