@tanzerfe/page-use 0.0.5 → 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.
- package/README.md +49 -269
- package/dist/{index-D4_hId31.d.mts → index-BOXzkiXG.d.mts} +86 -8
- package/dist/index.d.mts +2 -2
- package/dist/index.mjs +2 -2
- package/dist/{src-tOb9oqjb.mjs → src-ChJ6q7jl.mjs} +959 -80
- package/dist/vue.d.mts +2 -2
- package/dist/vue.mjs +2 -2
- package/package.json +2 -1
|
@@ -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
|
-
|
|
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())
|
|
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
|
|
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
|
|
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
|
-
...
|
|
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
|
|
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
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
2220
|
-
|
|
2221
|
-
|
|
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
|
|
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
|
-
...
|
|
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
|
-
|
|
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, {
|
|
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;
|
|
@@ -3921,7 +4807,7 @@ var PageUseLauncherElement = class extends LitElement {
|
|
|
3921
4807
|
type="button"
|
|
3922
4808
|
class="btn preview-btn"
|
|
3923
4809
|
?disabled=${!available || running}
|
|
3924
|
-
title=${running ? "任务执行中不可试听" : "
|
|
4810
|
+
title=${running ? "任务执行中不可试听" : "按当前设置试听"}
|
|
3925
4811
|
@click=${() => playing ? stopPreview() : startPreview(engine, {
|
|
3926
4812
|
rate: settings.voiceRate,
|
|
3927
4813
|
voice: voiceName
|
|
@@ -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">
|
|
@@ -4231,7 +5110,7 @@ var PageUseLauncherElement = class extends LitElement {
|
|
|
4231
5110
|
<button
|
|
4232
5111
|
type="button"
|
|
4233
5112
|
class="icon-btn close"
|
|
4234
|
-
title="
|
|
5113
|
+
title="关闭"
|
|
4235
5114
|
@click=${() => this.close()}
|
|
4236
5115
|
>
|
|
4237
5116
|
${closeIcon}
|
|
@@ -4282,4 +5161,4 @@ function mountPageUseUI(options = {}) {
|
|
|
4282
5161
|
}
|
|
4283
5162
|
|
|
4284
5163
|
//#endregion
|
|
4285
|
-
export { pageUseSettings as A,
|
|
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 };
|