@tanzerfe/page-use 0.0.6 → 0.0.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,8 +1,64 @@
1
- import { PageAgent } from "page-agent";
1
+ import { PageAgent, tool } from "page-agent";
2
+ import { clickElement } from "@page-agent/page-controller";
3
+ import * as z from "zod/v4";
2
4
  import { LitElement, css, html, nothing, svg } from "lit";
3
5
  import { keyed } from "lit/directives/keyed.js";
4
6
  import { unsafeHTML } from "lit/directives/unsafe-html.js";
5
7
 
8
+ //#region src/tour/styles.ts
9
+ /** 内置风格 key:日常使用者 */
10
+ const TOUR_STYLE_USER = "user";
11
+ /** 内置风格 key:培训讲师 */
12
+ const TOUR_STYLE_TRAINER = "trainer";
13
+ /** 内置风格;宿主以相同 key 配置时覆盖对应项 */
14
+ const DEFAULT_TOUR_STYLES = [{
15
+ key: TOUR_STYLE_USER,
16
+ label: "日常使用者",
17
+ prompt: [
18
+ "- 受众:日常使用本系统的业务人员。",
19
+ "- 侧重:这个模块能做什么、怎么操作(入口按钮、筛选方式、常用动作)。",
20
+ "- 篇幅:每个模块一到两句,语气简洁直接,不讲背景与原理。"
21
+ ].join("\n")
22
+ }, {
23
+ key: TOUR_STYLE_TRAINER,
24
+ label: "培训讲师",
25
+ prompt: [
26
+ "- 场景:讲解人边演示边讲解,听众可能是学员、领导或客户。",
27
+ "- 称呼:保持通用,开场可用「大家好」;不要使用「各位学员」「同学们」等指定听众身份的称呼。",
28
+ "- 侧重:模块的业务含义、指标或字段的统计口径、典型操作流程与注意事项;开场说明页面在业务流程中的作用。",
29
+ "- 篇幅:每个模块二到四句,条理清晰、用语规范;可结合按页提示中的业务说明,不编造页面上没有的数据与规则。"
30
+ ].join("\n")
31
+ }];
32
+ /** 合并内置与宿主风格:同 key 覆盖(保持内置顺序),新 key 追加;丢弃 key / label / prompt 为空的项 */
33
+ function mergeTourStyles(custom) {
34
+ const result = DEFAULT_TOUR_STYLES.map((style) => ({ ...style }));
35
+ for (const style of custom ?? []) {
36
+ const key = style?.key?.trim();
37
+ const label = style?.label?.trim();
38
+ const prompt = style?.prompt?.trim();
39
+ if (!key || !label || !prompt) continue;
40
+ const item = {
41
+ key,
42
+ label,
43
+ prompt
44
+ };
45
+ const index = result.findIndex((s) => s.key === key);
46
+ if (index >= 0) result[index] = item;
47
+ else result.push(item);
48
+ }
49
+ return result;
50
+ }
51
+ /** 按候选 key 依次查找第一个存在的风格;都不存在时返回 null */
52
+ function resolveTourStyle(styles, ...keys) {
53
+ for (const key of keys) {
54
+ if (!key) continue;
55
+ const style = styles.find((s) => s.key === key);
56
+ if (style) return style;
57
+ }
58
+ return null;
59
+ }
60
+
61
+ //#endregion
6
62
  //#region src/voice/browserEngine.ts
7
63
  /** 内置浏览器引擎的保留 id;注入引擎使用该 id 会被忽略 */
8
64
  const BROWSER_ENGINE_ID = "browser";
@@ -203,6 +259,39 @@ const DEFAULT_MAX_STEPS = 20;
203
259
  const DEFAULT_SESSION_HEADER = "x-opencode-session";
204
260
  /** localStorage / sessionStorage 键前缀,可通过 storageKeyPrefix 覆盖 */
205
261
  const DEFAULT_STORAGE_KEY_PREFIX = "tz-page-use";
262
+ /** 页面讲解单次最多切换 Tab 的次数(不含结束时切回);每次切换多一次 LLM 调用,限制演示时长 */
263
+ const DEFAULT_TOUR_MAX_TAB_SWITCHES = 5;
264
+ /** 未指定内容区时的回退顺序 */
265
+ const CONTENT_ROOT_FALLBACKS = ["main", "[role=main]"];
266
+ function resolveContentRoot(getter) {
267
+ return () => {
268
+ let el;
269
+ try {
270
+ el = getter?.();
271
+ } catch (err) {
272
+ console.warn("[PageUse] getContentRoot 执行异常,使用默认内容区", err);
273
+ }
274
+ if (el) return el;
275
+ if (typeof document === "undefined") return null;
276
+ for (const selector of CONTENT_ROOT_FALLBACKS) {
277
+ const found = document.querySelector(selector);
278
+ if (found) return found;
279
+ }
280
+ return document.body;
281
+ };
282
+ }
283
+ function normalizeTour(value) {
284
+ const config = typeof value === "object" && value ? value : {};
285
+ const max = config.maxTabSwitches;
286
+ const styles = mergeTourStyles(config.styles);
287
+ const preferred = config.defaultStyle?.trim() || "user";
288
+ return {
289
+ enabled: value !== false,
290
+ maxTabSwitches: typeof max === "number" && Number.isInteger(max) && max > 0 ? max : 5,
291
+ styles,
292
+ defaultStyle: styles.some((s) => s.key === preferred) ? preferred : styles[0].key
293
+ };
294
+ }
206
295
  /** 相对路径补齐为当前 origin 的绝对地址并去掉末尾 /;无法解析时返回空串 */
207
296
  function toAbsoluteUrl(input) {
208
297
  const base = typeof window !== "undefined" ? window.location.origin : void 0;
@@ -248,7 +337,9 @@ function normalizeOptions(options) {
248
337
  storageKeyPrefix: options.storageKeyPrefix || "tz-page-use",
249
338
  pageAgentOptions: options.pageAgentOptions ?? {},
250
339
  exposeGlobal: options.exposeGlobal !== false,
251
- ttsEngines: listTtsEngines(options.tts)
340
+ ttsEngines: listTtsEngines(options.tts),
341
+ getContentRoot: resolveContentRoot(options.getContentRoot),
342
+ pageTour: normalizeTour(options.pageTour)
252
343
  };
253
344
  }
254
345
  /** URL 查询参数:?pageUseDebug=1 打开调试面板,?pageUseDebug=0 关闭;结果记入 sessionStorage */
@@ -343,6 +434,7 @@ const DEFAULT_PAGE_USE_SETTINGS = {
343
434
  voiceRate: 1.1,
344
435
  voiceEngineId: "",
345
436
  voiceNames: {},
437
+ tourStyle: "",
346
438
  launcherVisible: null,
347
439
  fabPos: null
348
440
  };
@@ -457,6 +549,11 @@ const pageUseSettings = {
457
549
  }
458
550
  };
459
551
 
552
+ //#endregion
553
+ //#region src/tour/constants.ts
554
+ /** 页面讲解工具名:大纲文本、工具注册与播报静默列表共用 */
555
+ const PRESENT_PAGE_TOOL_NAME = "present_page";
556
+
460
557
  //#endregion
461
558
  //#region src/voice/preview.ts
462
559
  /**
@@ -509,6 +606,7 @@ function stopPreview() {
509
606
  * 队列只有两部分:当前正在播报的一组文本,以及一条待播的步骤文本。
510
607
  * - 步骤播报不打断当前播报,只覆盖待播槽(语音跟不上执行时丢弃中间步骤);
511
608
  * - 提问 / 结论先清空再播报;
609
+ * - 页面讲解(speakAndWait)先清空再播报,返回的 Promise 在整组结束时 resolve,用于光标与语音同步;
512
610
  * - 每组开始时读取一次引擎并在组内固定使用(splitSentences 与中止对象保持一致);
513
611
  * - 每组持有一个 AbortController,清空时中止,引擎据此停止自身的合成与播放;
514
612
  * - 每次清空递增代次,旧代次 speak() 的迟到 resolve 一律忽略,避免中止后误触发下一条。
@@ -528,9 +626,20 @@ function createSpeaker(getSettings, getEngine = () => browserTtsEngine) {
528
626
  let controller = null;
529
627
  /** 当前组使用的引擎;组内不随设置切换 */
530
628
  let engine = getEngine();
