@tanzerfe/page-use 0.0.6 → 0.0.8

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 * as z from "zod/v4";
3
+ import { clickElement } from "@page-agent/page-controller";
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,784 @@ 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
+ }
1598
+
1599
+ //#endregion
1600
+ //#region src/core/newWindow.ts
1601
+ /**
1602
+ * 新窗口打开记录器:任务执行期间识别点击触发的新窗口打开,供点击工具与按页提示反馈给模型。
1603
+ * - 识别:window.open、点击 <a target>(含点到其子元素)、未插入文档的 <a> 调用 click()
1604
+ * - 只以调用发生为依据:不检查 window.open 返回值,被浏览器弹窗拦截也视为已打开
1605
+ * - 不改变页面行为:原调用照常执行,参数与返回值不变,不拦截重复打开
1606
+ * 与 page-agent 解耦,由 createPageUseAgent 在任务开始时 install、结束时 uninstall。
1607
+ */
1608
+ /** 在当前窗口内打开的窗口名(比较时转小写),不视为新窗口 */
1609
+ const SAME_WINDOW_TARGETS = /* @__PURE__ */ new Set([
1610
+ "_self",
1611
+ "_parent",
1612
+ "_top"
1613
+ ]);
1614
+ /** 窗口名是否指向新窗口:空串(window.open 默认 _blank)与自定义窗口名均视为新窗口 */
1615
+ function opensNewWindow(name) {
1616
+ return !SAME_WINDOW_TARGETS.has(name.trim().toLowerCase());
1617
+ }
1618
+ /** 链接满足新窗口条件时返回绝对地址,否则返回 null */
1619
+ function newWindowAnchorUrl(anchor) {
1620
+ const target = anchor.getAttribute("target") ?? "";
1621
+ if (!target.trim() || !opensNewWindow(target)) return null;
1622
+ if (anchor.hasAttribute("download")) return null;
1623
+ const href = anchor.getAttribute("href")?.trim() ?? "";
1624
+ if (!href || /^javascript:/i.test(href)) return null;
1625
+ return anchor.href || href;
1626
+ }
1627
+ /** window.open 的地址参数解析为绝对 URL;未传地址时浏览器打开空白页 */
1628
+ function resolveOpenUrl(win, url) {
1629
+ const raw = url === void 0 ? "" : String(url).trim();
1630
+ if (!raw) return "about:blank";
1631
+ try {
1632
+ return new URL(raw, win.location.href).href;
1633
+ } catch {
1634
+ return raw;
1635
+ }
1636
+ }
1637
+ function unique(urls) {
1638
+ return [...new Set(urls)];
1639
+ }
1640
+ function createNewWindowWatcher(win = window) {
1641
+ let records = [];
1642
+ let installed = null;
1643
+ function recordAnchorClick(anchor, event) {
1644
+ const url = newWindowAnchorUrl(anchor);
1645
+ if (url) records.push({
1646
+ url,
1647
+ event
1648
+ });
1649
+ }
1650
+ /** 已插入文档的链接(含 Shadow DOM 内):点击事件经 composedPath 传到 window */
1651
+ function onWindowClick(event) {
1652
+ const anchor = event.composedPath().find((node) => node instanceof win.HTMLAnchorElement);
1653
+ if (anchor) recordAnchorClick(anchor, event);
1654
+ }
1655
+ function install() {
1656
+ uninstall();
1657
+ records = [];
1658
+ const originalOpen = win.open;
1659
+ const wrappedOpen = (url, target, features) => {
1660
+ const opened = originalOpen.call(win, url, target, features);
1661
+ if (opensNewWindow(target ?? "")) records.push({ url: resolveOpenUrl(win, url) });
1662
+ return opened;
1663
+ };
1664
+ win.open = wrappedOpen;
1665
+ const proto = win.HTMLAnchorElement.prototype;
1666
+ const originalClick = proto.click;
1667
+ const wrappedClick = function() {
1668
+ if (this.isConnected) return originalClick.call(this);
1669
+ const listener = (event) => recordAnchorClick(this, event);
1670
+ this.addEventListener("click", listener, {
1671
+ capture: true,
1672
+ once: true
1673
+ });
1674
+ try {
1675
+ originalClick.call(this);
1676
+ } finally {
1677
+ this.removeEventListener("click", listener, { capture: true });
1678
+ }
1679
+ };
1680
+ proto.click = wrappedClick;
1681
+ win.addEventListener("click", onWindowClick, { capture: true });
1682
+ installed = { restore: () => {
1683
+ if (win.open === wrappedOpen) win.open = originalOpen;
1684
+ if (proto.click === wrappedClick) proto.click = originalClick;
1685
+ win.removeEventListener("click", onWindowClick, { capture: true });
1686
+ } };
1687
+ }
1688
+ function uninstall() {
1689
+ installed?.restore();
1690
+ installed = null;
1691
+ }
1692
+ function since(n) {
1693
+ return unique(records.slice(n).filter((r) => !r.event?.defaultPrevented).map((r) => r.url));
1694
+ }
1695
+ return {
1696
+ install,
1697
+ uninstall,
1698
+ count: () => records.length,
1699
+ since,
1700
+ all: () => since(0)
1701
+ };
1702
+ }
1703
+ /** 点击结果追加的说明:告知模型新窗口不可观察,当前页面不会变化 */
1704
+ function formatOpenedNotice(urls) {
1705
+ return `已通过新窗口打开:${urls.join("、")}。新窗口不在可操作范围内,当前页面不会因此变化。`;
1706
+ }
800
1707
 