531
- function playGroup(sentences) {
629
+ /** 当前组结束(播完或被清空)时的回调;仅 speakAndWait 的组设置 */
630
+ let groupDone = null;
631
+ function finishGroup() {
632
+ const done = groupDone;
633
+ groupDone = null;
634
+ done?.();
635
+ }
636
+ function playGroup(sentences, onDone) {
532
637
  engine = getEngine();
533
- if (!engine.isAvailable()) return;
638
+ if (!engine.isAvailable()) {
639
+ onDone?.();
640
+ return;
641
+ }
642
+ groupDone = onDone ?? null;
534
643
  current = engine.splitSentences ? [...sentences] : [sentences.join("")];
535
644
  controller = new AbortController();
536
645
  playNext();
@@ -540,6 +649,7 @@ function createSpeaker(getSettings, getEngine = () => browserTtsEngine) {
540
649
  if (text === void 0 || !controller) {
541
650
  current = null;
542
651
  controller = null;
652
+ finishGroup();
543
653
  if (pending) {
544
654
  const next = pending;
545
655
  pending = null;
@@ -569,6 +679,7 @@ function createSpeaker(getSettings, getEngine = () => browserTtsEngine) {
569
679
  controller = null;
570
680
  current = null;
571
681
  pending = null;
682
+ finishGroup();
572
683
  }
573
684
  return {
574
685
  enqueue(sentences) {
@@ -583,7 +694,19 @@ function createSpeaker(getSettings, getEngine = () => browserTtsEngine) {
583
694
  clear();
584
695
  if (sentences.length) playGroup(sentences);
585
696
  },
586
- clear
697
+ clear,
698
+ speakAndWait(sentences, signal) {
699
+ clear();
700
+ if (!sentences.length || signal?.aborted) return Promise.resolve();
701
+ return new Promise((resolve) => {
702
+ const onAbort = () => clear();
703
+ signal?.addEventListener("abort", onAbort, { once: true });
704
+ playGroup(sentences, () => {
705
+ signal?.removeEventListener("abort", onAbort);
706
+ resolve();
707
+ });
708
+ });
709
+ }
587
710
  };
588
711
  }
589
712
 
@@ -669,7 +792,7 @@ function joinLines(lines) {
669
792
  return result.replace(/([。!?!?;;,,、::])[。!?!?;;,,、::\s]+/g, "$1").replace(/^[。!?!?;;,,、::\s]+/, "").trim();
670
793
  }
671
794
  /** 超过上限时在上限内最后一个句末标点处截断,找不到则硬截断,并追加提示语 */
672
- function truncate(text, maxChars, hint) {
795
+ function truncate$1(text, maxChars, hint) {
673
796
  if (text.length <= maxChars) return text;
674
797
  const head = text.slice(0, maxChars);
675
798
  let cut = -1;
@@ -712,7 +835,7 @@ function toSpeechText(text, options = {}) {
712
835
  const maxChars = options.maxChars ?? 240;
713
836
  const joined = joinLines(toLines((text || "").replace(/```[\s\S]*?(?:```|$)/g, "\n").replace(/<\/?[a-zA-Z][^>]*>/g, "")));
714
837
  if (!joined) return [];
715
- return splitSentences(truncate(joined, maxChars, options.truncatedHint ?? "详细内容请查看面板"));
838
+ return splitSentences(truncate$1(joined, maxChars, options.truncatedHint ?? "详细内容请查看面板"));
716
839
  }
717
840
 
718
841
  //#endregion
@@ -720,6 +843,7 @@ function toSpeechText(text, options = {}) {
720
843
  /**
721
844
  * 演示播报:订阅进度事件,按设置把步骤描述、提问与结论交给 speaker 播报。
722
845
  * 只订阅事件,不影响任务执行节奏;对所有任务来源生效。
846
+ * 例外:页面讲解经 narrate() 逐段播报并等待播完,由讲解工具控制光标移动的节奏。
723
847
  * 是否播报 = 设置 voiceEnabled(总开关,持久化)且未处于本次任务静音(narrationMute,运行期)。
724
848
  */
725
849
  /**
@@ -733,8 +857,15 @@ const SILENT_STEP_TOOLS = /* @__PURE__ */ new Set([
733
857
  "done",
734
858
  "ask_user",
735
859
  "error",
736
- "retry"
860
+ "retry",
861
+ PRESENT_PAGE_TOOL_NAME
737
862
  ]);
863
+ /** 页面讲解在语音关闭 / 静音 / 未启动播报时,每个模块的停留时长(ms),供观众看清光标位置 */
864
+ const SILENT_DWELL_MS = 2500;
865
+ /** 讲解词的播报字数上限:高于讲解工具的讲解词上限,正常不截断 */
866
+ const TOUR_SPEECH_MAX_CHARS$1 = 400;
867
+ /** 当前运行的播报(startNarration 期间有效),供页面讲解逐段播报 */
868
+ let active = null;
738
869
  /**
739
870
  * 调试面板名称(PageUseLauncherConfig.title),用于截断提示语「详细内容请查看「名称」」。
740
871
  * 由 launcher 挂载时登记、卸载时撤销;未挂载面板时为 null,提示语回退为「详细内容请查看面板」。
@@ -763,7 +894,11 @@ function startNarration({ subscribe, engines, speaker: injected }) {
763
894
  };
764
895
  }, () => resolveTtsEngine(available, pageUseSettings.get().voiceEngineId));
765
896
  const enabled = () => pageUseSettings.get().voiceEnabled && !narrationMute.get().muted;
766
- const speech = (text) => toSpeechText(text, { truncatedHint: buildTruncatedHint(panelName ?? void 0) });
897
+ const current = {
898
+ speaker,
899
+ enabled
900
+ };
901
+ active = current;
767
902
  const offEvents = subscribe((event) => {
768
903
  switch (event.type) {
769
904
  case "started":
@@ -791,12 +926,675 @@ function startNarration({ subscribe, engines, speaker: injected }) {
791
926
  if (muted) speaker.clear();
792
927
  });
793
928
  return () => {
929
+ if (active === current) active = null;
794
930
  offEvents();
795
931
  offSettings();
796
932
  offMute();
797
933
  speaker.clear();
798
934
  };
799
935
  }
936
+ function speech(text) {
937
+ return toSpeechText(text, { truncatedHint: buildTruncatedHint(panelName ?? void 0) });
938
+ }
939
+ /** 等待 ms 毫秒,signal 中止时立即 resolve */
940
+ function dwell(ms, signal) {
941
+ if (ms <= 0 || signal?.aborted) return Promise.resolve();
942
+ return new Promise((resolve) => {
943
+ const finish = () => {
944
+ clearTimeout(timer);
945
+ signal?.removeEventListener("abort", finish);
946
+ resolve();
947
+ };
948
+ const timer = setTimeout(finish, ms);
949
+ signal?.addEventListener("abort", finish, { once: true });
950
+ });
951
+ }
952
+ /**
953
+ * 页面讲解的一段讲解词:播报开启时完整播完再 resolve;
954
+ * 语音关闭、静音或未启动播报时按 SILENT_DWELL_MS 停留;
955
+ * 播报中途被静音 / 关闭而提前结束时补足停留时间,避免光标立即跳走。signal 中止时立即 resolve。
956
+ */
957
+ async function narrate(text, signal) {
958
+ if (signal?.aborted) return;
959
+ const startedAt = Date.now();
960
+ const narration = active;
961
+ if (narration?.enabled()) {
962
+ const sentences = toSpeechText(text, { maxChars: TOUR_SPEECH_MAX_CHARS$1 });
963
+ await narration.speaker.speakAndWait(sentences, signal);
964
+ if (signal?.aborted || narration.enabled()) return;
965
+ await dwell(SILENT_DWELL_MS - (Date.now() - startedAt), signal);
966
+ return;
967
+ }
968
+ await dwell(SILENT_DWELL_MS, signal);
969
+ }
970
+
971
+ //#endregion
972
+ //#region src/tour/highlight.ts
973
+ /**
974
+ * 页面讲解的模块高亮框:fixed 定位的描边层,位于 SimulatorMask 之上、不拦截指针。
975
+ * 同一时间只存在一个;仅在窗口尺寸变化时重算位置(讲解期间蒙层拦截用户滚动)。
976
+ */
977
+ /** 高亮框元素 id,便于宿主覆盖样式或排查 */
978
+ const TOUR_HIGHLIGHT_ID = "tz-page-use-tour-highlight";
979
+ /** 比 page-agent SimulatorMask 的 z-index(2147483641)高一层,保证在蒙层上方可见 */
980
+ const HIGHLIGHT_Z_INDEX = 2147483642;
981
+ /** 高亮框相对模块的外扩像素,避免描边压住模块边缘内容 */
982
+ const HIGHLIGHT_PADDING = 6;
983
+ let current = null;
984
+ function place(el, target) {
985
+ const rect = target.getBoundingClientRect();
986
+ el.style.left = `${rect.left - HIGHLIGHT_PADDING}px`;
987
+ el.style.top = `${rect.top - HIGHLIGHT_PADDING}px`;
988
+ el.style.width = `${rect.width + 12}px`;
989
+ el.style.height = `${rect.height + 12}px`;
990
+ }
991
+ function showTourHighlight(target) {
992
+ hideTourHighlight();
993
+ const el = document.createElement("div");
994
+ el.id = TOUR_HIGHLIGHT_ID;
995
+ el.setAttribute("aria-hidden", "true");
996
+ Object.assign(el.style, {
997
+ position: "fixed",
998
+ pointerEvents: "none",
999
+ zIndex: String(HIGHLIGHT_Z_INDEX),
1000
+ boxSizing: "border-box",
1001
+ borderRadius: "8px",
1002
+ border: "2px solid var(--tz-page-use-primary, #0879ff)",
1003
+ boxShadow: "0 0 0 4px rgba(8, 121, 255, 0.18), 0 0 18px rgba(8, 121, 255, 0.35)",
1004
+ transition: "left 0.25s ease, top 0.25s ease, width 0.25s ease, height 0.25s ease"
1005
+ });
1006
+ place(el, target);
1007
+ document.body.appendChild(el);
1008
+ const onResize = () => place(el, target);
1009
+ window.addEventListener("resize", onResize);
1010
+ current = {
1011
+ el,
1012
+ target,
1013
+ onResize
1014
+ };
1015
+ }
1016
+ function hideTourHighlight() {
1017
+ if (!current) return;
1018
+ window.removeEventListener("resize", current.onResize);
1019
+ current.el.remove();
1020
+ current = null;
1021
+ }
1022
+
1023
+ //#endregion
1024
+ //#region src/tour/sections.ts
1025
+ /**
1026
+ * 页面模块识别:在内容区根节点内,按标题(h1–h3)与带 aria-label 的区域划分讲解模块。
1027
+ *
1028
+ * - 标题模块的根节点:从标题逐级向上,父元素一旦还包含另一个「级别不低于自身」的标题即停止,
1029
+ * 取停止前一层;这样 h2 模块能包住其下的 h3,又不会扩到并列 h2 的公共容器。
1030
+ * - 第一个 h1 作为页面概述,不进入可指向的模块清单,但仍参与边界判断。
1031
+ * - 纯 DOM 函数,可见性判断可注入(jsdom 不计算布局)。
1032
+ */
1033
+ /** 模块清单条数上限:清单会追加到每一步的按页提示,控制 token 增量 */
1034
+ const SECTION_LIMIT = 20;
1035
+ /** 模块说明的最大字符数(超出截断并加省略号) */
1036
+ const SECTION_DESC_MAX_CHARS = 60;
1037
+ /** 默认可见性:已渲染、尺寸非 0、visibility 不为 hidden(aria-hidden 由扫描统一处理) */
1038
+ function isElementVisible(el) {
1039
+ if (el.getClientRects().length === 0) return false;
1040
+ const rect = el.getBoundingClientRect();
1041
+ if (rect.width <= 0 || rect.height <= 0) return false;
1042
+ return getComputedStyle(el).visibility !== "hidden";
1043
+ }
1044
+ /** 在 aria-hidden 子树内的元素不对外呈现,也不作为讲解对象 */
1045
+ function isAriaHidden(el) {
1046
+ return el.closest("[aria-hidden=\"true\"]") !== null;
1047
+ }
1048
+ function normalizeText(text) {
1049
+ return (text || "").replace(/\s+/g, " ").trim();
1050
+ }
1051
+ function truncate(text, max) {
1052
+ return text.length > max ? `${text.slice(0, max)}…` : text;
1053
+ }
1054
+ /** a 在文档中位于 b 之前 */
1055
+ function precedes(a, b) {
1056
+ return (a.compareDocumentPosition(b) & Node.DOCUMENT_POSITION_FOLLOWING) !== 0;
1057
+ }
1058
+ function collectHeadings(root, isVisible) {
1059
+ const result = [];
1060
+ for (const el of root.querySelectorAll("h1, h2, h3")) {
1061
+ const name = normalizeText(el.textContent);
1062
+ if (!name || isAriaHidden(el) || !isVisible(el)) continue;
1063
+ result.push({
1064
+ el,
1065
+ level: Number(el.tagName.charAt(1)),
1066
+ name
1067
+ });
1068
+ }
1069
+ return result;
1070
+ }
1071
+ /** 从标题向上找模块根节点,不越过内容区根节点 */
1072
+ function findSectionRoot(heading, all, root) {
1073
+ let node = heading.el;
1074
+ while (node.parentElement && node.parentElement !== root) {
1075
+ const parent = node.parentElement;
1076
+ if (all.some((c) => c !== heading && c.level <= heading.level && parent.contains(c.el))) break;
1077
+ node = parent;
1078
+ }
1079
+ return node;
1080
+ }
1081
+ /** container 内位于 after 之后(且不在 after 内部)的第一个非空 <p> */
1082
+ function firstParagraphAfter(container, after, scope, isVisible) {
1083
+ for (const p of container.querySelectorAll("p")) {
1084
+ if (!scope.contains(p) || after.contains(p) || !precedes(after, p)) continue;
1085
+ if (isAriaHidden(p) || !isVisible(p)) continue;
1086
+ const text = normalizeText(p.textContent);
1087
+ if (text) return text;
1088
+ }
1089
+ return "";
1090
+ }
1091
+ /**
1092
+ * 说明文字:先在标题的父元素内找标题之后的 <p>(`<div><h2/><p/></div>`);
1093
+ * 找不到再到祖父元素内找标题所在分支之后的 <p>(`<header><h2/></header><p/>`)。
1094
+ * 只在 scope(模块根节点或内容区)内查找,避免取到相邻模块的文字。
1095
+ */
1096
+ function findDescription(heading, scope, isVisible) {
1097
+ const parent = heading.parentElement;
1098
+ if (!parent) return "";
1099
+ const own = firstParagraphAfter(parent, heading, scope, isVisible);
1100
+ if (own) return own;
1101
+ const grand = parent.parentElement;
1102
+ if (!grand || !scope.contains(parent) || parent === scope) return "";
1103
+ return firstParagraphAfter(grand, parent, scope, isVisible);
1104
+ }
1105
+ function scanPageSections(root, isVisible = isElementVisible) {
1106
+ const headings = collectHeadings(root, isVisible);
1107
+ const overviewHeading = headings.find((h) => h.level === 1) ?? null;
1108
+ const overview = overviewHeading ? {
1109
+ name: overviewHeading.name,
1110
+ desc: truncate(findDescription(overviewHeading.el, root, isVisible), 60),
1111
+ anchor: overviewHeading.el
1112
+ } : null;
1113
+ const found = [];
1114
+ for (const heading of headings) {
1115
+ if (heading === overviewHeading) continue;
1116
+ const sectionRoot = findSectionRoot(heading, headings, root);
1117
+ found.push({
1118
+ name: heading.name,
1119
+ desc: truncate(findDescription(heading.el, sectionRoot, isVisible), 60),
1120
+ level: heading.level,
1121
+ root: sectionRoot,
1122
+ anchor: heading.el
1123
+ });
1124
+ }
1125
+ for (const el of root.querySelectorAll("section[aria-label], [role=\"region\"][aria-label]")) {
1126
+ const name = normalizeText(el.getAttribute("aria-label"));
1127
+ if (!name || isAriaHidden(el) || !isVisible(el)) continue;
1128
+ if (headings.some((h) => el.contains(h.el))) continue;
1129
+ found.push({
1130
+ name,
1131
+ desc: "",
1132
+ level: 0,
1133
+ root: el,
1134
+ anchor: el
1135
+ });
1136
+ }
1137
+ found.sort((a, b) => a.root === b.root ? 0 : precedes(a.root, b.root) ? -1 : 1);
1138
+ const sections = [];
1139
+ for (const [i, item] of found.slice(0, 20).entries()) {
1140
+ let parentId = null;
1141
+ for (const prev of sections) if (prev.root !== item.root && prev.root.contains(item.root)) parentId = prev.id;
1142
+ sections.push({
1143
+ ...item,
1144
+ id: `s${i + 1}`,
1145
+ parentId
1146
+ });
1147
+ }
1148
+ return {
1149
+ overview,
1150
+ sections
1151
+ };
1152
+ }
1153
+
1154
+ //#endregion
1155
+ //#region src/tour/tabs.ts
1156
+ /** Tab 组数量上限:随模块清单进入每一步的按页提示 */
1157
+ const TAB_GROUP_LIMIT = 10;
1158
+ /** 单组列出的 Tab 数量上限 */
1159
+ const TAB_PER_GROUP_LIMIT = 12;
1160
+ /** Tab 文本最大字符数(去掉角标等冗余后仍过长时截断) */
1161
+ const TAB_TEXT_MAX_CHARS = 20;
1162
+ /** Tab 结构适配表;新增组件库时在此追加 */
1163
+ const TAB_ADAPTERS = [{
1164
+ list: "[role=\"tablist\"]",
1165
+ tab: "[role=\"tab\"]",
1166
+ isActive: (tab) => tab.getAttribute("aria-selected") === "true",
1167
+ isDisabled: (tab) => tab.getAttribute("aria-disabled") === "true" || tab.hasAttribute("disabled")
1168
+ }, {
1169
+ list: ".n-tabs-nav",
1170
+ tab: ".n-tabs-tab",
1171
+ isActive: (tab) => tab.classList.contains("n-tabs-tab--active"),
1172
+ isDisabled: (tab) => tab.classList.contains("n-tabs-tab--disabled")
1173
+ }];
1174
+ function tabText(tab) {
1175
+ const text = normalizeText(tab.getAttribute("aria-label")) || normalizeText(tab.textContent);
1176
+ return text.length > TAB_TEXT_MAX_CHARS ? `${text.slice(0, TAB_TEXT_MAX_CHARS)}…` : text;
1177
+ }
1178
+ /** 包含元素的最内层模块:嵌套模块的根节点在文档顺序上位于外层之后,取最后一个命中项 */
1179
+ function findOwnerSection(el, sections) {
1180
+ let owner = null;
1181
+ for (const section of sections) if (section.root.contains(el)) owner = section.id;
1182
+ return owner;
1183
+ }
1184
+ function scanTabGroups(root, sections, isVisible = isElementVisible) {
1185
+ const seen = /* @__PURE__ */ new Set();
1186
+ const found = [];
1187
+ for (const adapter of TAB_ADAPTERS) for (const list of root.querySelectorAll(adapter.list)) {
1188
+ if (seen.has(list)) continue;
1189
+ seen.add(list);
1190
+ if (isAriaHidden(list) || !isVisible(list)) continue;
1191
+ found.push({
1192
+ list,
1193
+ adapter
1194
+ });
1195
+ }
1196
+ found.sort((a, b) => a.list.compareDocumentPosition(b.list) & Node.DOCUMENT_POSITION_FOLLOWING ? -1 : 1);
1197
+ const groups = [];
1198
+ for (const { list, adapter } of found) {
1199
+ const tabs = Array.from(list.querySelectorAll(adapter.tab)).filter((tab) => tab.closest(adapter.list) === list && isVisible(tab)).map((el) => ({
1200
+ el,
1201
+ text: tabText(el),
1202
+ active: adapter.isActive(el),
1203
+ disabled: adapter.isDisabled(el)
1204
+ })).filter((tab) => tab.text);
1205
+ if (tabs.length < 2) continue;
1206
+ const id = `g${groups.length + 1}`;
1207
+ groups.push({
1208
+ id,
1209
+ label: normalizeText(list.getAttribute("aria-label")),
1210
+ ownerSectionId: findOwnerSection(list, sections),
1211
+ list,
1212
+ tabs: tabs.slice(0, 12).map((tab, i) => ({
1213
+ ...tab,
1214
+ id: `${id}-${i + 1}`
1215
+ }))
1216
+ });
1217
+ if (groups.length >= 10) break;
1218
+ }
1219
+ return groups;
1220
+ }
1221
+
1222
+ //#endregion
1223
+ //#region src/tour/outline.ts
1224
+ /**
1225
+ * 页面大纲:模块 + Tab 组的一次扫描结果,以及提供给模型的精简文本。
1226
+ * 文本追加到每一步的按页提示,模型据此在一次 present_page 调用中给出全部讲解词。
1227
+ */
1228
+ const EMPTY_OUTLINE = {
1229
+ overview: null,
1230
+ sections: [],
1231
+ tabGroups: []
1232
+ };
1233
+ function scanPageOutline(root, isVisible = isElementVisible) {
1234
+ if (!root) return EMPTY_OUTLINE;
1235
+ const { overview, sections } = scanPageSections(root, isVisible);
1236
+ return {
1237
+ overview,
1238
+ sections,
1239
+ tabGroups: scanTabGroups(root, sections, isVisible)
1240
+ };
1241
+ }
1242
+ /** 没有可指向模块时给模型的说明:避免按屏幕内容自拟模块名导致全部跳过 */
1243
+ const EMPTY_SECTIONS_HINT = "(未识别到可指向的模块:介绍页面时 sections 传空数组,只用 intro 讲解页面概述,不要自拟模块名)";
1244
+ /** 存在嵌套模块时的说明:父模块已覆盖子模块内容,避免重复讲解 */
1245
+ const NESTED_HINT = "(缩进的是所属模块内的子模块:讲了所属模块后,子模块只在有额外内容时再单独讲,避免重复)";
1246
+ /**
1247
+ * 大纲文本;没有概述、模块与 Tab 组时返回空串(不追加到提示)。
1248
+ * 子模块按层级缩进;同名模块标注序号,便于模型区分(如多个「数据列表」)。
1249
+ */
1250
+ function formatOutline(outline) {
1251
+ const { overview, sections, tabGroups } = outline;
1252
+ if (!overview && !sections.length && !tabGroups.length) return "";
1253
+ const lines = [`## 页面模块(介绍页面时用 ${PRESENT_PAGE_TOOL_NAME} 按编号讲解)`];
1254
+ if (overview) lines.push(`页面概述:「${overview.name}」${overview.desc}`);
1255
+ if (sections.length) {
1256
+ const nameTotals = /* @__PURE__ */ new Map();
1257
+ for (const s of sections) nameTotals.set(s.name, (nameTotals.get(s.name) ?? 0) + 1);
1258
+ const nameSeen = /* @__PURE__ */ new Map();
1259
+ const depth = /* @__PURE__ */ new Map();
1260
+ for (const s of sections) {
1261
+ const level = s.parentId ? (depth.get(s.parentId) ?? 0) + 1 : 0;
1262
+ depth.set(s.id, level);
1263
+ const nth = (nameSeen.get(s.name) ?? 0) + 1;
1264
+ nameSeen.set(s.name, nth);
1265
+ const dup = (nameTotals.get(s.name) ?? 0) > 1 ? `(同名第 ${nth} 个)` : "";
1266
+ lines.push(`${" ".repeat(level)}- ${s.id} 「${s.name}」${dup}${s.desc ? ` ${s.desc}` : ""}`);
1267
+ }
1268
+ if (sections.some((s) => s.parentId)) lines.push(NESTED_HINT);
1269
+ } else lines.push(EMPTY_SECTIONS_HINT);
1270
+ if (tabGroups.length) {
1271
+ lines.push("## 页面 Tab(g 为 Tab 组编号,* 为当前 Tab)");
1272
+ for (const g of tabGroups) {
1273
+ const label = g.label ? `「${g.label}」` : "";
1274
+ const owner = g.ownerSectionId ? `(属于 ${g.ownerSectionId})` : "(页面级)";
1275
+ const tabs = g.tabs.map((t) => `${t.active ? "*" : ""}${t.id}「${t.text}」${t.disabled ? "(禁用)" : ""}`).join(" / ");
1276
+ lines.push(`- ${g.id}${label}${owner}: ${tabs}`);
1277
+ }
1278
+ }
1279
+ return lines.join("\n");
1280
+ }
1281
+
1282
+ //#endregion
1283
+ //#region src/tour/presentTool.ts
1284
+ /**
1285
+ * 页面讲解控制器:维护提供给模型的页面大纲,并提供 present_page 工具。
1286
+ *
1287
+ * 一次讲解可由多次工具调用组成:每轮讲当前可见的模块,带 next_tab 时讲完切换 Tab 并返回新大纲;
1288
+ * 不带 next_tab 的一轮为结束轮,切回讲解中切换过的 Tab。除切换已识别的 Tab 外不点击任何元素。
1289
+ * 光标、点击动画经 page-agent SimulatorMask 监听的窗口事件驱动;用户关闭蒙层时由工具临时创建蒙层。
1290
+ */
1291
+ /** 讲解词最大长度:培训风格每段需要二到四句,超出由模型精简 */
1292
+ const TOUR_SPEECH_MAX_CHARS = 300;
1293
+ /** 滚动后等待平滑滚动结束的时间(ms) */
1294
+ const SCROLL_SETTLE_MS = 400;
1295
+ /** 派发光标移动后等待光标动画到位的时间(ms),与 page-agent 点击前的等待一致 */
1296
+ const POINTER_SETTLE_MS = 300;
1297
+ /** 切换 Tab 后判定渲染稳定:连续该时长无 DOM 变化(ms) */
1298
+ const DOM_SETTLE_IDLE_MS = 300;
1299
+ /** 切换 Tab 后等待渲染稳定的上限(ms);超时仍继续扫描 */
1300
+ const DOM_SETTLE_MAX_MS = 3e3;
1301
+ /** page-agent SimulatorMask 监听的窗口事件 */
1302
+ const MOVE_POINTER_EVENT = "PageAgent::MovePointerTo";
1303
+ const CLICK_POINTER_EVENT = "PageAgent::ClickPointer";
1304
+ function abortError() {
1305
+ const error = /* @__PURE__ */ new Error("Task aborted");
1306
+ error.name = "AbortError";
1307
+ return error;
1308
+ }
1309
+ function throwIfAborted(signal) {
1310
+ if (signal?.aborted) throw abortError();
1311
+ }
1312
+ function sleep(ms, signal) {
1313
+ if (ms <= 0 || signal?.aborted) return Promise.resolve();
1314
+ return new Promise((resolve) => {
1315
+ const finish = () => {
1316
+ clearTimeout(timer);
1317
+ signal?.removeEventListener("abort", finish);
1318
+ resolve();
1319
+ };
1320
+ const timer = setTimeout(finish, ms);
1321
+ signal?.addEventListener("abort", finish, { once: true });
1322
+ });
1323
+ }
1324
+ /** 连续 DOM_SETTLE_IDLE_MS 无变化或达到 DOM_SETTLE_MAX_MS 时 resolve;中止时立即 resolve */
1325
+ function waitForDomSettled(root, signal) {
1326
+ if (!root || signal?.aborted) return Promise.resolve();
1327
+ return new Promise((resolve) => {
1328
+ let idleTimer;
1329
+ const finish = () => {
1330
+ clearTimeout(idleTimer);
1331
+ clearTimeout(maxTimer);
1332
+ observer.disconnect();
1333
+ signal?.removeEventListener("abort", finish);
1334
+ resolve();
1335
+ };
1336
+ const resetIdle = () => {
1337
+ clearTimeout(idleTimer);
1338
+ idleTimer = setTimeout(finish, 300);
1339
+ };
1340
+ const observer = new MutationObserver(resetIdle);
1341
+ observer.observe(root, {
1342
+ subtree: true,
1343
+ childList: true,
1344
+ attributes: true,
1345
+ characterData: true
1346
+ });
1347
+ const maxTimer = setTimeout(finish, DOM_SETTLE_MAX_MS);
1348
+ signal?.addEventListener("abort", finish, { once: true });
1349
+ resetIdle();
1350
+ });
1351
+ }
1352
+ function movePointerTo(el) {
1353
+ const rect = el.getBoundingClientRect();
1354
+ window.dispatchEvent(new CustomEvent(MOVE_POINTER_EVENT, { detail: {
1355
+ x: rect.left + rect.width / 2,
1356
+ y: rect.top + rect.height / 2
1357
+ } }));
1358
+ }
1359
+ /** 不在视口内时滚动到可见;返回是否发生滚动。高于视口的模块对齐顶部,否则居中 */
1360
+ function scrollIntoViewIfNeeded(el) {
1361
+ const rect = el.getBoundingClientRect();
1362
+ const viewport = window.innerHeight || document.documentElement.clientHeight;
1363
+ if (rect.top >= 0 && rect.bottom <= viewport) return false;
1364
+ el.scrollIntoView?.({
1365
+ block: rect.height > viewport ? "start" : "center",
1366
+ behavior: "smooth"
1367
+ });
1368
+ return true;
1369
+ }
1370
+ function describeTab(tab) {
1371
+ return `「${tab.text}」`;
1372
+ }
1373
+ function buildDescription(maxTabSwitches) {
1374
+ return [
1375
+ "页面讲解:按编号依次把光标移到页面模块并语音讲解,只移动光标,不点击模块内容。",
1376
+ "- 调用条件:用户明确要求介绍、讲解、演示页面或页面功能时调用。",
1377
+ "- 以下情况不调用:打开或进入页面、查找、查询、填写、筛选等操作类指令;询问具体数据或某个字段含义时,读取页面后在 done 中以文字回答;无法确定用户是否要求讲解时,按原方式以文字回答。",
1378
+ "- 指令同时包含导航与讲解(如「打开某页面并介绍一下」)时,先导航到目标页面并确认到达,再调用。",
1379
+ "- sections:按讲解顺序列出按页提示「页面模块」中的编号,并为每个模块写一两句面向演示观众的讲解词,说明模块用途和能看到的内容;不编造具体数值,不包含编号。",
1380
+ "- intro:本轮开场,可省略。首轮可概述页面,后续轮次可写切换 Tab 后的过渡语。",
1381
+ "- 页面有 Tab 时逐轮调用:每轮只讲当前 Tab 下可见的模块,用 next_tab 指定下一个要切换的 Tab 编号。按深度优先顺序:先讲完当前 Tab 内的子 Tab,再切换同级的下一个 Tab。工具会返回切换后的模块清单,据此继续下一轮。",
1382
+ `- 单次讲解最多切换 ${maxTabSwitches} 次 Tab,其余 Tab 在讲解词中概括说明。`,
1383
+ "- 最后一轮不传 next_tab,工具会恢复讲解开始时的 Tab;之后调用 done 简短收尾,不重复讲解内容。",
1384
+ "- 称呼保持通用(如「大家好」),不要假定听众身份(如「各位学员」「同学们」)。",
1385
+ "- 按页提示中的「讲解风格」决定讲解的受众、侧重点和详略(用户未选择风格时该处为默认风格);用户指令中另有讲解要求(如面向新员工、侧重操作步骤)时,以用户指令为准。",
1386
+ "- 页面没有可指向的模块时,sections 传空数组,只用 intro 讲解页面概述,不要自拟模块名。"
1387
+ ].join("\n");
1388
+ }
1389
+ function createPageTour(deps) {
1390
+ const scan = deps.scan ?? scanPageOutline;
1391
+ const narrateText = deps.narrate ?? narrate;
1392
+ const click = deps.click ?? clickElement;
1393
+ const wait = deps.sleep ?? sleep;
1394
+ const settle = deps.waitForDomSettled ?? waitForDomSettled;
1395
+ let outline = EMPTY_OUTLINE;
1396
+ let switchCount = 0;
1397
+ let initialTabs = [];
1398
+ let maskForced = false;
1399
+ /** 任务级中止信号(page-agent 每个任务一个,传给该任务内的所有工具调用) */
1400
+ let taskSignal;
1401
+ const rescan = () => scan(deps.getContentRoot());
1402
+ function styleText() {
1403
+ const style = deps.getStyle?.();
1404
+ if (!style) return "";
1405
+ return `## 讲解风格:${style.label}(介绍页面时使用)\n${style.prompt}`;
1406
+ }
1407
+ function outlineText() {
1408
+ try {
1409
+ outline = rescan();
1410
+ } catch (err) {
1411
+ console.warn("[PageUse] 页面模块识别异常", err);
1412
+ outline = EMPTY_OUTLINE;
1413
+ }
1414
+ const text = formatOutline(outline);
1415
+ const style = text ? styleText() : "";
1416
+ return style ? `${text}\n${style}` : text;
1417
+ }
1418
+ /**
1419
+ * 解析模块:按编号,模型误传模块名时按名称(同名取第一个)。
1420
+ * 元素已被替换时重新扫描,按「名称 + 同名序号」匹配,避免多个同名模块(如多个「数据列表」)错位。
1421
+ */
1422
+ function resolveSection(key) {
1423
+ const list = outline.sections;
1424
+ const known = list.find((s) => s.id === key) ?? list.find((s) => s.name === key);
1425
+ if (!known) return null;
1426
+ if (known.root.isConnected && known.anchor.isConnected) return {
1427
+ id: known.id,
1428
+ section: known
1429
+ };
1430
+ const nth = list.filter((s) => s.name === known.name).indexOf(known);
1431
+ const fresh = rescan().sections.filter((s) => s.name === known.name)[nth];
1432
+ return fresh ? {
1433
+ id: known.id,
1434
+ section: fresh
1435
+ } : null;
1436
+ }
1437
+ /** 按编号解析 Tab;元素已被替换时按「组 label + Tab 文本」匹配 */
1438
+ function resolveTab(id) {
1439
+ for (const group of outline.tabGroups) {
1440
+ const tab = group.tabs.find((t) => t.id === id);
1441
+ if (!tab) continue;
1442
+ if (tab.el.isConnected) return {
1443
+ group,
1444
+ tab
1445
+ };
1446
+ for (const freshGroup of rescan().tabGroups) {
1447
+ if (freshGroup.label !== group.label) continue;
1448
+ const freshTab = freshGroup.tabs.find((t) => t.text === tab.text);
1449
+ if (freshTab) return {
1450
+ group: freshGroup,
1451
+ tab: freshTab
1452
+ };
1453
+ }
1454
+ return null;
1455
+ }
1456
+ return null;
1457
+ }
1458
+ async function ensureMask(agent) {
1459
+ if (deps.maskEnabled || maskForced) return;
1460
+ maskForced = true;
1461
+ agent.pageController.initMask();
1462
+ await agent.pageController.showMask();
1463
+ deps.onMaskForced?.();
1464
+ }
1465
+ /** 光标移到 Tab、播放点击动画并点击,等待渲染稳定 */
1466
+ async function clickTab(el, signal) {
1467
+ if (scrollIntoViewIfNeeded(el)) await wait(400, signal);
1468
+ throwIfAborted(signal);
1469
+ movePointerTo(el);
1470
+ await wait(300, signal);
1471
+ throwIfAborted(signal);
1472
+ window.dispatchEvent(new CustomEvent(CLICK_POINTER_EVENT));
1473
+ await click(el);
1474
+ await settle(deps.getContentRoot(), signal);
1475
+ }
1476
+ /** 按记录的逆序切回:先恢复后切换的组(外层 Tab 或当前 Tab 内的子 Tab),已不存在的组跳过 */
1477
+ async function restoreTabs(signal) {
1478
+ const restored = [];
1479
+ const pending = initialTabs;
1480
+ initialTabs = [];
1481
+ for (const initial of [...pending].reverse()) {
1482
+ throwIfAborted(signal);
1483
+ const groups = rescan().tabGroups;
1484
+ const tab = (groups.find((g) => g.list === initial.list) ?? (initial.label ? groups.find((g) => g.label === initial.label) : void 0))?.tabs.find((t) => t.text === initial.text);
1485
+ if (!tab || tab.active) continue;
1486
+ await clickTab(tab.el, signal);
1487
+ restored.push(describeTab(tab));
1488
+ }
1489
+ return restored;
1490
+ }
1491
+ async function presentSections(items, signal) {
1492
+ const done = [];
1493
+ const skipped = [];
1494
+ for (const item of items) {
1495
+ throwIfAborted(signal);
1496
+ const resolved = resolveSection(item.id);
1497
+ if (!resolved) {
1498
+ skipped.push(item.id);
1499
+ continue;
1500
+ }
1501
+ const { section } = resolved;
1502
+ if (scrollIntoViewIfNeeded(section.root)) await wait(400, signal);
1503
+ throwIfAborted(signal);
1504
+ movePointerTo(section.anchor);
1505
+ await wait(300, signal);
1506
+ throwIfAborted(signal);
1507
+ showTourHighlight(section.root);
1508
+ await narrateText(item.speech, signal);
1509
+ hideTourHighlight();
1510
+ throwIfAborted(signal);
1511
+ done.push(`${resolved.id}「${section.name}」`);
1512
+ }
1513
+ return {
1514
+ done,
1515
+ skipped
1516
+ };
1517
+ }
1518
+ /** 校验并切换 Tab;返回结果说明(含新大纲),不满足条件时返回原因且不点击 */
1519
+ async function switchTab(id, signal) {
1520
+ const target = resolveTab(id);
1521
+ if (!target) return `未切换 Tab:${id} 不在当前 Tab 清单中。`;
1522
+ const { group, tab } = target;
1523
+ if (tab.disabled) return `未切换 Tab:${describeTab(tab)}已禁用。`;
1524
+ if (tab.active) return `未切换 Tab:${describeTab(tab)}已是当前 Tab。`;
1525
+ if (switchCount >= deps.maxTabSwitches) return `未切换 Tab:已达到单次讲解的切换上限(${deps.maxTabSwitches} 次),请在讲解词中概括其余 Tab,并以不带 next_tab 的调用结束讲解。`;
1526
+ const active = group.tabs.find((t) => t.active);
1527
+ if (active && !initialTabs.some((t) => t.list === group.list)) initialTabs.push({
1528
+ list: group.list,
1529
+ label: group.label,
1530
+ text: active.text
1531
+ });
1532
+ await clickTab(tab.el, signal);
1533
+ switchCount += 1;
1534
+ outlineText();
1535
+ const text = formatOutline(outline) || "(切换后未识别到模块)";
1536
+ return `已切换到 Tab ${describeTab(tab)}。以下为切换后的页面模块,请继续讲解:\n${text}`;
1537
+ }
1538
+ return {
1539
+ outlineText,
1540
+ tool: tool({
1541
+ description: buildDescription(deps.maxTabSwitches),
1542
+ inputSchema: z.object({
1543
+ intro: z.string().max(300).optional().describe("本轮开场讲解词,可省略"),
1544
+ sections: z.array(z.object({
1545
+ id: z.string().describe("模块编号,如 s1"),
1546
+ speech: z.string().min(1).max(300).describe("该模块的讲解词")
1547
+ })).max(20).describe("按讲解顺序排列的模块"),
1548
+ next_tab: z.string().optional().describe("讲完本轮后切换到的 Tab 编号,如 g1-2;最后一轮不传")
1549
+ }),
1550
+ async execute(input, ctx) {
1551
+ const { signal } = ctx;
1552
+ taskSignal = signal;
1553
+ try {
1554
+ await ensureMask(this);
1555
+ throwIfAborted(signal);
1556
+ if (input.intro?.trim()) {
1557
+ const overview = outline.overview?.anchor;
1558
+ if (overview?.isConnected) {
1559
+ movePointerTo(overview);
1560
+ await wait(300, signal);
1561
+ throwIfAborted(signal);
1562
+ }
1563
+ await narrateText(input.intro, signal);
1564
+ }
1565
+ const { done, skipped } = await presentSections(input.sections, signal);
1566
+ const lines = [done.length ? `已讲解:${done.join("、")}。` : "本轮未讲解模块。"];
1567
+ if (skipped.length) lines.push(outline.sections.length ? `跳过:${skipped.join("、")}(不在当前模块清单中,请只使用清单中的编号,如 s1)。` : `跳过:${skipped.join("、")}。本页没有可指向的模块,不要自拟模块名;如需讲解,只用 intro 讲解页面概述。`);
1568
+ if (input.next_tab) lines.push(await switchTab(input.next_tab, signal));
1569
+ else {
1570
+ const restored = await restoreTabs(signal);
1571
+ lines.push(restored.length ? `讲解结束,已恢复初始 Tab ${restored.join("、")}。` : "讲解结束。", "请调用 done 简短收尾,不要重复讲解内容。");
1572
+ }
1573
+ throwIfAborted(signal);
1574
+ return lines.join("\n");
1575
+ } finally {
1576
+ hideTourHighlight();
1577
+ }
1578
+ }
1579
+ }),
1580
+ onBeforeTask() {
1581
+ switchCount = 0;
1582
+ initialTabs = [];
1583
+ taskSignal = void 0;
1584
+ },
1585
+ async onAfterTask() {
1586
+ if (!initialTabs.length || taskSignal?.aborted) {
1587
+ initialTabs = [];
1588
+ return;
1589
+ }
1590
+ try {
1591
+ await restoreTabs();
1592
+ } catch (err) {
1593
+ console.warn("[PageUse] 讲解结束后恢复 Tab 失败", err);
1594
+ }
1595
+ }
1596
+ };
1597
+ }
800
1598
 
801
1599
  //#endregion
802
1600
  //#region src/core/siteMap.ts
@@ -888,9 +1686,10 @@ function buildSystemInstruction(options, flags = {}) {
888
1686
  }
889
1687
  /**
890
1688
  * 按页提示:page-agent 每步调用一次。
891
- * 路由名命中 routeHints 优先,其次按路径命中;返回 null 表示不追加。
1689
+ * 路由名命中 routeHints 优先,其次按路径命中;outlineText(页面讲解的模块清单)追加在末尾。
1690
+ * 返回 null 表示不追加。
892
1691
  */
893
- function buildPageInstruction(options) {
1692
+ function buildPageInstruction(options, outlineText = "") {
894
1693
  const path = options.getCurrentPath();
895
1694
  const parts = [];
896
1695
  const menu = findMenuByPath(path, options.getMenus());
@@ -903,6 +1702,7 @@ function buildPageInstruction(options) {
903
1702
  }
904
1703
  const hint = routeName && options.routeHints[routeName] || options.routeHints[normalizePath(path)];
905
1704
  if (hint) parts.push(hint);
1705
+ if (outlineText) parts.push(outlineText);
906
1706
  return parts.length ? parts.join("\n") : null;
907
1707
  }
908
1708
 
@@ -913,6 +1713,7 @@ function buildPageInstruction(options) {
913
1713
  * - LLM:浏览器请求 OpenAI 兼容端点,参数来自宿主选项
914
1714
  * - 站点知识:菜单树写入 system 提示,按页提示按当前路径动态注入
915
1715
  * - 任务开始前按设置执行宿主的 beforeTask(如展开折叠的侧栏)
1716
+ * - 页面讲解:按页提示附带页面模块清单,并注册 present_page 工具(pageTour 关闭时均不启用)
916
1717
  */
917
1718
  /** DOM 文本超过该长度时截断,避免大表格页把上下文撑爆 */
918
1719
  const PAGE_CONTENT_MAX_CHARS = 6e4;
@@ -922,7 +1723,7 @@ const STEP_DELAY_SECONDS = .6;
922
1723
  function newSessionId() {
923
1724
  return typeof crypto !== "undefined" && crypto.randomUUID?.() || `page-use-${Date.now()}`;
924
1725
  }
925
- function createPageUseAgent({ options, enableMask, narration = false, shouldRunBeforeTask, onTaskFinished }) {
1726
+ function createPageUseAgent({ options, enableMask, narration = false, shouldRunBeforeTask, onTaskFinished, onMaskForced, getTourStyle }) {
926
1727
  const { llm } = options;
927
1728
  if (!llm.baseURL) throw new Error("[PageUse] 未配置 llm.baseURL");
928
1729
  let sessionId = newSessionId();
@@ -940,6 +1741,14 @@ function createPageUseAgent({ options, enableMask, narration = false, shouldRunB
940
1741
  } : void 0;
941
1742
  const extraBody = llm.extraBody;
942
1743
  const hostBeforeTask = options.beforeTask;
1744
+ const hostPageAgent = options.pageAgentOptions;
1745
+ const tour = options.pageTour.enabled ? createPageTour({
1746
+ getContentRoot: options.getContentRoot,
1747
+ maxTabSwitches: options.pageTour.maxTabSwitches,
1748
+ maskEnabled: enableMask,
1749
+ onMaskForced,
1750
+ getStyle: getTourStyle
1751
+ }) : null;
943
1752
  return new PageAgent({
944
1753
  baseURL: llm.baseURL,
945
1754
  apiKey: llm.apiKey || void 0,
@@ -958,25 +1767,31 @@ function createPageUseAgent({ options, enableMask, narration = false, shouldRunB
958
1767
  experimentalScriptExecutionTool: false,
959
1768
  instructions: {
960
1769
  system: buildSystemInstruction(options, { narration }),
961
- getPageInstructions: () => buildPageInstruction(options)
1770
+ getPageInstructions: () => buildPageInstruction(options, tour?.outlineText() ?? "")
962
1771
  },
963
1772
  transformPageContent: (content) => {
964
1773
  if (content.length <= PAGE_CONTENT_MAX_CHARS) return content;
965
1774
  return `${content.slice(0, PAGE_CONTENT_MAX_CHARS)}\n...(页面内容过长已截断,可滚动后再观察)`;
966
1775
  },
967
- ...options.pageAgentOptions,
1776
+ ...hostPageAgent,
1777
+ customTools: {
1778
+ ...tour ? { [PRESENT_PAGE_TOOL_NAME]: tour.tool } : {},
1779
+ ...hostPageAgent.customTools
1780
+ },
968
1781
  onBeforeTask: async (...args) => {
969
1782
  sessionId = newSessionId();
1783
+ tour?.onBeforeTask();
970
1784
  if (hostBeforeTask && shouldRunBeforeTask()) try {
971
1785
  await hostBeforeTask();
972
1786
  } catch (err) {
973
1787
  console.warn("[PageUse] beforeTask 执行异常", err);
974
1788
  }
975
- await options.pageAgentOptions.onBeforeTask?.(...args);
1789
+ await hostPageAgent.onBeforeTask?.(...args);
976
1790
  },
977
1791
  onAfterTask: async (...args) => {
1792
+ await tour?.onAfterTask(args[1]);
978
1793
  onTaskFinished?.(args[1]);
979
- await options.pageAgentOptions.onAfterTask?.(...args);
1794
+ await hostPageAgent.onAfterTask?.(...args);
980
1795
  }
981
1796
  });
982
1797
  }
@@ -1060,19 +1875,37 @@ function storageKey(prefix) {
1060
1875
  function createId() {
1061
1876
  return `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
1062
1877
  }
1063
- /** 结构校验:丢弃缺少字符串 label / task 的项,缺 id 时补生成;hidden 只保留字符串并去重 */
1878
+ /** 讲解风格 key 去除首尾空白;非字符串或空白视为未指定(跟随面板设置),返回 undefined */
1879
+ function normalizeTourStyle(value) {
1880
+ return typeof value === "string" && value.trim() ? value.trim() : void 0;
1881
+ }
1882
+ /** 保存用的规范化结果:label / task 去除首尾空白;未指定讲解风格时不出现 tourStyle 键 */
1883
+ function normalize(preset) {
1884
+ const tourStyle = normalizeTourStyle(preset.tourStyle);
1885
+ return {
1886
+ label: preset.label.trim(),
1887
+ task: preset.task.trim(),
1888
+ ...tourStyle ? { tourStyle } : {}
1889
+ };
1890
+ }
1891
+ /**
1892
+ * 结构校验:丢弃缺少字符串 label / task 的项,缺 id 时补生成;
1893
+ * tourStyle 非字符串或空白时只丢弃该字段,指令本身保留;hidden 只保留字符串并去重
1894
+ */
1064
1895
  function sanitize(raw) {
1065
1896
  if (!raw || typeof raw !== "object") return { ...EMPTY_STATE };
1066
1897
  const { custom, hidden } = raw;
1067
1898
  return {
1068
1899
  custom: (Array.isArray(custom) ? custom : []).flatMap((item) => {
1069
1900
  if (!item || typeof item !== "object") return [];
1070
- const { id, label, task } = item;
1901
+ const { id, label, task, tourStyle } = item;
1071
1902
  if (typeof label !== "string" || typeof task !== "string") return [];
1903
+ const style = normalizeTourStyle(tourStyle);
1072
1904
  return [{
1073
1905
  id: typeof id === "string" && id ? id : createId(),
1074
1906
  label,
1075
- task
1907
+ task,
1908
+ ...style ? { tourStyle: style } : {}
1076
1909
  }];
1077
1910
  }),
1078
1911
  hidden: Array.isArray(hidden) ? [...new Set(hidden.filter((item) => typeof item === "string"))] : []
@@ -1099,7 +1932,10 @@ function persist(patch) {
1099
1932
  window.localStorage.setItem(storageKey(prefix), JSON.stringify(current.get()));
1100
1933
  } catch {}
1101
1934
  }
1102
- /** 必填与重复校验;excludeId 为编辑中的自身,与自身相同不算重复 */
1935
+ /**
1936
+ * 必填与重复校验;excludeId 为编辑中的自身,与自身相同不算重复。
1937
+ * 重复按 task + 讲解风格组合判断:同一指令可按不同风格各存一条,未指定风格以空串参与比较
1938
+ */
1103
1939
  function validate(preset, excludeId) {
1104
1940
  if (!preset.label.trim()) return {
1105
1941
  ok: false,
@@ -1110,7 +1946,8 @@ function validate(preset, excludeId) {
1110
1946
  ok: false,
1111
1947
  reason: "task-empty"
1112
1948
  };
1113
- return ensureStore().get().custom.some((item) => item.id !== excludeId && item.task.trim() === task) ? {
1949
+ const tourStyle = normalizeTourStyle(preset.tourStyle) ?? "";
1950
+ return ensureStore().get().custom.some((item) => item.id !== excludeId && item.task.trim() === task && (item.tourStyle ?? "") === tourStyle) ? {
1114
1951
  ok: false,
1115
1952
  reason: "duplicate"
1116
1953
  } : { ok: true };
@@ -1129,18 +1966,17 @@ const pageUsePresets = {
1129
1966
  get() {
1130
1967
  return ensureStore().get();
1131
1968
  },
1132
- /** 新增自定义指令,排在最前;label / task 去除首尾空白后保存 */
1969
+ /** 新增自定义指令,排在最前;label / task / tourStyle 去除首尾空白后保存 */
1133
1970
  add(preset) {
1134
1971
  const result = validate(preset);
1135
1972
  if (!result.ok) return result;
1136
1973
  persist({ custom: [{
1137
1974
  id: createId(),
1138
- label: preset.label.trim(),
1139
- task: preset.task.trim()
1975
+ ...normalize(preset)
1140
1976
  }, ...ensureStore().get().custom] });
1141
1977
  return result;
1142
1978
  },
1143
- /** 编辑自定义指令,位置不变 */
1979
+ /** 编辑自定义指令,位置不变;整条替换,未传 tourStyle 即清除原风格 */
1144
1980
  update(id, preset) {
1145
1981
  const { custom } = ensureStore().get();
1146
1982
  if (!custom.some((item) => item.id === id)) return {
@@ -1151,8 +1987,7 @@ const pageUsePresets = {
1151
1987
  if (!result.ok) return result;
1152
1988
  persist({ custom: custom.map((item) => item.id === id ? {
1153
1989
  id,
1154
- label: preset.label.trim(),
1155
- task: preset.task.trim()
1990
+ ...normalize(preset)
1156
1991
  } : item) });
1157
1992
  return result;
1158
1993
  },
@@ -1184,6 +2019,11 @@ function summarizeActionInput(name, input) {
1184
2019
  case "wait": return `等待 ${record.seconds ?? 1} 秒`;
1185
2020
  case "done": return record.success === false ? "结束任务(未达成)" : "结束任务";
1186
2021
  case "ask_user": return "向用户提问";
2022
+ case PRESENT_PAGE_TOOL_NAME: {
2023
+ const count = Array.isArray(record.sections) ? record.sections.length : 0;
2024
+ const base = count ? `讲解页面(${count} 个模块)` : "讲解页面";
2025
+ return record.next_tab ? `${base},随后切换 Tab` : base;
2026
+ }
1187
2027
  default: return name;
1188
2028
  }
1189
2029
  }
@@ -1379,7 +2219,13 @@ function ensureAgent(options) {
1379
2219
  options,
1380
2220
  enableMask: settings.enableMask,
1381
2221
  narration: settings.voiceEnabled,
1382
- shouldRunBeforeTask: () => pageUseSettings.get().enableBeforeTask
2222
+ shouldRunBeforeTask: () => pageUseSettings.get().enableBeforeTask,
2223
+ onMaskForced: () => {
2224
+ if (agent !== created) return;
2225
+ agentMaskFlag = null;
2226
+ store.set({ maskEnabled: true });
2227
+ },
2228
+ getTourStyle: () => resolveTourStyle(options.pageTour.styles, activeOptions?.tourStyle, pageUseSettings.get().tourStyle, options.pageTour.defaultStyle)
1383
2229
  });
1384
2230
  bindAgentEvents(created);
1385
2231
  created.onAskUser = (question, askOptions) => handleAskUser(question, askOptions?.signal);
@@ -2058,7 +2904,7 @@ var PageUseGlowElement = class extends LitElement {
2058
2904
 
2059
2905
  //#endregion
2060
2906
  //#region package.json
2061
- var version = "0.0.6";
2907
+ var version = "0.0.7";
2062
2908
 
2063
2909
  //#endregion
2064
2910
  //#region src/ui/drag.ts
@@ -2215,21 +3061,10 @@ const robotIcon = html`${unsafeHTML(PAGE_USE_ROBOT_ICON_SVG)}`;
2215
3061
 
2216
3062
  //#endregion
2217
3063
  //#region src/ui/presets.ts
2218
- /** 通用示例:不依赖具体站点,覆盖详情、标签、返回等典型操作 */
2219
- const PAGE_USE_STATIC_PRESETS = [
2220
- {
2221
- label: "看第一条详情",
2222
- task: "在当前列表页打开第一条记录的详情"
2223
- },
2224
- {
2225
- label: "切到下一个标签",
2226
- task: "切换到当前页面的下一个标签页"
2227
- },
2228
- {
2229
- label: "总结当前页",
2230
- task: "概括当前页面展示的主要内容与可执行的操作"
2231
- }
2232
- ];
3064
+ /**
3065
+ * 通用示例:预留。
3066
+ */
3067
+ const PAGE_USE_STATIC_PRESETS = [];
2233
3068
  /**
2234
3069
  * 取前 N 个可见叶子菜单,生成「打开 XX」快捷指令。
2235
3070
  * exclude 中的 task 被跳过且不占名额,由后续菜单补位(用户隐藏菜单指令时使用)。
@@ -2260,12 +3095,13 @@ function resolvePresetGroups(input) {
2260
3095
  const visible = (list) => (list || []).filter((preset) => !hidden.has(preset.task));
2261
3096
  const limit = input.menuPresetLimit;
2262
3097
  const menu = limit > 0 ? buildMenuPresets(input.menus, limit, hidden) : [];
2263
- const builtin = visible(PAGE_USE_STATIC_PRESETS);
3098
+ const builtinSource = input.builtinPresets === false ? [] : PAGE_USE_STATIC_PRESETS;
3099
+ const builtin = visible(builtinSource);
2264
3100
  const host = visible(input.presets);
2265
3101
  const scenarios = visible(input.scenarios);
2266
3102
  const allSourceTasks = new Set([
2267
3103
  ...limit > 0 ? buildMenuPresets(input.menus, Number.POSITIVE_INFINITY) : [],
2268
- ...PAGE_USE_STATIC_PRESETS,
3104
+ ...builtinSource,
2269
3105
  ...input.presets || [],
2270
3106
  ...input.scenarios || []
2271
3107
  ].map((preset) => preset.task));
@@ -2922,13 +3758,6 @@ const launcherStyles = css`
2922
3758
  border-radius: 0 0 10px 10px;
2923
3759
  background: rgba(250, 251, 252, 0.5);
2924
3760
  }
2925
- /* 悬浮在输入区上沿(以 .composer 为定位参照) */
2926
- .jump-latest {
2927
- position: absolute;
2928
- right: 16px;
2929
- bottom: calc(100% + 8px);
2930
- box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
2931
- }
2932
3761
  .input,
2933
3762
  .textarea {
2934
3763
  width: 100%;
@@ -3070,6 +3899,15 @@ const launcherStyles = css`
3070
3899
  color: var(--text-3);
3071
3900
  font-size: 12px;
3072
3901
  }
3902
+ .field-hint {
3903
+ color: var(--text-3);
3904
+ font-size: 11px;
3905
+ line-height: 1.5;
3906
+ }
3907
+ /* 讲解风格:与复选框文字、播报选项输入框左对齐(同 21px 缩进) */
3908
+ .tour-style-field {
3909
+ margin-left: 21px;
3910
+ }
3073
3911
  .range {
3074
3912
  width: 100%;
3075
3913
  margin: 0;
@@ -3220,7 +4058,7 @@ const PARENT_VIEW = {
3220
4058
  const PRESET_SAVE_ERRORS = {
3221
4059
  "label-empty": "请填写名称",
3222
4060
  "task-empty": "请填写指令内容",
3223
- duplicate: "已存在指令内容相同的自定义指令",
4061
+ duplicate: "已存在相同的自定义指令",
3224
4062
  "not-found": "该指令已被删除,请重新新增"
3225
4063
  };
3226
4064
  /** 播报语速倍率的调节范围与步长(1 为正常语速,过快或过慢时中文语音难以听清;注入引擎自行换算为服务参数) */
@@ -3273,12 +4111,12 @@ var PageUseLauncherElement = class extends LitElement {
3273
4111
  toastTimer = null;
3274
4112
  /**
3275
4113
  * 日志区跟随滚动:默认贴底跟随新内容;用户上翻后停止跟随,
3276
- * 回到底部附近(阈值内)或点击「最新」后恢复。
4114
+ * 回到底部附近(阈值内)后恢复。
3277
4115
  */
3278
4116
  stickToBottom = true;
3279
4117
  /** 上次渲染时日志内容的摘要;仅内容变化时才贴底,避免每秒的耗时刷新把用户拉回底部 */
3280
4118
  logSignature = "";
3281
- /** 下次渲染强制滚到底部(点击「最新」、任务开始) */
4119
+ /** 下次渲染强制滚到底部(任务开始) */
3282
4120
  forceScroll = false;
3283
4121
  /**
3284
4122
  * 入口尺寸缓存:入口在面板展开时被隐藏,offsetWidth 为 0,
@@ -3598,16 +4436,20 @@ var PageUseLauncherElement = class extends LitElement {
3598
4436
  this.measureFab();
3599
4437
  this.fabDrag.reclamp();
3600
4438
  }
3601
- async submit() {
4439
+ /** tourStyle:快捷指令携带的讲解风格;输入框提交时不传,按面板设置与宿主默认 */
4440
+ async submit(tourStyle) {
3602
4441
  const text = this.task.trim();
3603
4442
  if (isRunning() || !text) return;
3604
4443
  this.task = "";
3605
4444
  this.requestUpdate();
3606
- await runTask(text, { source: LAUNCHER_SOURCE });
4445
+ await runTask(text, {
4446
+ source: LAUNCHER_SOURCE,
4447
+ ...tourStyle ? { tourStyle } : {}
4448
+ });
3607
4449
  }
3608
- applyPreset(text) {
4450
+ applyPreset(text, tourStyle) {
3609
4451
  this.task = text;
3610
- this.submit();
4452
+ this.submit(tourStyle);
3611
4453
  }
3612
4454
  lastTaskText(state) {
3613
4455
  return state.currentTask || pageUseHistory.entries()[0]?.task || "";
@@ -3644,23 +4486,18 @@ var PageUseLauncherElement = class extends LitElement {
3644
4486
  onLogScroll(event) {
3645
4487
  const el = event.target;
3646
4488
  const stick = el.scrollHeight - el.scrollTop - el.clientHeight < STICK_THRESHOLD;
3647
- if (stick === this.stickToBottom) return;
3648
4489
  this.stickToBottom = stick;
3649
- this.requestUpdate();
3650
4490
  }
3651
4491
  /**
3652
4492
  * 滚轮上翻立即停止跟随:平滑滚动的首个 scroll 事件位移可能小于阈值,
3653
4493
  * 若此时仍判定为贴底,内容刷新会把视图拉回底部,表现为上翻时抖动。
4494
+ * 内容不足一屏(或已在顶部)时无法上翻,也不会触发 scroll 事件来恢复跟随,
4495
+ * 因此 scrollTop 为 0 时忽略,否则后续内容增长时不再贴底。
3654
4496
  */
3655
4497
  onLogWheel(event) {
3656
4498
  if (event.deltaY >= 0 || !this.stickToBottom) return;
4499
+ if (event.currentTarget.scrollTop <= 0) return;
3657
4500
  this.stickToBottom = false;
3658
- this.requestUpdate();
3659
- }
3660
- scrollLogToBottom() {
3661
- this.stickToBottom = true;
3662
- this.forceScroll = true;
3663
- this.requestUpdate();
3664
4501
  }
3665
4502
  toggleTray(kind) {
3666
4503
  this.tray = this.tray === kind ? null : kind;
@@ -3685,6 +4522,7 @@ var PageUseLauncherElement = class extends LitElement {
3685
4522
  return resolvePresetGroups({
3686
4523
  menus: getPageUseConfig()?.getMenus(),
3687
4524
  menuPresetLimit: this._config.menuPresetLimit ?? 6,
4525
+ builtinPresets: this._config.builtinPresets,
3688
4526
  presets: this._config.presets,
3689
4527
  scenarios: this._config.scenarios,
3690
4528
  state: pageUsePresets.get()
@@ -3695,7 +4533,7 @@ var PageUseLauncherElement = class extends LitElement {
3695
4533
  type="button"
3696
4534
  class="btn tiny round secondary ${accent ? "accent" : ""}"
3697
4535
  title=${preset.task}
3698
- @click=${() => this.applyPreset(preset.task)}
4536
+ @click=${() => this.applyPreset(preset.task, preset.tourStyle)}
3699
4537
  >
3700
4538
  ${preset.label}
3701
4539
  </button>`);
@@ -3750,6 +4588,7 @@ var PageUseLauncherElement = class extends LitElement {
3750
4588
  ${config?.beforeTask ? this.renderCheckbox("enableBeforeTask", config.beforeTaskLabel) : nothing}
3751
4589
  ${this.renderCheckbox("showStepDetails", "步骤显示模型思考与返回")}
3752
4590
  ${this.renderVoiceSettings(settings)}
4591
+ ${config?.pageTour.enabled ? this.renderTourStyleSetting(settings, config) : nothing}
3753
4592
  <div class="row settings-actions">
3754
4593
  <button type="button" class="btn" @click=${() => pageUseSettings.resetPositions()}>
3755
4594
  复位窗口
@@ -3771,6 +4610,7 @@ var PageUseLauncherElement = class extends LitElement {
3771
4610
  id,
3772
4611
  label: item?.label ?? "",
3773
4612
  task: item?.task ?? "",
4613
+ tourStyle: item?.tourStyle ?? "",
3774
4614
  error: ""
3775
4615
  };
3776
4616
  this.requestUpdate();
@@ -3780,7 +4620,8 @@ var PageUseLauncherElement = class extends LitElement {
3780
4620
  if (!draft) return;
3781
4621
  const preset = {
3782
4622
  label: draft.label,
3783
- task: draft.task
4623
+ task: draft.task,
4624
+ tourStyle: draft.tourStyle
3784
4625
  };
3785
4626
  const result = draft.id === null ? pageUsePresets.add(preset) : pageUsePresets.update(draft.id, preset);
3786
4627
  this.presetDraft = result.ok ? null : {
@@ -3808,6 +4649,7 @@ var PageUseLauncherElement = class extends LitElement {
3808
4649
  draft.task = e.target.value;
3809
4650
  }}
3810
4651
  ></textarea>
4652
+ ${this.renderPresetTourStyle(draft)}
3811
4653
  ${draft.error ? html`<div class="alert error preset-error">${draft.error}</div>` : nothing}
3812
4654
  <div class="row preset-form-actions">
3813
4655
  <button
@@ -3825,6 +4667,32 @@ var PageUseLauncherElement = class extends LitElement {
3825
4667
  </button>
3826
4668
  </div>
3827
4669
  </div>`;
4670
+ }
4671
+ /**
4672
+ * 表单中的讲解风格(可选):仅页面讲解启用时显示,选项与设置页一致。
4673
+ * 已保存的 key 不在当前风格列表中(宿主移除了该风格)时追加「已失效」项并选中,
4674
+ * 不改选直接保存会保留原值;执行时由 resolveTourStyle 回退到面板设置与宿主默认
4675
+ */
4676
+ renderPresetTourStyle(draft) {
4677
+ const config = getPageUseConfig();
4678
+ if (!config?.pageTour.enabled) return nothing;
4679
+ const { styles } = config.pageTour;
4680
+ const stale = draft.tourStyle && !styles.some((style) => style.key === draft.tourStyle);
4681
+ return html`<label class="field preset-tour-style">
4682
+ <span class="field-label">讲解风格</span>
4683
+ <select
4684
+ class="input preset-tour-style-select"
4685
+ @change=${(e) => {
4686
+ draft.tourStyle = e.target.value;
4687
+ }}
4688
+ >
4689
+ <option value="" ?selected=${!draft.tourStyle}>跟随面板设置</option>
4690
+ ${styles.map((style) => html`<option value=${style.key} ?selected=${style.key === draft.tourStyle}>
4691
+ ${style.label}
4692
+ </option>`)}
4693
+ ${stale ? html`<option value=${draft.tourStyle} selected>已失效(${draft.tourStyle})</option>` : nothing}
4694
+ </select>
4695
+ </label>`;
3828
4696
  }
3829
4697
  /** 列表项:名称一行,指令内容灰色单行省略(悬停查看全文),右侧为操作按钮 */
3830
4698
  renderPresetItem(preset, actions) {
@@ -3906,6 +4774,24 @@ var PageUseLauncherElement = class extends LitElement {
3906
4774
  * 演示播报:总开关关闭时只显示开关;开启后依次为引擎下拉(可选引擎多于一个时)、
3907
4775
  * 语速、音色下拉与试听按钮(无音色列表时只有试听按钮)、不可用 / 无中文语音提示。
3908
4776
  */
4777
+ /** 页面讲解风格:面板中未指定风格的讲解任务使用;快捷指令携带的风格优先 */
4778
+ renderTourStyleSetting(settings, config) {
4779
+ const { styles, defaultStyle } = config.pageTour;
4780
+ const defaultLabel = styles.find((style) => style.key === defaultStyle)?.label ?? defaultStyle;
4781
+ return html`<label class="field tour-style-field">
4782
+ <span class="field-label">讲解风格</span>
4783
+ <select
4784
+ class="input tour-style-select"
4785
+ @change=${(e) => pageUseSettings.update({ tourStyle: e.target.value })}
4786
+ >
4787
+ <option value="" ?selected=${!settings.tourStyle}>默认(${defaultLabel})</option>
4788
+ ${styles.map((style) => html`<option value=${style.key} ?selected=${style.key === settings.tourStyle}>
4789
+ ${style.label}
4790
+ </option>`)}
4791
+ </select>
4792
+ <span class="field-hint">用于「介绍当前页面」;也可以直接在指令中写明讲解要求,如「面向新员工介绍当前页面,侧重操作步骤」</span>
4793
+ </label>`;
4794
+ }
3909
4795
  renderVoiceSettings(settings) {
3910
4796
  const toggle = this.renderCheckbox("voiceEnabled", "语音播报", "演示时播报执行步骤、提问与结论");
3911
4797
  if (!settings.voiceEnabled) return toggle;
@@ -4086,13 +4972,6 @@ var PageUseLauncherElement = class extends LitElement {
4086
4972
  const historyCount = pageUseHistory.entries().length;
4087
4973
  const question = state.pendingQuestion;
4088
4974
  return html`<footer class="composer">
4089
- ${!this.stickToBottom && !isLogEmpty ? html`<button
4090
- type="button"
4091
- class="btn tiny round jump-latest"
4092
- @click=${() => this.scrollLogToBottom()}
4093
- >
4094
- ↓ 最新
4095
- </button>` : nothing}
4096
4975
  ${question ? html`<div class="alert warning">
4097
4976
  <div class="alert-title">${question.question}</div>
4098
4977
  <div class="input-group">
@@ -4282,4 +5161,4 @@ function mountPageUseUI(options = {}) {
4282
5161
  }
4283
5162
 
4284
5163
  //#endregion
4285
- export { pageUseSettings as A, browserTtsEngine as B, getPageUseConfig as C, pageUsePresets as D, pageUseStore as E, normalizeOptions as F, readLauncherSwitch as I, PAGE_USE_MASK_COLORS as L, DEFAULT_MODEL as M, DEFAULT_SESSION_HEADER as N, pageUseHistory as O, DEFAULT_STORAGE_KEY_PREFIX as P, SIMULATOR_MASK_ID as R, getElapsedSeconds as S, isRunning as T, runPageUseTool as _, PAGE_USE_STATIC_PRESETS as a, pageUseCapability as b, PAGE_USE_GLOW_TAG as c, PAGE_USE_GLOBAL_KEY as d, PAGE_USE_COMMAND_TYPE as f, isPageUseCommand as g, formatPageUseResultForModel as h, PageUseLauncherElement as i, DEFAULT_MAX_STEPS as j, DEFAULT_PAGE_USE_SETTINGS as k, PageUseGlowElement as l, PAGE_USE_TOOL_NAME as m, mountPageUseUI as n, buildMenuPresets as o, PAGE_USE_TOOL as p, PAGE_USE_LAUNCHER_TAG as r, PAGE_USE_ROBOT_ICON_SVG as s, definePageUseElements as t, createPageUse as u, hidePageUseLauncher as v, isPageUseConfigured as w, clearPageUseLog as x, showPageUseLauncher as y, BROWSER_ENGINE_ID as z };
5164
+ export { pageUseSettings as A, BROWSER_ENGINE_ID as B, getPageUseConfig as C, pageUsePresets as D, pageUseStore as E, DEFAULT_TOUR_MAX_TAB_SWITCHES as F, DEFAULT_TOUR_STYLES as H, normalizeOptions as I, readLauncherSwitch as L, DEFAULT_MODEL as M, DEFAULT_SESSION_HEADER as N, pageUseHistory as O, DEFAULT_STORAGE_KEY_PREFIX as P, PAGE_USE_MASK_COLORS as R, getElapsedSeconds as S, isRunning as T, TOUR_STYLE_TRAINER as U, browserTtsEngine as V, TOUR_STYLE_USER as W, runPageUseTool as _, PAGE_USE_STATIC_PRESETS as a, pageUseCapability as b, PAGE_USE_GLOW_TAG as c, PAGE_USE_GLOBAL_KEY as d, PAGE_USE_COMMAND_TYPE as f, isPageUseCommand as g, formatPageUseResultForModel as h, PageUseLauncherElement as i, DEFAULT_MAX_STEPS as j, DEFAULT_PAGE_USE_SETTINGS as k, PageUseGlowElement as l, PAGE_USE_TOOL_NAME as m, mountPageUseUI as n, buildMenuPresets as o, PAGE_USE_TOOL as p, PAGE_USE_LAUNCHER_TAG as r, PAGE_USE_ROBOT_ICON_SVG as s, definePageUseElements as t, createPageUse as u, hidePageUseLauncher as v, isPageUseConfigured as w, clearPageUseLog as x, showPageUseLauncher as y, SIMULATOR_MASK_ID as z };