801
1708
  //#endregion
802
1709
  //#region src/core/siteMap.ts
@@ -879,6 +1786,8 @@ function buildSystemInstruction(options, flags = {}) {
879
1786
  "- 页面内的 Tab 通过点击 Tab 标签切换;详情/编辑通常是表格行内的按钮,点击后在抽屉或弹窗中打开。",
880
1787
  "- 下拉选择、日期选择若为自定义组件而非原生 select:先点击该输入框,再点击弹出层中的目标选项文字。",
881
1788
  "- 数据加载需要时间;点击后若内容未变化,先使用 wait 等待 1 到 2 秒再重新观察。",
1789
+ "- 允许点击会在新窗口打开页面或外链的入口(用户任务需要打开它时)。",
1790
+ "- 点击结果或页面提示出现「已通过新窗口打开」时,新窗口内容不可观察,当前页面也不会变化;若打开该页面就是任务目标,立即调用 done(success=true),说明已在新窗口打开的页面或地址,不要重复点击,也不要等待当前页面变化。",
882
1791
  "",
883
1792
  "## 安全边界",
884
1793
  "- 未经用户明确要求,不得执行删除、提交、审批、发布、批量修改等不可逆操作;不确定时用 ask_user 询问。",
@@ -888,9 +1797,11 @@ function buildSystemInstruction(options, flags = {}) {
888
1797
  }
889
1798
  /**
890
1799
  * 按页提示:page-agent 每步调用一次。
891
- * 路由名命中 routeHints 优先,其次按路径命中;返回 null 表示不追加。
1800
+ * 路由名命中 routeHints 优先,其次按路径命中;outlineText(页面讲解的模块清单)追加在其后;
1801
+ * openedUrls(本任务已通过新窗口打开的地址)追加在末尾,覆盖点击结果返回后才发生的异步打开。
1802
+ * 返回 null 表示不追加。
892
1803
  */
893
- function buildPageInstruction(options) {
1804
+ function buildPageInstruction(options, outlineText = "", openedUrls = []) {
894
1805
  const path = options.getCurrentPath();
895
1806
  const parts = [];
896
1807
  const menu = findMenuByPath(path, options.getMenus());
@@ -903,6 +1814,8 @@ function buildPageInstruction(options) {
903
1814
  }
904
1815
  const hint = routeName && options.routeHints[routeName] || options.routeHints[normalizePath(path)];
905
1816
  if (hint) parts.push(hint);
1817
+ if (outlineText) parts.push(outlineText);
1818
+ if (openedUrls.length) parts.push(`本任务已通过新窗口打开:${openedUrls.join("、")}`);
906
1819
  return parts.length ? parts.join("\n") : null;
907
1820
  }
908
1821
 
@@ -913,16 +1826,36 @@ function buildPageInstruction(options) {
913
1826
  * - LLM:浏览器请求 OpenAI 兼容端点,参数来自宿主选项
914
1827
  * - 站点知识:菜单树写入 system 提示,按页提示按当前路径动态注入
915
1828
  * - 任务开始前按设置执行宿主的 beforeTask(如展开折叠的侧栏)
1829
+ * - 页面讲解:按页提示附带页面模块清单,并注册 present_page 工具(pageTour 关闭时均不启用)
1830
+ * - 新窗口打开:覆盖内置点击工具,点击触发新窗口打开时在结果中说明;按页提示列出本任务已打开的地址
916
1831
  */
917
1832
  /** DOM 文本超过该长度时截断,避免大表格页把上下文撑爆 */
918
1833
  const PAGE_CONTENT_MAX_CHARS = 6e4;
919
1834
  /** 每步间隔(秒):留给路由切换与接口加载的时间 */
920
1835
  const STEP_DELAY_SECONDS = .6;
1836
+ /** page-agent 内置点击工具名:同名注册即覆盖内置实现 */
1837
+ const CLICK_TOOL_NAME = "click_element_by_index";
1838
+ /**
1839
+ * 点击工具:与内置实现相同(描述、输入结构不变),点击期间识别到新窗口打开时在结果后追加说明。
1840
+ * clickElement 返回前已等待 0.2 秒,同步与微任务内的打开均已记录;更晚的打开由按页提示兜底。
1841
+ */
1842
+ function createClickTool(watcher) {
1843
+ return tool({
1844
+ description: "Click element by index",
1845
+ inputSchema: z.object({ index: z.int().min(0) }),
1846
+ async execute(input) {
1847
+ const before = watcher.count();
1848
+ const { message } = await this.pageController.clickElement(input.index);
1849
+ const urls = watcher.since(before);
1850
+ return urls.length ? `${message}\n${formatOpenedNotice(urls)}` : message;
1851
+ }
1852
+ });
1853
+ }
921
1854
  /** 生成会话 ID;一次 execute 视为一个会话,任务开始时刷新 */
922
1855
  function newSessionId() {
923
1856
  return typeof crypto !== "undefined" && crypto.randomUUID?.() || `page-use-${Date.now()}`;
924
1857
  }
925
- function createPageUseAgent({ options, enableMask, narration = false, shouldRunBeforeTask, onTaskFinished }) {
1858
+ function createPageUseAgent({ options, enableMask, narration = false, shouldRunBeforeTask, onTaskFinished, onMaskForced, getTourStyle }) {
926
1859
  const { llm } = options;
927
1860
  if (!llm.baseURL) throw new Error("[PageUse] 未配置 llm.baseURL");
928
1861
  let sessionId = newSessionId();
@@ -940,6 +1873,15 @@ function createPageUseAgent({ options, enableMask, narration = false, shouldRunB
940
1873
  } : void 0;
941
1874
  const extraBody = llm.extraBody;
942
1875
  const hostBeforeTask = options.beforeTask;
1876
+ const hostPageAgent = options.pageAgentOptions;
1877
+ const tour = options.pageTour.enabled ? createPageTour({
1878
+ getContentRoot: options.getContentRoot,
1879
+ maxTabSwitches: options.pageTour.maxTabSwitches,
1880
+ maskEnabled: enableMask,
1881
+ onMaskForced,
1882
+ getStyle: getTourStyle
1883
+ }) : null;
1884
+ const newWindows = createNewWindowWatcher();
943
1885
  return new PageAgent({
944
1886
  baseURL: llm.baseURL,
945
1887
  apiKey: llm.apiKey || void 0,
@@ -958,25 +1900,38 @@ function createPageUseAgent({ options, enableMask, narration = false, shouldRunB
958
1900
  experimentalScriptExecutionTool: false,
959
1901
  instructions: {
960
1902
  system: buildSystemInstruction(options, { narration }),
961
- getPageInstructions: () => buildPageInstruction(options)
1903
+ getPageInstructions: () => buildPageInstruction(options, tour?.outlineText() ?? "", newWindows.all())
962
1904
  },
963
1905
  transformPageContent: (content) => {
964
1906
  if (content.length <= PAGE_CONTENT_MAX_CHARS) return content;
965
1907
  return `${content.slice(0, PAGE_CONTENT_MAX_CHARS)}\n...(页面内容过长已截断,可滚动后再观察)`;
966
1908
  },
967
- ...options.pageAgentOptions,
1909
+ ...hostPageAgent,
1910
+ customTools: {
1911
+ [CLICK_TOOL_NAME]: createClickTool(newWindows),
1912
+ ...tour ? { [PRESENT_PAGE_TOOL_NAME]: tour.tool } : {},
1913
+ ...hostPageAgent.customTools
1914
+ },
968
1915
  onBeforeTask: async (...args) => {
1916
+ newWindows.install();
969
1917
  sessionId = newSessionId();
1918
+ tour?.onBeforeTask();
970
1919
  if (hostBeforeTask && shouldRunBeforeTask()) try {
971
1920
  await hostBeforeTask();
972
1921
  } catch (err) {
973
1922
  console.warn("[PageUse] beforeTask 执行异常", err);
974
1923
  }
975
- await options.pageAgentOptions.onBeforeTask?.(...args);
1924
+ await hostPageAgent.onBeforeTask?.(...args);
976
1925
  },
977
1926
  onAfterTask: async (...args) => {
1927
+ newWindows.uninstall();
1928
+ await tour?.onAfterTask(args[1]);
978
1929
  onTaskFinished?.(args[1]);
979
- await options.pageAgentOptions.onAfterTask?.(...args);
1930
+ await hostPageAgent.onAfterTask?.(...args);
1931
+ },
1932
+ onDispose: (...args) => {
1933
+ newWindows.uninstall();
1934
+ hostPageAgent.onDispose?.(...args);
980
1935
  }
981
1936
  });
982
1937
  }
@@ -1060,19 +2015,37 @@ function storageKey(prefix) {
1060
2015
  function createId() {
1061
2016
  return `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
1062
2017
  }
1063
- /** 结构校验:丢弃缺少字符串 label / task 的项,缺 id 时补生成;hidden 只保留字符串并去重 */
2018
+ /** 讲解风格 key 去除首尾空白;非字符串或空白视为未指定(跟随面板设置),返回 undefined */
2019
+ function normalizeTourStyle(value) {
2020
+ return typeof value === "string" && value.trim() ? value.trim() : void 0;
2021
+ }
2022
+ /** 保存用的规范化结果:label / task 去除首尾空白;未指定讲解风格时不出现 tourStyle 键 */
2023
+ function normalize(preset) {
2024
+ const tourStyle = normalizeTourStyle(preset.tourStyle);
2025
+ return {
2026
+ label: preset.label.trim(),
2027
+ task: preset.task.trim(),
2028
+ ...tourStyle ? { tourStyle } : {}
2029
+ };
2030
+ }
2031
+ /**
2032
+ * 结构校验:丢弃缺少字符串 label / task 的项,缺 id 时补生成;
2033
+ * tourStyle 非字符串或空白时只丢弃该字段,指令本身保留;hidden 只保留字符串并去重
2034
+ */
1064
2035
  function sanitize(raw) {
1065
2036
  if (!raw || typeof raw !== "object") return { ...EMPTY_STATE };
1066
2037
  const { custom, hidden } = raw;
1067
2038
  return {
1068
2039
  custom: (Array.isArray(custom) ? custom : []).flatMap((item) => {
1069
2040
  if (!item || typeof item !== "object") return [];
1070
- const { id, label, task } = item;
2041
+ const { id, label, task, tourStyle } = item;
1071
2042
  if (typeof label !== "string" || typeof task !== "string") return [];
2043
+ const style = normalizeTourStyle(tourStyle);
1072
2044
  return [{
1073
2045
  id: typeof id === "string" && id ? id : createId(),
1074
2046
  label,
1075
- task
2047
+ task,
2048
+ ...style ? { tourStyle: style } : {}
1076
2049
  }];
1077
2050
  }),
1078
2051
  hidden: Array.isArray(hidden) ? [...new Set(hidden.filter((item) => typeof item === "string"))] : []
@@ -1099,7 +2072,10 @@ function persist(patch) {
1099
2072
  window.localStorage.setItem(storageKey(prefix), JSON.stringify(current.get()));
1100
2073
  } catch {}
1101
2074
  }
1102
- /** 必填与重复校验;excludeId 为编辑中的自身,与自身相同不算重复 */
2075
+ /**
2076
+ * 必填与重复校验;excludeId 为编辑中的自身,与自身相同不算重复。
2077
+ * 重复按 task + 讲解风格组合判断:同一指令可按不同风格各存一条,未指定风格以空串参与比较
2078
+ */
1103
2079
  function validate(preset, excludeId) {
1104
2080
  if (!preset.label.trim()) return {
1105
2081
  ok: false,
@@ -1110,7 +2086,8 @@ function validate(preset, excludeId) {
1110
2086
  ok: false,
1111
2087
  reason: "task-empty"
1112
2088
  };
1113
- return ensureStore().get().custom.some((item) => item.id !== excludeId && item.task.trim() === task) ? {
2089
+ const tourStyle = normalizeTourStyle(preset.tourStyle) ?? "";
2090
+ return ensureStore().get().custom.some((item) => item.id !== excludeId && item.task.trim() === task && (item.tourStyle ?? "") === tourStyle) ? {
1114
2091
  ok: false,
1115
2092
  reason: "duplicate"
1116
2093
  } : { ok: true };
@@ -1129,18 +2106,17 @@ const pageUsePresets = {
1129
2106
  get() {
1130
2107
  return ensureStore().get();
1131
2108
  },
1132
- /** 新增自定义指令,排在最前;label / task 去除首尾空白后保存 */
2109
+ /** 新增自定义指令,排在最前;label / task / tourStyle 去除首尾空白后保存 */
1133
2110
  add(preset) {
1134
2111
  const result = validate(preset);
1135
2112
  if (!result.ok) return result;
1136
2113
  persist({ custom: [{
1137
2114
  id: createId(),
1138
- label: preset.label.trim(),
1139
- task: preset.task.trim()
2115
+ ...normalize(preset)
1140
2116
  }, ...ensureStore().get().custom] });
1141
2117
  return result;
1142
2118
  },
1143
- /** 编辑自定义指令,位置不变 */
2119
+ /** 编辑自定义指令,位置不变;整条替换,未传 tourStyle 即清除原风格 */
1144
2120
  update(id, preset) {
1145
2121
  const { custom } = ensureStore().get();
1146
2122
  if (!custom.some((item) => item.id === id)) return {
@@ -1151,8 +2127,7 @@ const pageUsePresets = {
1151
2127
  if (!result.ok) return result;
1152
2128
  persist({ custom: custom.map((item) => item.id === id ? {
1153
2129
  id,
1154
- label: preset.label.trim(),
1155
- task: preset.task.trim()
2130
+ ...normalize(preset)
1156
2131
  } : item) });
1157
2132
  return result;
1158
2133
  },
@@ -1184,6 +2159,11 @@ function summarizeActionInput(name, input) {
1184
2159
  case "wait": return `等待 ${record.seconds ?? 1} 秒`;
1185
2160
  case "done": return record.success === false ? "结束任务(未达成)" : "结束任务";
1186
2161
  case "ask_user": return "向用户提问";
2162
+ case PRESENT_PAGE_TOOL_NAME: {
2163
+ const count = Array.isArray(record.sections) ? record.sections.length : 0;
2164
+ const base = count ? `讲解页面(${count} 个模块)` : "讲解页面";
2165
+ return record.next_tab ? `${base},随后切换 Tab` : base;
2166
+ }
1187
2167
  default: return name;
1188
2168
  }
1189
2169
  }
@@ -1379,7 +2359,13 @@ function ensureAgent(options) {
1379
2359
  options,
1380
2360
  enableMask: settings.enableMask,
1381
2361
  narration: settings.voiceEnabled,
1382
- shouldRunBeforeTask: () => pageUseSettings.get().enableBeforeTask
2362
+ shouldRunBeforeTask: () => pageUseSettings.get().enableBeforeTask,
2363
+ onMaskForced: () => {
2364
+ if (agent !== created) return;
2365
+ agentMaskFlag = null;
2366
+ store.set({ maskEnabled: true });
2367
+ },
2368
+ getTourStyle: () => resolveTourStyle(options.pageTour.styles, activeOptions?.tourStyle, pageUseSettings.get().tourStyle, options.pageTour.defaultStyle)
1383
2369
  });
1384
2370
  bindAgentEvents(created);
1385
2371
  created.onAskUser = (question, askOptions) => handleAskUser(question, askOptions?.signal);
@@ -2058,7 +3044,7 @@ var PageUseGlowElement = class extends LitElement {
2058
3044
 
2059
3045
  //#endregion
2060
3046
  //#region package.json
2061
- var version = "0.0.6";
3047
+ var version = "0.0.8";
2062
3048
 
2063
3049
  //#endregion
2064
3050
  //#region src/ui/drag.ts
@@ -2215,21 +3201,10 @@ const robotIcon = html`${unsafeHTML(PAGE_USE_ROBOT_ICON_SVG)}`;
2215
3201
 
2216
3202
  //#endregion
2217
3203
  //#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
- ];
3204
+ /**
3205
+ * 通用示例:预留。
3206
+ */
3207
+ const PAGE_USE_STATIC_PRESETS = [];
2233
3208
  /**
2234
3209
  * 取前 N 个可见叶子菜单,生成「打开 XX」快捷指令。
2235
3210
  * exclude 中的 task 被跳过且不占名额,由后续菜单补位(用户隐藏菜单指令时使用)。
@@ -2260,12 +3235,13 @@ function resolvePresetGroups(input) {
2260
3235
  const visible = (list) => (list || []).filter((preset) => !hidden.has(preset.task));
2261
3236
  const limit = input.menuPresetLimit;
2262
3237
  const menu = limit > 0 ? buildMenuPresets(input.menus, limit, hidden) : [];
2263
- const builtin = visible(PAGE_USE_STATIC_PRESETS);
3238
+ const builtinSource = input.builtinPresets === false ? [] : PAGE_USE_STATIC_PRESETS;
3239
+ const builtin = visible(builtinSource);
2264
3240
  const host = visible(input.presets);
2265
3241
  const scenarios = visible(input.scenarios);
2266
3242
  const allSourceTasks = new Set([
2267
3243
  ...limit > 0 ? buildMenuPresets(input.menus, Number.POSITIVE_INFINITY) : [],
2268
- ...PAGE_USE_STATIC_PRESETS,
3244
+ ...builtinSource,
2269
3245
  ...input.presets || [],
2270
3246
  ...input.scenarios || []
2271
3247
  ].map((preset) => preset.task));
@@ -2922,13 +3898,6 @@ const launcherStyles = css`
2922
3898
  border-radius: 0 0 10px 10px;
2923
3899
  background: rgba(250, 251, 252, 0.5);
2924
3900
  }
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
3901
  .input,
2933
3902
  .textarea {
2934
3903
  width: 100%;
@@ -3070,6 +4039,15 @@ const launcherStyles = css`
3070
4039
  color: var(--text-3);
3071
4040
  font-size: 12px;
3072
4041
  }
4042
+ .field-hint {
4043
+ color: var(--text-3);
4044
+ font-size: 11px;
4045
+ line-height: 1.5;
4046
+ }
4047
+ /* 讲解风格:与复选框文字、播报选项输入框左对齐(同 21px 缩进) */
4048
+ .tour-style-field {
4049
+ margin-left: 21px;
4050
+ }
3073
4051
  .range {
3074
4052
  width: 100%;
3075
4053
  margin: 0;
@@ -3220,7 +4198,7 @@ const PARENT_VIEW = {
3220
4198
  const PRESET_SAVE_ERRORS = {
3221
4199
  "label-empty": "请填写名称",
3222
4200
  "task-empty": "请填写指令内容",
3223
- duplicate: "已存在指令内容相同的自定义指令",
4201
+ duplicate: "已存在相同的自定义指令",
3224
4202
  "not-found": "该指令已被删除,请重新新增"
3225
4203
  };
3226
4204
  /** 播报语速倍率的调节范围与步长(1 为正常语速,过快或过慢时中文语音难以听清;注入引擎自行换算为服务参数) */
@@ -3273,12 +4251,12 @@ var PageUseLauncherElement = class extends LitElement {
3273
4251
  toastTimer = null;
3274
4252
  /**
3275
4253
  * 日志区跟随滚动:默认贴底跟随新内容;用户上翻后停止跟随,
3276
- * 回到底部附近(阈值内)或点击「最新」后恢复。
4254
+ * 回到底部附近(阈值内)后恢复。
3277
4255
  */
3278
4256
  stickToBottom = true;
3279
4257
  /** 上次渲染时日志内容的摘要;仅内容变化时才贴底,避免每秒的耗时刷新把用户拉回底部 */
3280
4258
  logSignature = "";
3281
- /** 下次渲染强制滚到底部(点击「最新」、任务开始) */
4259
+ /** 下次渲染强制滚到底部(任务开始) */
3282
4260
  forceScroll = false;
3283
4261
  /**
3284
4262
  * 入口尺寸缓存:入口在面板展开时被隐藏,offsetWidth 为 0,
@@ -3598,16 +4576,20 @@ var PageUseLauncherElement = class extends LitElement {
3598
4576
  this.measureFab();
3599
4577
  this.fabDrag.reclamp();
3600
4578
  }
3601
- async submit() {
4579
+ /** tourStyle:快捷指令携带的讲解风格;输入框提交时不传,按面板设置与宿主默认 */
4580
+ async submit(tourStyle) {
3602
4581
  const text = this.task.trim();
3603
4582
  if (isRunning() || !text) return;
3604
4583
  this.task = "";
3605
4584
  this.requestUpdate();
3606
- await runTask(text, { source: LAUNCHER_SOURCE });
4585
+ await runTask(text, {
4586
+ source: LAUNCHER_SOURCE,
4587
+ ...tourStyle ? { tourStyle } : {}
4588
+ });
3607
4589
  }
3608
- applyPreset(text) {
4590
+ applyPreset(text, tourStyle) {
3609
4591
  this.task = text;
3610
- this.submit();
4592
+ this.submit(tourStyle);
3611
4593
  }
3612
4594
  lastTaskText(state) {
3613
4595
  return state.currentTask || pageUseHistory.entries()[0]?.task || "";
@@ -3644,23 +4626,18 @@ var PageUseLauncherElement = class extends LitElement {
3644
4626
  onLogScroll(event) {
3645
4627
  const el = event.target;
3646
4628
  const stick = el.scrollHeight - el.scrollTop - el.clientHeight < STICK_THRESHOLD;
3647
- if (stick === this.stickToBottom) return;
3648
4629
  this.stickToBottom = stick;
3649
- this.requestUpdate();
3650
4630
  }
3651
4631
  /**
3652
4632
  * 滚轮上翻立即停止跟随:平滑滚动的首个 scroll 事件位移可能小于阈值,
3653
4633
  * 若此时仍判定为贴底,内容刷新会把视图拉回底部,表现为上翻时抖动。
4634
+ * 内容不足一屏(或已在顶部)时无法上翻,也不会触发 scroll 事件来恢复跟随,
4635
+ * 因此 scrollTop 为 0 时忽略,否则后续内容增长时不再贴底。
3654
4636
  */
3655
4637
  onLogWheel(event) {
3656
4638
  if (event.deltaY >= 0 || !this.stickToBottom) return;
4639
+ if (event.currentTarget.scrollTop <= 0) return;
3657
4640
  this.stickToBottom = false;
3658
- this.requestUpdate();
3659
- }
3660
- scrollLogToBottom() {
3661
- this.stickToBottom = true;
3662
- this.forceScroll = true;
3663
- this.requestUpdate();
3664
4641
  }
3665
4642
  toggleTray(kind) {
3666
4643
  this.tray = this.tray === kind ? null : kind;
@@ -3685,6 +4662,7 @@ var PageUseLauncherElement = class extends LitElement {
3685
4662
  return resolvePresetGroups({
3686
4663
  menus: getPageUseConfig()?.getMenus(),
3687
4664
  menuPresetLimit: this._config.menuPresetLimit ?? 6,
4665
+ builtinPresets: this._config.builtinPresets,
3688
4666
  presets: this._config.presets,
3689
4667
  scenarios: this._config.scenarios,
3690
4668
  state: pageUsePresets.get()
@@ -3695,7 +4673,7 @@ var PageUseLauncherElement = class extends LitElement {
3695
4673
  type="button"
3696
4674
  class="btn tiny round secondary ${accent ? "accent" : ""}"
3697
4675
  title=${preset.task}
3698
- @click=${() => this.applyPreset(preset.task)}
4676
+ @click=${() => this.applyPreset(preset.task, preset.tourStyle)}
3699
4677
  >
3700
4678
  ${preset.label}
3701
4679
  </button>`);
@@ -3750,6 +4728,7 @@ var PageUseLauncherElement = class extends LitElement {
3750
4728
  ${config?.beforeTask ? this.renderCheckbox("enableBeforeTask", config.beforeTaskLabel) : nothing}
3751
4729
  ${this.renderCheckbox("showStepDetails", "步骤显示模型思考与返回")}
3752
4730
  ${this.renderVoiceSettings(settings)}
4731
+ ${config?.pageTour.enabled ? this.renderTourStyleSetting(settings, config) : nothing}
3753
4732
  <div class="row settings-actions">
3754
4733
  <button type="button" class="btn" @click=${() => pageUseSettings.resetPositions()}>
3755
4734
  复位窗口
@@ -3771,6 +4750,7 @@ var PageUseLauncherElement = class extends LitElement {
3771
4750
  id,
3772
4751
  label: item?.label ?? "",
3773
4752
  task: item?.task ?? "",
4753
+ tourStyle: item?.tourStyle ?? "",
3774
4754
  error: ""
3775
4755
  };
3776
4756
  this.requestUpdate();
@@ -3780,7 +4760,8 @@ var PageUseLauncherElement = class extends LitElement {
3780
4760
  if (!draft) return;
3781
4761
  const preset = {
3782
4762
  label: draft.label,
3783
- task: draft.task
4763
+ task: draft.task,
4764
+ tourStyle: draft.tourStyle
3784
4765
  };
3785
4766
  const result = draft.id === null ? pageUsePresets.add(preset) : pageUsePresets.update(draft.id, preset);
3786
4767
  this.presetDraft = result.ok ? null : {
@@ -3808,6 +4789,7 @@ var PageUseLauncherElement = class extends LitElement {
3808
4789
  draft.task = e.target.value;
3809
4790
  }}
3810
4791
  ></textarea>
4792
+ ${this.renderPresetTourStyle(draft)}
3811
4793
  ${draft.error ? html`<div class="alert error preset-error">${draft.error}</div>` : nothing}
3812
4794
  <div class="row preset-form-actions">
3813
4795
  <button
@@ -3825,6 +4807,32 @@ var PageUseLauncherElement = class extends LitElement {
3825
4807
  </button>
3826
4808
  </div>
3827
4809
  </div>`;
4810
+ }
4811
+ /**
4812
+ * 表单中的讲解风格(可选):仅页面讲解启用时显示,选项与设置页一致。
4813
+ * 已保存的 key 不在当前风格列表中(宿主移除了该风格)时追加「已失效」项并选中,
4814
+ * 不改选直接保存会保留原值;执行时由 resolveTourStyle 回退到面板设置与宿主默认
4815
+ */
4816
+ renderPresetTourStyle(draft) {
4817
+ const config = getPageUseConfig();
4818
+ if (!config?.pageTour.enabled) return nothing;
4819
+ const { styles } = config.pageTour;
4820
+ const stale = draft.tourStyle && !styles.some((style) => style.key === draft.tourStyle);
4821
+ return html`<label class="field preset-tour-style">
4822
+ <span class="field-label">讲解风格</span>
4823
+ <select
4824
+ class="input preset-tour-style-select"
4825
+ @change=${(e) => {
4826
+ draft.tourStyle = e.target.value;
4827
+ }}
4828
+ >
4829
+ <option value="" ?selected=${!draft.tourStyle}>跟随面板设置</option>
4830
+ ${styles.map((style) => html`<option value=${style.key} ?selected=${style.key === draft.tourStyle}>
4831
+ ${style.label}
4832
+ </option>`)}
4833
+ ${stale ? html`<option value=${draft.tourStyle} selected>已失效(${draft.tourStyle})</option>` : nothing}
4834
+ </select>
4835
+ </label>`;
3828
4836
  }
3829
4837
  /** 列表项:名称一行,指令内容灰色单行省略(悬停查看全文),右侧为操作按钮 */
3830
4838
  renderPresetItem(preset, actions) {
@@ -3906,6 +4914,24 @@ var PageUseLauncherElement = class extends LitElement {
3906
4914
  * 演示播报:总开关关闭时只显示开关;开启后依次为引擎下拉(可选引擎多于一个时)、
3907
4915
  * 语速、音色下拉与试听按钮(无音色列表时只有试听按钮)、不可用 / 无中文语音提示。
3908
4916
  */
4917
+ /** 页面讲解风格:面板中未指定风格的讲解任务使用;快捷指令携带的风格优先 */
4918
+ renderTourStyleSetting(settings, config) {
4919
+ const { styles, defaultStyle } = config.pageTour;
4920
+ const defaultLabel = styles.find((style) => style.key === defaultStyle)?.label ?? defaultStyle;
4921
+ return html`<label class="field tour-style-field">
4922
+ <span class="field-label">讲解风格</span>
4923
+ <select
4924
+ class="input tour-style-select"
4925
+ @change=${(e) => pageUseSettings.update({ tourStyle: e.target.value })}
4926
+ >
4927
+ <option value="" ?selected=${!settings.tourStyle}>默认(${defaultLabel})</option>
4928
+ ${styles.map((style) => html`<option value=${style.key} ?selected=${style.key === settings.tourStyle}>
4929
+ ${style.label}
4930
+ </option>`)}
4931
+ </select>
4932
+ <span class="field-hint">用于「介绍当前页面」;也可以直接在指令中写明讲解要求,如「面向新员工介绍当前页面,侧重操作步骤」</span>
4933
+ </label>`;
4934
+ }
3909
4935
  renderVoiceSettings(settings) {
3910
4936
  const toggle = this.renderCheckbox("voiceEnabled", "语音播报", "演示时播报执行步骤、提问与结论");
3911
4937
  if (!settings.voiceEnabled) return toggle;
@@ -4086,13 +5112,6 @@ var PageUseLauncherElement = class extends LitElement {
4086
5112
  const historyCount = pageUseHistory.entries().length;
4087
5113
  const question = state.pendingQuestion;
4088
5114
  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
5115
  ${question ? html`<div class="alert warning">
4097
5116
  <div class="alert-title">${question.question}</div>
4098
5117
  <div class="input-group">
@@ -4282,4 +5301,4 @@ function mountPageUseUI(options = {}) {
4282
5301
  }
4283
5302
 
4284
5303
  //#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 };
5304
+ 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 };