@tanzerfe/page-use 0.0.2 → 0.0.5

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,7 +1,804 @@
1
1
  import { PageAgent } from "page-agent";
2
- import { LitElement, css, html, nothing } from "lit";
2
+ import { LitElement, css, html, nothing, svg } from "lit";
3
3
  import { keyed } from "lit/directives/keyed.js";
4
+ import { unsafeHTML } from "lit/directives/unsafe-html.js";
4
5
 
6
+ //#region src/voice/browserEngine.ts
7
+ /** 内置浏览器引擎的保留 id;注入引擎使用该 id 会被忽略 */
8
+ const BROWSER_ENGINE_ID = "browser";
9
+ /** 播报语言 */
10
+ const SPEECH_LANG = "zh-CN";
11
+ /** 超时兜底的语速估算:中文语音在 rate=1 时约每秒 4 字 */
12
+ const CHARS_PER_SECOND = 4;
13
+ /** 超时兜底的余量(毫秒):覆盖语音引擎启动延迟;onend 不触发时按估算时长 + 余量视为播完 */
14
+ const TIMEOUT_MARGIN_MS = 2e3;
15
+ /** 当前环境是否支持语音合成 */
16
+ function isSpeechSupported() {
17
+ return typeof globalThis.speechSynthesis !== "undefined" && typeof globalThis.SpeechSynthesisUtterance !== "undefined";
18
+ }
19
+ /** 可用的中文语音(lang 以 zh 开头,兼容 zh-CN / zh_CN / zh-HK 等写法);Chrome 首次调用可能为空,需监听 voiceschanged */
20
+ function getChineseVoices() {
21
+ if (!isSpeechSupported()) return [];
22
+ return globalThis.speechSynthesis.getVoices().filter((voice) => /^zh(?:$|[-_])/i.test(voice.lang));
23
+ }
24
+ /** 语音选择:指定名称 → 第一个中文语音 → undefined(浏览器默认语音) */
25
+ function resolveVoice(voiceName) {
26
+ if (!isSpeechSupported()) return void 0;
27
+ const voices = globalThis.speechSynthesis.getVoices();
28
+ return voiceName && voices.find((voice) => voice.name === voiceName) || getChineseVoices()[0];
29
+ }
30
+ const browserTtsEngine = {
31
+ id: BROWSER_ENGINE_ID,
32
+ label: "本地语音(浏览器)",
33
+ splitSentences: true,
34
+ isAvailable: isSpeechSupported,
35
+ listVoices: () => getChineseVoices().map((voice) => ({
36
+ value: voice.name,
37
+ label: voice.name
38
+ })),
39
+ speak(text, { rate, voice: voiceName, signal }) {
40
+ return new Promise((resolve) => {
41
+ if (signal.aborted) {
42
+ resolve();
43
+ return;
44
+ }
45
+ const utterance = new SpeechSynthesisUtterance(text);
46
+ utterance.lang = SPEECH_LANG;
47
+ utterance.rate = rate > 0 ? rate : 1;
48
+ const voice = resolveVoice(voiceName);
49
+ if (voice) utterance.voice = voice;
50
+ let settled = false;
51
+ let timer;
52
+ const finish = () => {
53
+ if (settled) return;
54
+ settled = true;
55
+ clearTimeout(timer);
56
+ signal.removeEventListener("abort", onAbort);
57
+ resolve();
58
+ };
59
+ const onAbort = () => {
60
+ globalThis.speechSynthesis.cancel();
61
+ finish();
62
+ };
63
+ utterance.onend = finish;
64
+ utterance.onerror = finish;
65
+ signal.addEventListener("abort", onAbort);
66
+ timer = setTimeout(finish, text.length / (CHARS_PER_SECOND * utterance.rate) * 1e3 + TIMEOUT_MARGIN_MS);
67
+ globalThis.speechSynthesis.speak(utterance);
68
+ });
69
+ }
70
+ };
71
+
72
+ //#endregion
73
+ //#region src/core/maskTheme.ts
74
+ /**
75
+ * 运行期蒙层与模拟光标的定制主题(覆盖 page-agent SimulatorMask 的默认视觉)。
76
+ *
77
+ * page-agent 的 CSS Module 类名带哈希(如 `_cursor_1dgwb_2`),随版本变化,
78
+ * 因此这里只依赖稳定的结构:
79
+ * - 蒙层根节点 id:`page-agent-runtime_simulator-mask`
80
+ * - 子节点顺序:ai-motion 光晕层在前(初始化失败时不存在),光标节点恒为最后一个子节点
81
+ * - 光标子节点顺序:1 点击波纹 / 2 箭头填充 / 3 箭头描边
82
+ * - 点击态类名包含 `clicking` 子串
83
+ * 升级 page-agent 后若光标样式失效,先核对 SimulatorMask 的 #createCursor 结构。
84
+ */
85
+ /** 蒙层根节点 id(@page-agent/page-controller 内部常量) */
86
+ const SIMULATOR_MASK_ID = "page-agent-runtime_simulator-mask";
87
+ /** 主题色:主色 #0879ff 同系的蓝 → 青 → 靛渐变 */
88
+ const PAGE_USE_MASK_COLORS = {
89
+ primary: "#0879ff",
90
+ cyan: "#36cfc9",
91
+ indigo: "#597ef7",
92
+ /** 误触拦截时的警示色 */
93
+ warning: "#fa8c16"
94
+ };
95
+ const CURSOR_ARROW_MASK = `url("data:image/svg+xml,${encodeURIComponent(`<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><path d='M 15 42 L 15 36.99 Q 15 31.99 23.7 31.99 L 28.05 31.99 Q 32.41 31.99 32.41 21.99 L 32.41 17 Q 32.41 12 41.09 16.95 L 76.31 37.05 Q 85 42 76.31 46.95 L 41.09 67.05 Q 32.41 72 32.41 62.01 L 32.41 57.01 Q 32.41 52.01 23.7 52.01 L 19.35 52.01 Q 15 52.01 15 47.01 Z' fill='#000'/></svg>`)}")`;
96
+ const MASK = `#${SIMULATOR_MASK_ID}`;
97
+ /** 光标节点:蒙层的最后一个子节点 */
98
+ const CURSOR = `${MASK} > :last-child`;
99
+ const { primary, cyan, indigo } = PAGE_USE_MASK_COLORS;
100
+ const PAGE_USE_MASK_THEME_CSS = `
101
+ /* 隐藏 ai-motion 默认光晕(光标之外的子节点),光晕改由 <tz-page-use-glow> 渲染 */
102
+ ${MASK} > :not(:last-child) { display: none !important; }
103
+ /* 蒙层本身保持透明,仅负责拦截点击;真实鼠标由 wait 转圈改为禁止符号,明确「此时不可操作」而非「页面卡死」 */
104
+ ${MASK} { cursor: not-allowed !important; }
105
+
106
+ /* 光标整体外发光 */
107
+ ${CURSOR} {
108
+ filter: drop-shadow(0 0 6px ${primary}8c) drop-shadow(0 2px 3px rgba(0, 0, 0, 0.25));
109
+ }
110
+ /* 光标尖端(节点原点)的呼吸光圈 */
111
+ ${CURSOR}::before {
112
+ content: '';
113
+ position: absolute;
114
+ left: -16px;
115
+ top: -16px;
116
+ width: 32px;
117
+ height: 32px;
118
+ border-radius: 50%;
119
+ background: radial-gradient(circle, ${cyan}66 0%, ${primary}26 55%, transparent 72%);
120
+ animation: page-use-cursor-halo 1.6s ease-in-out infinite;
121
+ pointer-events: none;
122
+ }
123
+ /* 箭头描边:主题渐变 */
124
+ ${CURSOR} > :nth-child(3) {
125
+ background: linear-gradient(45deg, ${primary}, ${cyan}) !important;
126
+ }
127
+ /* 箭头填充:用同形遮罩替换原白色 SVG 背景,改为浅色渐变 */
128
+ ${CURSOR} > :nth-child(2) {
129
+ background: linear-gradient(135deg, #ffffff 30%, #e6f4ff) !important;
130
+ -webkit-mask: ${CURSOR_ARROW_MASK} center / 100% 100% no-repeat;
131
+ mask: ${CURSOR_ARROW_MASK} center / 100% 100% no-repeat;
132
+ }
133
+ /* 点击波纹:外环改主题色 */
134
+ ${CURSOR} > :nth-child(1)::after {
135
+ border-color: ${cyan} !important;
136
+ border-width: 3px !important;
137
+ }
138
+ /* 点击波纹:追加一层实心扩散,增强点击反馈 */
139
+ ${CURSOR} > :nth-child(1)::before {
140
+ content: '';
141
+ position: absolute;
142
+ inset: 25%;
143
+ border-radius: 50%;
144
+ background: radial-gradient(circle, ${indigo}80, transparent 70%);
145
+ opacity: 0;
146
+ }
147
+ ${CURSOR}[class*='clicking'] > :nth-child(1)::before {
148
+ animation: page-use-cursor-press 420ms ease-out forwards;
149
+ }
150
+
151
+ @keyframes page-use-cursor-halo {
152
+ 0%, 100% { transform: scale(0.85); opacity: 0.7; }
153
+ 50% { transform: scale(1.25); opacity: 1; }
154
+ }
155
+ @keyframes page-use-cursor-press {
156
+ 0% { transform: scale(0.4); opacity: 1; }
157
+ 100% { transform: scale(2.4); opacity: 0; }
158
+ }
159
+ @media (prefers-reduced-motion: reduce) {
160
+ ${CURSOR}::before { animation: none; }
161
+ }
162
+ `;
163
+
164
+ //#endregion
165
+ //#region src/voice/engines.ts
166
+ /**
167
+ * 语音合成引擎注册表:合并宿主注入的引擎与内置浏览器引擎,并按设置解析当前生效引擎。
168
+ * narration(实际播报)与调试面板(设置展示)共用这里的结论,避免两处各自回退。
169
+ */
170
+ /**
171
+ * 可选引擎列表:浏览器引擎(本地语音)在最前,注入引擎按注入顺序、去重排在其后(即下拉框顺序)。
172
+ * 宿主注入 id 为 'browser' 的引擎会被忽略;重复 id 保留先出现者。
173
+ */
174
+ function listTtsEngines(injected) {
175
+ const list = injected === void 0 ? [] : Array.isArray(injected) ? injected : [injected];
176
+ const seen = /* @__PURE__ */ new Set([BROWSER_ENGINE_ID]);
177
+ const engines = [browserTtsEngine];
178
+ for (const engine of list) {
179
+ if (seen.has(engine.id)) {
180
+ console.warn(`[PageUse] 语音合成引擎 id 重复,已忽略:${engine.id}(${engine.label})`);
181
+ continue;
182
+ }
183
+ seen.add(engine.id);
184
+ engines.push(engine);
185
+ }
186
+ return engines;
187
+ }
188
+ /**
189
+ * 当前生效引擎:保存的 id 命中则用之;否则为默认引擎,即浏览器引擎(本地语音),与是否注入引擎无关。
190
+ * 回退只影响本次解析,不改写设置,宿主恢复该引擎后仍按原选择播报。
191
+ */
192
+ function resolveTtsEngine(engines, savedId) {
193
+ return savedId && engines.find((engine) => engine.id === savedId) || browserTtsEngine;
194
+ }
195
+
196
+ //#endregion
197
+ //#region src/core/options.ts
198
+ /** 默认模型,仅为示例取值;接入其它端点时应通过 llm.model 显式指定支持 function calling 的模型 */
199
+ const DEFAULT_MODEL = "deepseek-chat";
200
+ /** 单次任务最大步数;每步一次 LLM 调用,防止死循环消耗 token */
201
+ const DEFAULT_MAX_STEPS = 20;
202
+ /** 默认会话头:OpenCode Go 要求每个会话带稳定的 x-opencode-session 用于路由与提示缓存 */
203
+ const DEFAULT_SESSION_HEADER = "x-opencode-session";
204
+ /** localStorage / sessionStorage 键前缀,可通过 storageKeyPrefix 覆盖 */
205
+ const DEFAULT_STORAGE_KEY_PREFIX = "tz-page-use";
206
+ /** 相对路径补齐为当前 origin 的绝对地址并去掉末尾 /;无法解析时返回空串 */
207
+ function toAbsoluteUrl(input) {
208
+ const base = typeof window !== "undefined" ? window.location.origin : void 0;
209
+ try {
210
+ return new URL(input, base).toString().replace(/\/+$/, "");
211
+ } catch {
212
+ return "";
213
+ }
214
+ }
215
+ /** 按路由模式从 location 读取当前路径 */
216
+ function readLocationPath(mode) {
217
+ if (typeof window === "undefined") return "/";
218
+ if (mode === "hash") return window.location.hash.replace(/^#/, "") || "/";
219
+ return `${window.location.pathname}${window.location.search}`;
220
+ }
221
+ function resolveSessionHeader(value) {
222
+ if (value === false) return "";
223
+ return (value ?? "").trim() || "x-opencode-session";
224
+ }
225
+ function normalizeOptions(options) {
226
+ const { llm } = options;
227
+ const rawBase = (llm.baseURL || "").trim();
228
+ const routerMode = options.routerMode ?? "hash";
229
+ return {
230
+ llm: {
231
+ baseURL: rawBase ? toAbsoluteUrl(rawBase) : "",
232
+ apiKey: (llm.apiKey || "").trim(),
233
+ model: (llm.model || "").trim() || "deepseek-chat",
234
+ maxSteps: llm.maxSteps && llm.maxSteps > 0 ? llm.maxSteps : 20,
235
+ disableNamedToolChoice: Boolean(llm.disableNamedToolChoice),
236
+ extraBody: llm.extraBody && typeof llm.extraBody === "object" ? llm.extraBody : {},
237
+ sessionHeader: resolveSessionHeader(llm.sessionHeader)
238
+ },
239
+ appName: options.appName,
240
+ getMenus: options.getMenus ?? (() => void 0),
241
+ routerMode,
242
+ getCurrentPath: options.getCurrentPath ?? (() => readLocationPath(routerMode)),
243
+ resolveRouteName: options.resolveRouteName,
244
+ routeHints: options.routeHints ?? {},
245
+ layoutHint: options.layoutHint,
246
+ beforeTask: options.beforeTask,
247
+ beforeTaskLabel: options.beforeTaskLabel || "任务开始前执行宿主预处理",
248
+ storageKeyPrefix: options.storageKeyPrefix || "tz-page-use",
249
+ pageAgentOptions: options.pageAgentOptions ?? {},
250
+ exposeGlobal: options.exposeGlobal !== false,
251
+ ttsEngines: listTtsEngines(options.tts)
252
+ };
253
+ }
254
+ /** URL 查询参数:?pageUseDebug=1 打开调试面板,?pageUseDebug=0 关闭;结果记入 sessionStorage */
255
+ const LAUNCHER_QUERY_KEY = "pageUseDebug";
256
+ /** 从 location 读取调试开关(hash 路由的查询参数在 # 之后,两处都查) */
257
+ /** 读取 URL 中的 pageUseDebug 参数(search 或 hash 内的查询串);未携带时为 null。供 launcher 判定显示状态 */
258
+ function readLauncherQuery() {
259
+ const fromSearch = new URLSearchParams(window.location.search).get(LAUNCHER_QUERY_KEY);
260
+ if (fromSearch !== null) return fromSearch;
261
+ const hashQuery = window.location.hash.split("?")[1];
262
+ return hashQuery ? new URLSearchParams(hashQuery).get(LAUNCHER_QUERY_KEY) : null;
263
+ }
264
+ /**
265
+ * 调试面板是否显示。优先级:URL 参数(并记入本会话)> 会话记录 > fallback。
266
+ * fallback 由宿主决定(例如「开发环境显示,生产环境隐藏」)。
267
+ */
268
+ function readLauncherSwitch(fallback, storageKeyPrefix = DEFAULT_STORAGE_KEY_PREFIX) {
269
+ if (typeof window === "undefined") return fallback;
270
+ const sessionKey = `${storageKeyPrefix}-debug`;
271
+ try {
272
+ const query = readLauncherQuery();
273
+ if (query !== null) {
274
+ const enabled = query === "1" || query === "true";
275
+ window.sessionStorage.setItem(sessionKey, enabled ? "1" : "0");
276
+ return enabled;
277
+ }
278
+ const session = window.sessionStorage.getItem(sessionKey);
279
+ if (session !== null) return session === "1";
280
+ } catch {}
281
+ return fallback;
282
+ }
283
+
284
+ //#endregion
285
+ //#region src/core/store.ts
286
+ function createStore(initial) {
287
+ let state = initial;
288
+ const listeners = /* @__PURE__ */ new Set();
289
+ const notify = () => {
290
+ for (const listener of listeners) try {
291
+ listener(state);
292
+ } catch (err) {
293
+ console.warn("[PageUse] store 订阅回调异常", err);
294
+ }
295
+ };
296
+ return {
297
+ get: () => state,
298
+ set(patch) {
299
+ const partial = typeof patch === "function" ? patch(state) : patch;
300
+ if (Object.keys(partial).every((key) => Object.is(state[key], partial[key]))) return;
301
+ state = {
302
+ ...state,
303
+ ...partial
304
+ };
305
+ notify();
306
+ },
307
+ replace(next) {
308
+ if (Object.is(next, state)) return;
309
+ state = next;
310
+ notify();
311
+ },
312
+ subscribe(listener) {
313
+ listeners.add(listener);
314
+ return () => {
315
+ listeners.delete(listener);
316
+ };
317
+ }
318
+ };
319
+ }
320
+
321
+ //#endregion
322
+ //#region src/core/settings.ts
323
+ /**
324
+ * Page Use 本地设置(localStorage 持久化,单例)。
325
+ * 含 page-agent 内置面板/元素编号高亮开关、蒙层与光晕、演示播报、调试入口拖拽位置与显示状态。
326
+ * 存储键为 `${storageKeyPrefix}-settings`。
327
+ */
328
+ /** page-agent 元素高亮容器 id(page-controller 内部常量,随版本可能变化) */
329
+ const HIGHLIGHT_CONTAINER_ID = "playwright-highlight-container";
330
+ const HIGHLIGHT_STYLE_ID = "page-use-hide-highlights";
331
+ /** page-agent 内置面板根节点 id(@page-agent/ui 内部常量) */
332
+ const BUILTIN_PANEL_ID = "page-agent-runtime_agent-panel";
333
+ const BUILTIN_PANEL_STYLE_ID = "page-use-hide-builtin-panel";
334
+ const MASK_THEME_STYLE_ID = "page-use-mask-theme";
335
+ const DEFAULT_PAGE_USE_SETTINGS = {
336
+ showBuiltinPanel: false,
337
+ showHighlights: false,
338
+ showStepDetails: true,
339
+ enableBeforeTask: true,
340
+ enableMask: true,
341
+ customMaskEffect: true,
342
+ voiceEnabled: false,
343
+ voiceRate: 1.1,
344
+ voiceEngineId: "",
345
+ voiceNames: {},
346
+ launcherVisible: null,
347
+ fabPos: null
348
+ };
349
+ /**
350
+ * 调试面板不再提供开关的设置项及其固定值。
351
+ * 复选框已移除,本地存储中曾经改过的值无法在界面上改回,因此读取与更新时一律覆盖为固定值;
352
+ * PageUseSettings 保留这两个字段,避免破坏调用方的类型。
353
+ */
354
+ const LOCKED_SETTINGS = {
355
+ showBuiltinPanel: false,
356
+ customMaskEffect: true
357
+ };
358
+ function storageKey$2(prefix) {
359
+ return `${prefix}-settings`;
360
+ }
361
+ /** 旧字段 voiceName 迁移为浏览器引擎的音色;已有 voiceNames 时以新结构为准,旧字段一律丢弃 */
362
+ function migrate(stored) {
363
+ const { voiceName, ...rest } = stored;
364
+ if (voiceName === void 0 || rest.voiceNames) return rest;
365
+ return {
366
+ ...rest,
367
+ voiceNames: voiceName ? { [BROWSER_ENGINE_ID]: voiceName } : {}
368
+ };
369
+ }
370
+ function load$2(prefix) {
371
+ try {
372
+ const raw = window.localStorage.getItem(storageKey$2(prefix));
373
+ if (!raw) return { ...DEFAULT_PAGE_USE_SETTINGS };
374
+ return {
375
+ ...DEFAULT_PAGE_USE_SETTINGS,
376
+ ...migrate(JSON.parse(raw)),
377
+ ...LOCKED_SETTINGS
378
+ };
379
+ } catch {
380
+ return { ...DEFAULT_PAGE_USE_SETTINGS };
381
+ }
382
+ }
383
+ function save(prefix, settings) {
384
+ try {
385
+ window.localStorage.setItem(storageKey$2(prefix), JSON.stringify(settings));
386
+ } catch {}
387
+ }
388
+ /** 按开关注入/移除一段覆盖样式(用于压制 page-agent 自带的 DOM,这些节点位于宿主 document 中) */
389
+ function toggleOverrideStyle(styleId, css, show) {
390
+ const existing = document.getElementById(styleId);
391
+ if (show) {
392
+ existing?.remove();
393
+ return;
394
+ }
395
+ if (existing) return;
396
+ const style = document.createElement("style");
397
+ style.id = styleId;
398
+ style.textContent = css;
399
+ document.head.appendChild(style);
400
+ }
401
+ /**
402
+ * 把设置同步到页面:
403
+ * - 高亮:page-controller 的 doHighlightElements 内部写死为 true,只能用样式覆盖其高亮容器
404
+ * - 内置面板:Panel 在 statuschange 到 running 时会自行 show(),因此用 !important 样式压制
405
+ * - 蒙层主题:注入覆盖样式;关闭时移除,恢复 page-agent 默认光晕与光标
406
+ */
407
+ function applyToDocument(settings, prev) {
408
+ if (typeof document === "undefined") return;
409
+ if (!prev || prev.showHighlights !== settings.showHighlights) toggleOverrideStyle(HIGHLIGHT_STYLE_ID, `#${HIGHLIGHT_CONTAINER_ID}{display:none !important;}`, settings.showHighlights);
410
+ if (!prev || prev.showBuiltinPanel !== settings.showBuiltinPanel) toggleOverrideStyle(BUILTIN_PANEL_STYLE_ID, `#${BUILTIN_PANEL_ID}{display:none !important;}`, settings.showBuiltinPanel);
411
+ if (!prev || prev.customMaskEffect !== settings.customMaskEffect) toggleOverrideStyle(MASK_THEME_STYLE_ID, PAGE_USE_MASK_THEME_CSS, !settings.customMaskEffect);
412
+ }
413
+ let prefix$2 = DEFAULT_STORAGE_KEY_PREFIX;
414
+ let store$4 = null;
415
+ /** 懒创建:首次访问时读取存储并把设置应用到页面 */
416
+ function ensureStore$2() {
417
+ if (store$4) return store$4;
418
+ const created = createStore(load$2(prefix$2));
419
+ let prev = null;
420
+ const sync = (next) => {
421
+ applyToDocument(next, prev);
422
+ prev = next;
423
+ };
424
+ sync(created.get());
425
+ created.subscribe((next) => {
426
+ save(prefix$2, next);
427
+ sync(next);
428
+ });
429
+ store$4 = created;
430
+ return created;
431
+ }
432
+ /** 切换存储键前缀(createPageUse 时调用);前缀变化时重新读取 */
433
+ function setSettingsStoragePrefix(next) {
434
+ if (next === prefix$2 && store$4) return;
435
+ prefix$2 = next;
436
+ if (store$4) store$4.replace(load$2(prefix$2));
437
+ else ensureStore$2();
438
+ }
439
+ const pageUseSettings = {
440
+ store: {
441
+ get: () => ensureStore$2().get(),
442
+ subscribe: (listener) => ensureStore$2().subscribe(listener)
443
+ },
444
+ get() {
445
+ return ensureStore$2().get();
446
+ },
447
+ /** 固定项(LOCKED_SETTINGS)的修改被忽略 */
448
+ update(patch) {
449
+ ensureStore$2().set({
450
+ ...patch,
451
+ ...LOCKED_SETTINGS
452
+ });
453
+ },
454
+ /** 把浮动入口(及随之定位的面板)恢复到默认角落位置 */
455
+ resetPositions() {
456
+ ensureStore$2().set({ fabPos: null });
457
+ }
458
+ };
459
+
460
+ //#endregion
461
+ //#region src/voice/preview.ts
462
+ /**
463
+ * 音色试听:用当前引擎、音色与语速播报一句固定文本,供设置页选择音色时对比。
464
+ * 不经 speaker 队列:试听是单次、可中止的调用,不参与待播与抢占,也不受本次任务静音影响。
465
+ * 与任务播报互斥:launcher 在任务执行中禁用试听,narration 在任务开始时调用 stopPreview()。
466
+ */
467
+ /** 试听文本 */
468
+ const PREVIEW_TEXT = "您好,我是页面智能体。";
469
+ const store$3 = createStore({ playing: false });
470
+ /** 试听状态(playing:是否正在试听),供设置页切换「试听 / 停止」按钮 */
471
+ const previewStore = store$3;
472
+ /** 当前试听的中止控制;空闲时为 null */
473
+ let controller = null;
474
+ /** 开始试听:先中止上一次试听;引擎 speak 结束(播完、失败或被中止)后复位状态 */
475
+ function startPreview(engine, options) {
476
+ stopPreview();
477
+ if (!engine.isAvailable()) return;
478
+ const current = new AbortController();
479
+ controller = current;
480
+ store$3.set({ playing: true });
481
+ let speaking;
482
+ try {
483
+ speaking = engine.speak(PREVIEW_TEXT, {
484
+ ...options,
485
+ signal: current.signal
486
+ });
487
+ } catch (error) {
488
+ speaking = Promise.reject(error);
489
+ }
490
+ speaking.catch((error) => console.warn("[PageUse] 试听失败", error)).then(() => {
491
+ if (controller !== current) return;
492
+ controller = null;
493
+ store$3.set({ playing: false });
494
+ });
495
+ }
496
+ /** 中止试听;空闲时不触碰引擎(浏览器引擎中止会调用 speechSynthesis.cancel()) */
497
+ function stopPreview() {
498
+ if (!controller) return;
499
+ controller.abort();
500
+ controller = null;
501
+ store$3.set({ playing: false });
502
+ }
503
+
504
+ //#endregion
505
+ //#region src/voice/speaker.ts
506
+ /**
507
+ * 演示播报的播放队列,发声委托给当前语音合成引擎(由 getEngine 提供,可在播报间切换)。
508
+ *
509
+ * 队列只有两部分:当前正在播报的一组文本,以及一条待播的步骤文本。
510
+ * - 步骤播报不打断当前播报,只覆盖待播槽(语音跟不上执行时丢弃中间步骤);
511
+ * - 提问 / 结论先清空再播报;
512
+ * - 每组开始时读取一次引擎并在组内固定使用(splitSentences 与中止对象保持一致);
513
+ * - 每组持有一个 AbortController,清空时中止,引擎据此停止自身的合成与播放;
514
+ * - 每次清空递增代次,旧代次 speak() 的迟到 resolve 一律忽略,避免中止后误触发下一条。
515
+ *
516
+ * 当前引擎不可用时 enqueue / interrupt 静默返回。
517
+ */
518
+ /**
519
+ * @param getSettings 每次调用引擎前读取;参数为当前组使用的引擎,调用方据此取该引擎的音色
520
+ * @param getEngine 每组开始时读取当前生效引擎
521
+ */
522
+ function createSpeaker(getSettings, getEngine = () => browserTtsEngine) {
523
+ let generation = 0;
524
+ /** 当前组剩余的待调用文本;null 表示空闲 */
525
+ let current = null;
526
+ let pending = null;
527
+ /** 当前组的中止控制;空闲时为 null,clear 据此判断是否需要中止引擎 */
528
+ let controller = null;
529
+ /** 当前组使用的引擎;组内不随设置切换 */
530
+ let engine = getEngine();
531
+ function playGroup(sentences) {
532
+ engine = getEngine();
533
+ if (!engine.isAvailable()) return;
534
+ current = engine.splitSentences ? [...sentences] : [sentences.join("")];
535
+ controller = new AbortController();
536
+ playNext();
537
+ }
538
+ function playNext() {
539
+ const text = current?.shift();
540
+ if (text === void 0 || !controller) {
541
+ current = null;
542
+ controller = null;
543
+ if (pending) {
544
+ const next = pending;
545
+ pending = null;
546
+ playGroup(next);
547
+ }
548
+ return;
549
+ }
550
+ const owner = generation;
551
+ const { rate, voiceName } = getSettings(engine);
552
+ let speaking;
553
+ try {
554
+ speaking = engine.speak(text, {
555
+ rate,
556
+ voice: voiceName,
557
+ signal: controller.signal
558
+ });
559
+ } catch (error) {
560
+ speaking = Promise.reject(error);
561
+ }
562
+ speaking.catch((error) => console.warn("[PageUse] 语音播报失败", error)).then(() => {
563
+ if (owner === generation) playNext();
564
+ });
565
+ }
566
+ function clear() {
567
+ generation += 1;
568
+ controller?.abort();
569
+ controller = null;
570
+ current = null;
571
+ pending = null;
572
+ }
573
+ return {
574
+ enqueue(sentences) {
575
+ if (!sentences.length) return;
576
+ if (current !== null) {
577
+ pending = sentences;
578
+ return;
579
+ }
580
+ playGroup(sentences);
581
+ },
582
+ interrupt(sentences) {
583
+ clear();
584
+ if (sentences.length) playGroup(sentences);
585
+ },
586
+ clear
587
+ };
588
+ }
589
+
590
+ //#endregion
591
+ //#region src/voice/speechText.ts
592
+ /**
593
+ * 播报文本清洗(纯函数,无 DOM 依赖):把模型输出的步骤描述 / 结论转成适合语音合成的句子数组。
594
+ * 结果只用于播报,不回写任务结果与面板展示内容。
595
+ */
596
+ /** 单条播报清洗后的字数上限:中文语音约 4 字/秒,240 字约 1 分钟(语速 1 时) */
597
+ const SPEECH_MAX_CHARS = 240;
598
+ /** 超过上限截断时追加的提示语(未挂载调试面板、不知道面板名称时使用) */
599
+ const SPEECH_TRUNCATED_HINT = "详细内容请查看面板";
600
+ /** 截断提示语:有面板名称(调试面板标题)时指明名称,如「详细内容请查看「页面智能体」」 */
601
+ function buildTruncatedHint(panelName) {
602
+ const name = panelName?.trim();
603
+ return name ? `详细内容请查看「${name}」` : SPEECH_TRUNCATED_HINT;
604
+ }
605
+ /**
606
+ * 单次合成的字数上限:Chrome 的在线语音单条超过约 15 秒会被截断,
607
+ * 超过该长度的句子再按逗号等停顿切分(约 4 字/秒 × 12 秒)
608
+ */
609
+ const MAX_UTTERANCE_CHARS = 50;
610
+ /** 句末标点:截断与切句的边界(分号视为句间边界,列表项以分号连接) */
611
+ const SENTENCE_END = "。!?!?;;";
612
+ /** 句内停顿:长句二次切分的边界 */
613
+ const CLAUSE_END = ",、:,:";
614
+ /** 行尾已有这些标点时,拼接下一行不再补分隔符 */
615
+ const TRAILING_PUNCT = /[。!?!?;;,,、::]$/;
616
+ /**
617
+ * 元素编号:page-agent 以 [index] 标注可交互元素,模型常把编号写进步骤描述,
618
+ * 如「点击索引为 23 的菜单」「点击 [23]」「点击元素 23」。
619
+ * 不匹配「编号 / 序号」:结论中的「编号 20260809」等可能是业务数据。
620
+ */
621
+ const ELEMENT_INDEX_PATTERNS = [
622
+ /索引\s*(?:为|是)?\s*\[?\d+\]?\s*(?:号)?\s*(?:的)?/g,
623
+ /元素\s*\[\d+\]\s*(?:的)?/g,
624
+ /元素\s*\d+\s*(?:号)?\s*(?:的)?/g,
625
+ /\[\d+\]/g
626
+ ];
627
+ /** 表格分隔行:| --- | :---: | */
628
+ const TABLE_SEPARATOR = /^\s*\|?\s*:?-{2,}:?\s*(?:\|\s*:?-{2,}:?\s*)*\|?\s*$/;
629
+ /** 表格数据行:首尾为竖线 */
630
+ const TABLE_ROW = /^\s*\|.*\|\s*$/;
631
+ /** 列表项行首:- * + 或 1. 1) 1、(序号后紧跟数字时视为小数,如「2.5 吨」,不当作列表) */
632
+ const LIST_MARKER = /^\s*(?:[-*+]\s+|\d+[.)](?!\d)\s*|\d+、\s*)/;
633
+ /** 行内 Markdown、网址、emoji、元素编号等清理 */
634
+ function cleanInline(text) {
635
+ let s = text;
636
+ for (const pattern of ELEMENT_INDEX_PATTERNS) s = s.replace(pattern, "");
637
+ s = s.replace(/!\[([^\]]*)\]\([^)]*\)/g, "$1").replace(/\[([^\]]+)\]\([^)]*\)/g, "$1").replace(/https?:\/\/[^\s))]+/g, "").replace(/(\*\*|__)(.+?)\1/g, "$2").replace(/\*([^*\n]+)\*/g, "$1").replace(/~~(.+?)~~/g, "$1").replace(/`([^`]*)`/g, "$1").replace(/[*`~]/g, "").replace(/[\p{Extended_Pictographic}\u{FE0F}\u{200D}\u{2713}\u{2717}\u{2718}]/gu, "").replace(/[「」『』【】]/g, "");
638
+ return s.replace(/\s+/g, " ").trim();
639
+ }
640
+ /** 按行处理块级结构:代码块与 HTML 已在调用前移除 */
641
+ function toLines(text) {
642
+ const lines = [];
643
+ for (const raw of text.split(/\r?\n/)) {
644
+ if (TABLE_SEPARATOR.test(raw)) continue;
645
+ if (TABLE_ROW.test(raw)) {
646
+ const cells = raw.split("|").map((cell) => cleanInline(cell)).filter(Boolean);
647
+ if (cells.length) lines.push({
648
+ text: cells.join(","),
649
+ item: true
650
+ });
651
+ continue;
652
+ }
653
+ const item = LIST_MARKER.test(raw);
654
+ const cleaned = cleanInline(raw.replace(/^\s*#{1,6}\s+/, "").replace(/^\s*(?:>\s?)+/, "").replace(LIST_MARKER, ""));
655
+ if (cleaned) lines.push({
656
+ text: cleaned,
657
+ item
658
+ });
659
+ }
660
+ return lines;
661
+ }
662
+ /** 拼接各行:列表项之间用分号,其余行之间补句号;行尾已有标点时不补 */
663
+ function joinLines(lines) {
664
+ let result = "";
665
+ lines.forEach((line, index) => {
666
+ if (index > 0 && !TRAILING_PUNCT.test(result)) result += lines[index - 1].item && line.item ? ";" : "。";
667
+ result += line.text;
668
+ });
669
+ return result.replace(/([。!?!?;;,,、::])[。!?!?;;,,、::\s]+/g, "$1").replace(/^[。!?!?;;,,、::\s]+/, "").trim();
670
+ }
671
+ /** 超过上限时在上限内最后一个句末标点处截断,找不到则硬截断,并追加提示语 */
672
+ function truncate(text, maxChars, hint) {
673
+ if (text.length <= maxChars) return text;
674
+ const head = text.slice(0, maxChars);
675
+ let cut = -1;
676
+ for (let i = head.length - 1; i >= 0; i -= 1) if (SENTENCE_END.includes(head[i])) {
677
+ cut = i;
678
+ break;
679
+ }
680
+ let kept = cut > 0 ? head.slice(0, cut + 1) : head;
681
+ kept = kept.replace(/[;;,,、::]$/, "");
682
+ if (!/[。!?!?]$/.test(kept)) kept += "。";
683
+ return `${kept}${hint}`;
684
+ }
685
+ /** 按句末标点切句;过长的句子再按逗号等停顿切分 */
686
+ function splitSentences(text) {
687
+ const sentences = text.match(new RegExp(`[^${SENTENCE_END}]+[${SENTENCE_END}]*`, "g")) || [];
688
+ const result = [];
689
+ for (const sentence of sentences) {
690
+ if (sentence.length <= MAX_UTTERANCE_CHARS) {
691
+ result.push(sentence);
692
+ continue;
693
+ }
694
+ const clauses = sentence.match(new RegExp(`[^${CLAUSE_END}]+[${CLAUSE_END}]*`, "g")) || [sentence];
695
+ let buffer = "";
696
+ for (const clause of clauses) {
697
+ if (buffer && buffer.length + clause.length > MAX_UTTERANCE_CHARS) {
698
+ result.push(buffer);
699
+ buffer = "";
700
+ }
701
+ buffer += clause;
702
+ }
703
+ if (buffer) result.push(buffer);
704
+ }
705
+ return result.map((s) => s.trim()).filter((s) => /[\p{L}\p{N}]/u.test(s));
706
+ }
707
+ /**
708
+ * 模型文本 → 播报句子数组。清洗后为空时返回空数组(调用方据此跳过播报)。
709
+ * 处理顺序:代码块 / HTML → 表格 → 行内 Markdown 与符号 → 拼行 → 截断 → 切句。
710
+ */
711
+ function toSpeechText(text, options = {}) {
712
+ const maxChars = options.maxChars ?? 240;
713
+ const joined = joinLines(toLines((text || "").replace(/```[\s\S]*?(?:```|$)/g, "\n").replace(/<\/?[a-zA-Z][^>]*>/g, "")));
714
+ if (!joined) return [];
715
+ return splitSentences(truncate(joined, maxChars, options.truncatedHint ?? "详细内容请查看面板"));
716
+ }
717
+
718
+ //#endregion
719
+ //#region src/voice/narration.ts
720
+ /**
721
+ * 演示播报:订阅进度事件,按设置把步骤描述、提问与结论交给 speaker 播报。
722
+ * 只订阅事件,不影响任务执行节奏;对所有任务来源生效。
723
+ * 是否播报 = 设置 voiceEnabled(总开关,持久化)且未处于本次任务静音(narrationMute,运行期)。
724
+ */
725
+ /**
726
+ * 不播报的步骤:滚动、等待没有演示价值;done 由结论播报替代;
727
+ * ask_user 的问题由 question 事件播报;error / retry 为执行出错与重试的伪工具名。
728
+ */
729
+ const SILENT_STEP_TOOLS = /* @__PURE__ */ new Set([
730
+ "scroll",
731
+ "scroll_horizontally",
732
+ "wait",
733
+ "done",
734
+ "ask_user",
735
+ "error",
736
+ "retry"
737
+ ]);
738
+ /**
739
+ * 调试面板名称(PageUseLauncherConfig.title),用于截断提示语「详细内容请查看「名称」」。
740
+ * 由 launcher 挂载时登记、卸载时撤销;未挂载面板时为 null,提示语回退为「详细内容请查看面板」。
741
+ */
742
+ let panelName = null;
743
+ /** launcher 登记面板名称,返回撤销函数(仅撤销自己登记的值) */
744
+ function registerNarrationPanelName(name) {
745
+ panelName = name;
746
+ return () => {
747
+ if (panelName === name) panelName = null;
748
+ };
749
+ }
750
+ /**
751
+ * 本次任务静音(调试面板标题栏按钮):仅运行期,不持久化,不修改 voiceEnabled。
752
+ * 新任务 started 时重置为 false;静音期间跳过的内容在取消静音后不补播。
753
+ */
754
+ const narrationMute = createStore({ muted: false });
755
+ /** 启动播报,返回停止函数(取消订阅并停止播报) */
756
+ function startNarration({ subscribe, engines, speaker: injected }) {
757
+ const available = engines ?? listTtsEngines(void 0);
758
+ const speaker = injected || createSpeaker((engine) => {
759
+ const { voiceRate, voiceNames } = pageUseSettings.get();
760
+ return {
761
+ rate: voiceRate,
762
+ voiceName: voiceNames[engine.id] ?? ""
763
+ };
764
+ }, () => resolveTtsEngine(available, pageUseSettings.get().voiceEngineId));
765
+ const enabled = () => pageUseSettings.get().voiceEnabled && !narrationMute.get().muted;
766
+ const speech = (text) => toSpeechText(text, { truncatedHint: buildTruncatedHint(panelName ?? void 0) });
767
+ const offEvents = subscribe((event) => {
768
+ switch (event.type) {
769
+ case "started":
770
+ narrationMute.set({ muted: false });
771
+ stopPreview();
772
+ speaker.clear();
773
+ break;
774
+ case "step":
775
+ if (!enabled() || SILENT_STEP_TOOLS.has(event.step.tool)) return;
776
+ speaker.enqueue(speech(event.step.goal));
777
+ break;
778
+ case "question":
779
+ if (enabled()) speaker.interrupt(speech(event.question));
780
+ break;
781
+ case "finished": if (event.result.status === "stopped" || !enabled()) speaker.clear();
782
+ else speaker.interrupt(speech(event.result.summary));
783
+ }
784
+ });
785
+ let prev = pageUseSettings.get();
786
+ const offSettings = pageUseSettings.store.subscribe((settings) => {
787
+ if (prev.voiceEnabled && !settings.voiceEnabled || prev.voiceEngineId !== settings.voiceEngineId) speaker.clear();
788
+ prev = settings;
789
+ });
790
+ const offMute = narrationMute.subscribe(({ muted }) => {
791
+ if (muted) speaker.clear();
792
+ });
793
+ return () => {
794
+ offEvents();
795
+ offSettings();
796
+ offMute();
797
+ speaker.clear();
798
+ };
799
+ }
800
+
801
+ //#endregion
5
802
  //#region src/core/siteMap.ts
6
803
  /** 去掉 `#`、前导 `/` 与查询串,得到用于比较的路径 */
7
804
  function normalizePath(path) {
@@ -52,8 +849,17 @@ function findMenuByPath(path, menus) {
52
849
  //#region src/core/pageInstructions.ts
53
850
  /** 未提供 layoutHint 时的通用页面结构描述 */
54
851
  const DEFAULT_LAYOUT_HINT = ["- 页面通常由导航菜单与内容区组成;内容区顶部若有面包屑或标题,可用来确认当前位置。", "- 分组菜单点击后展开/收起下级;叶子菜单点击后进入页面。"].join("\n");
55
- /** 全局系统提示:站点结构 + 交互约定 + 安全边界 */
56
- function buildSystemInstruction(options) {
852
+ /**
853
+ * 演示播报开启时追加的约束:page-agent 默认要求 next_goal 写「目标 + 动作」,模型常带元素编号,
854
+ * 念出来生硬;这里只约束步骤描述,不改变 done 的 text(结论会回传给调用方)。
855
+ */
856
+ const NARRATION_RULES = [
857
+ "## 语音播报",
858
+ "- 每一步的 next_goal 会被语音播报给观众:写成一句简短的中文,描述这一步要做什么,如「打开隐患台账页面」;不要包含元素编号、索引或方括号数字。",
859
+ "- 该要求只针对 next_goal;done 的 text 仍按原要求书写。"
860
+ ].join("\n");
861
+ /** 全局系统提示:站点结构 + 交互约定 + 安全边界(+ 播报约束) */
862
+ function buildSystemInstruction(options, flags = {}) {
57
863
  const urlLabel = options.routerMode === "hash" ? "URL hash" : "URL 路径";
58
864
  const siteMap = buildSiteMapText(options.getMenus(), options.routerMode);
59
865
  return [
@@ -76,7 +882,8 @@ function buildSystemInstruction(options) {
76
882
  "",
77
883
  "## 安全边界",
78
884
  "- 未经用户明确要求,不得执行删除、提交、审批、发布、批量修改等不可逆操作;不确定时用 ask_user 询问。",
79
- "- 任务完成后调用 done,用一句话说明当前所在页面及已完成的动作;无法完成时说明卡在哪一步。"
885
+ "- 任务完成后调用 done,用一句话说明当前所在页面及已完成的动作;无法完成时说明卡在哪一步。",
886
+ ...flags.narration ? ["", NARRATION_RULES] : []
80
887
  ].join("\n");
81
888
  }
82
889
  /**
@@ -115,7 +922,7 @@ const STEP_DELAY_SECONDS = .6;
115
922
  function newSessionId() {
116
923
  return typeof crypto !== "undefined" && crypto.randomUUID?.() || `page-use-${Date.now()}`;
117
924
  }
118
- function createPageUseAgent({ options, enableMask, shouldRunBeforeTask, onTaskFinished }) {
925
+ function createPageUseAgent({ options, enableMask, narration = false, shouldRunBeforeTask, onTaskFinished }) {
119
926
  const { llm } = options;
120
927
  if (!llm.baseURL) throw new Error("[PageUse] 未配置 llm.baseURL");
121
928
  let sessionId = newSessionId();
@@ -150,7 +957,7 @@ function createPageUseAgent({ options, enableMask, shouldRunBeforeTask, onTaskFi
150
957
  enableMask,
151
958
  experimentalScriptExecutionTool: false,
152
959
  instructions: {
153
- system: buildSystemInstruction(options),
960
+ system: buildSystemInstruction(options, { narration }),
154
961
  getPageInstructions: () => buildPageInstruction(options)
155
962
  },
156
963
  transformPageContent: (content) => {
@@ -167,134 +974,11 @@ function createPageUseAgent({ options, enableMask, shouldRunBeforeTask, onTaskFi
167
974
  }
168
975
  await options.pageAgentOptions.onBeforeTask?.(...args);
169
976
  },
170
- onAfterTask: async (...args) => {
171
- onTaskFinished?.(args[1]);
172
- await options.pageAgentOptions.onAfterTask?.(...args);
173
- }
174
- });
175
- }
176
-
177
- //#endregion
178
- //#region src/core/options.ts
179
- /** 默认模型,仅为示例取值;接入其它端点时应通过 llm.model 显式指定支持 function calling 的模型 */
180
- const DEFAULT_MODEL = "deepseek-chat";
181
- /** 单次任务最大步数;每步一次 LLM 调用,防止死循环消耗 token */
182
- const DEFAULT_MAX_STEPS = 20;
183
- /** 默认会话头:OpenCode Go 要求每个会话带稳定的 x-opencode-session 用于路由与提示缓存 */
184
- const DEFAULT_SESSION_HEADER = "x-opencode-session";
185
- /** localStorage / sessionStorage 键前缀,可通过 storageKeyPrefix 覆盖 */
186
- const DEFAULT_STORAGE_KEY_PREFIX = "tz-page-use";
187
- /** 相对路径补齐为当前 origin 的绝对地址并去掉末尾 /;无法解析时返回空串 */
188
- function toAbsoluteUrl(input) {
189
- const base = typeof window !== "undefined" ? window.location.origin : void 0;
190
- try {
191
- return new URL(input, base).toString().replace(/\/+$/, "");
192
- } catch {
193
- return "";
194
- }
195
- }
196
- /** 按路由模式从 location 读取当前路径 */
197
- function readLocationPath(mode) {
198
- if (typeof window === "undefined") return "/";
199
- if (mode === "hash") return window.location.hash.replace(/^#/, "") || "/";
200
- return `${window.location.pathname}${window.location.search}`;
201
- }
202
- function resolveSessionHeader(value) {
203
- if (value === false) return "";
204
- return (value ?? "").trim() || "x-opencode-session";
205
- }
206
- function normalizeOptions(options) {
207
- const { llm } = options;
208
- const rawBase = (llm.baseURL || "").trim();
209
- const routerMode = options.routerMode ?? "hash";
210
- return {
211
- llm: {
212
- baseURL: rawBase ? toAbsoluteUrl(rawBase) : "",
213
- apiKey: (llm.apiKey || "").trim(),
214
- model: (llm.model || "").trim() || "deepseek-chat",
215
- maxSteps: llm.maxSteps && llm.maxSteps > 0 ? llm.maxSteps : 20,
216
- disableNamedToolChoice: Boolean(llm.disableNamedToolChoice),
217
- extraBody: llm.extraBody && typeof llm.extraBody === "object" ? llm.extraBody : {},
218
- sessionHeader: resolveSessionHeader(llm.sessionHeader)
219
- },
220
- appName: options.appName,
221
- getMenus: options.getMenus ?? (() => void 0),
222
- routerMode,
223
- getCurrentPath: options.getCurrentPath ?? (() => readLocationPath(routerMode)),
224
- resolveRouteName: options.resolveRouteName,
225
- routeHints: options.routeHints ?? {},
226
- layoutHint: options.layoutHint,
227
- beforeTask: options.beforeTask,
228
- beforeTaskLabel: options.beforeTaskLabel || "任务开始前执行宿主预处理",
229
- storageKeyPrefix: options.storageKeyPrefix || "tz-page-use",
230
- pageAgentOptions: options.pageAgentOptions ?? {},
231
- exposeGlobal: options.exposeGlobal !== false
232
- };
233
- }
234
- /** URL 查询参数:?pageUseDebug=1 打开调试面板,?pageUseDebug=0 关闭;结果记入 sessionStorage */
235
- const LAUNCHER_QUERY_KEY = "pageUseDebug";
236
- /** 从 location 读取调试开关(hash 路由的查询参数在 # 之后,两处都查) */
237
- function readLauncherQuery() {
238
- const fromSearch = new URLSearchParams(window.location.search).get(LAUNCHER_QUERY_KEY);
239
- if (fromSearch !== null) return fromSearch;
240
- const hashQuery = window.location.hash.split("?")[1];
241
- return hashQuery ? new URLSearchParams(hashQuery).get(LAUNCHER_QUERY_KEY) : null;
242
- }
243
- /**
244
- * 调试面板是否显示。优先级:URL 参数(并记入本会话)> 会话记录 > fallback。
245
- * fallback 由宿主决定(例如「开发环境显示,生产环境隐藏」)。
246
- */
247
- function readLauncherSwitch(fallback, storageKeyPrefix = DEFAULT_STORAGE_KEY_PREFIX) {
248
- if (typeof window === "undefined") return fallback;
249
- const sessionKey = `${storageKeyPrefix}-debug`;
250
- try {
251
- const query = readLauncherQuery();
252
- if (query !== null) {
253
- const enabled = query === "1" || query === "true";
254
- window.sessionStorage.setItem(sessionKey, enabled ? "1" : "0");
255
- return enabled;
256
- }
257
- const session = window.sessionStorage.getItem(sessionKey);
258
- if (session !== null) return session === "1";
259
- } catch {}
260
- return fallback;
261
- }
262
-
263
- //#endregion
264
- //#region src/core/store.ts
265
- function createStore(initial) {
266
- let state = initial;
267
- const listeners = /* @__PURE__ */ new Set();
268
- const notify = () => {
269
- for (const listener of listeners) try {
270
- listener(state);
271
- } catch (err) {
272
- console.warn("[PageUse] store 订阅回调异常", err);
273
- }
274
- };
275
- return {
276
- get: () => state,
277
- set(patch) {
278
- const partial = typeof patch === "function" ? patch(state) : patch;
279
- if (Object.keys(partial).every((key) => Object.is(state[key], partial[key]))) return;
280
- state = {
281
- ...state,
282
- ...partial
283
- };
284
- notify();
285
- },
286
- replace(next) {
287
- if (Object.is(next, state)) return;
288
- state = next;
289
- notify();
290
- },
291
- subscribe(listener) {
292
- listeners.add(listener);
293
- return () => {
294
- listeners.delete(listener);
295
- };
977
+ onAfterTask: async (...args) => {
978
+ onTaskFinished?.(args[1]);
979
+ await options.pageAgentOptions.onAfterTask?.(...args);
296
980
  }
297
- };
981
+ });
298
982
  }
299
983
 
300
984
  //#endregion
@@ -323,7 +1007,7 @@ function ensureStore$1() {
323
1007
  if (!store$2) store$2 = createStore({ entries: load$1(prefix$1) });
324
1008
  return store$2;
325
1009
  }
326
- function persist(entries) {
1010
+ function persist$1(entries) {
327
1011
  ensureStore$1().set({ entries });
328
1012
  try {
329
1013
  window.localStorage.setItem(storageKey$1(prefix$1), JSON.stringify(entries));
@@ -344,206 +1028,100 @@ const pageUseHistory = {
344
1028
  return ensureStore$1().get().entries;
345
1029
  },
346
1030
  add(entry) {
347
- persist([{
1031
+ persist$1([{
348
1032
  id: `${entry.at}-${Math.random().toString(36).slice(2, 8)}`,
349
1033
  ...entry
350
1034
  }, ...ensureStore$1().get().entries].slice(0, MAX_ENTRIES));
351
1035
  },
352
1036
  remove(id) {
353
- persist(ensureStore$1().get().entries.filter((item) => item.id !== id));
1037
+ persist$1(ensureStore$1().get().entries.filter((item) => item.id !== id));
354
1038
  },
355
1039
  clear() {
356
- persist([]);
1040
+ persist$1([]);
357
1041
  }
358
1042
  };
359
1043
 
360
1044
  //#endregion
361
- //#region src/core/maskTheme.ts
1045
+ //#region src/core/presetStore.ts
362
1046
  /**
363
- * 运行期蒙层与模拟光标的定制主题(覆盖 page-agent SimulatorMask 的默认视觉)。
364
- *
365
- * page-agent 的 CSS Module 类名带哈希(如 `_cursor_1dgwb_2`),随版本变化,
366
- * 因此这里只依赖稳定的结构:
367
- * - 蒙层根节点 id:`page-agent-runtime_simulator-mask`
368
- * - 子节点顺序:ai-motion 光晕层在前(初始化失败时不存在),光标节点恒为最后一个子节点
369
- * - 光标子节点顺序:1 点击波纹 / 2 箭头填充 / 3 箭头描边
370
- * - 点击态类名包含 `clicking` 子串
371
- * 升级 page-agent 后若光标样式失效,先核对 SimulatorMask 的 #createCursor 结构。
1047
+ * Page Use 快捷指令的用户维护数据(localStorage,懒创建单例)。
1048
+ * - custom:用户自定义指令,新增排在最前
1049
+ * - hidden:被用户删除(隐藏)的来源指令(菜单生成 / 内置 / 宿主 presets / scenarios)的 task 文本
1050
+ * 来源指令本身不落盘;宿主修改 task 后旧的隐藏记录自然失效,不主动清理。
1051
+ * 存储键为 `${storageKeyPrefix}-presets`。
372
1052
  */
373
- /** 蒙层根节点 id(@page-agent/page-controller 内部常量) */
374
- const SIMULATOR_MASK_ID = "page-agent-runtime_simulator-mask";
375
- /** 主题色:主色 #0879ff 同系的蓝 → 青 → 靛渐变 */
376
- const PAGE_USE_MASK_COLORS = {
377
- primary: "#0879ff",
378
- cyan: "#36cfc9",
379
- indigo: "#597ef7",
380
- /** 误触拦截时的警示色 */
381
- warning: "#fa8c16"
1053
+ const EMPTY_STATE = {
1054
+ custom: [],
1055
+ hidden: []
382
1056
  };
383
- const CURSOR_ARROW_MASK = `url("data:image/svg+xml,${encodeURIComponent(`<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><path d='M 15 42 L 15 36.99 Q 15 31.99 23.7 31.99 L 28.05 31.99 Q 32.41 31.99 32.41 21.99 L 32.41 17 Q 32.41 12 41.09 16.95 L 76.31 37.05 Q 85 42 76.31 46.95 L 41.09 67.05 Q 32.41 72 32.41 62.01 L 32.41 57.01 Q 32.41 52.01 23.7 52.01 L 19.35 52.01 Q 15 52.01 15 47.01 Z' fill='#000'/></svg>`)}")`;
384
- const MASK = `#${SIMULATOR_MASK_ID}`;
385
- /** 光标节点:蒙层的最后一个子节点 */
386
- const CURSOR = `${MASK} > :last-child`;
387
- const { primary, cyan, indigo } = PAGE_USE_MASK_COLORS;
388
- const PAGE_USE_MASK_THEME_CSS = `
389
- /* 隐藏 ai-motion 默认光晕(光标之外的子节点),光晕改由 <tz-page-use-glow> 渲染 */
390
- ${MASK} > :not(:last-child) { display: none !important; }
391
- /* 蒙层本身保持透明,仅负责拦截点击;真实鼠标由 wait 转圈改为禁止符号,明确「此时不可操作」而非「页面卡死」 */
392
- ${MASK} { cursor: not-allowed !important; }
393
-
394
- /* 光标整体外发光 */
395
- ${CURSOR} {
396
- filter: drop-shadow(0 0 6px ${primary}8c) drop-shadow(0 2px 3px rgba(0, 0, 0, 0.25));
397
- }
398
- /* 光标尖端(节点原点)的呼吸光圈 */
399
- ${CURSOR}::before {
400
- content: '';
401
- position: absolute;
402
- left: -16px;
403
- top: -16px;
404
- width: 32px;
405
- height: 32px;
406
- border-radius: 50%;
407
- background: radial-gradient(circle, ${cyan}66 0%, ${primary}26 55%, transparent 72%);
408
- animation: page-use-cursor-halo 1.6s ease-in-out infinite;
409
- pointer-events: none;
410
- }
411
- /* 箭头描边:主题渐变 */
412
- ${CURSOR} > :nth-child(3) {
413
- background: linear-gradient(45deg, ${primary}, ${cyan}) !important;
414
- }
415
- /* 箭头填充:用同形遮罩替换原白色 SVG 背景,改为浅色渐变 */
416
- ${CURSOR} > :nth-child(2) {
417
- background: linear-gradient(135deg, #ffffff 30%, #e6f4ff) !important;
418
- -webkit-mask: ${CURSOR_ARROW_MASK} center / 100% 100% no-repeat;
419
- mask: ${CURSOR_ARROW_MASK} center / 100% 100% no-repeat;
420
- }
421
- /* 点击波纹:外环改主题色 */
422
- ${CURSOR} > :nth-child(1)::after {
423
- border-color: ${cyan} !important;
424
- border-width: 3px !important;
425
- }
426
- /* 点击波纹:追加一层实心扩散,增强点击反馈 */
427
- ${CURSOR} > :nth-child(1)::before {
428
- content: '';
429
- position: absolute;
430
- inset: 25%;
431
- border-radius: 50%;
432
- background: radial-gradient(circle, ${indigo}80, transparent 70%);
433
- opacity: 0;
434
- }
435
- ${CURSOR}[class*='clicking'] > :nth-child(1)::before {
436
- animation: page-use-cursor-press 420ms ease-out forwards;
437
- }
438
-
439
- @keyframes page-use-cursor-halo {
440
- 0%, 100% { transform: scale(0.85); opacity: 0.7; }
441
- 50% { transform: scale(1.25); opacity: 1; }
442
- }
443
- @keyframes page-use-cursor-press {
444
- 0% { transform: scale(0.4); opacity: 1; }
445
- 100% { transform: scale(2.4); opacity: 0; }
1057
+ function storageKey(prefix) {
1058
+ return `${prefix}-presets`;
446
1059
  }
447
- @media (prefers-reduced-motion: reduce) {
448
- ${CURSOR}::before { animation: none; }
1060
+ function createId() {
1061
+ return `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
449
1062
  }
450
- `;
451
-
452
- //#endregion
453
- //#region src/core/settings.ts
454
- /**
455
- * Page Use 本地设置(localStorage 持久化,单例)。
456
- * 含 page-agent 内置面板/元素编号高亮开关、蒙层与光晕、调试入口拖拽位置。
457
- * 存储键为 `${storageKeyPrefix}-settings`。
458
- */
459
- /** page-agent 元素高亮容器 id(page-controller 内部常量,随版本可能变化) */
460
- const HIGHLIGHT_CONTAINER_ID = "playwright-highlight-container";
461
- const HIGHLIGHT_STYLE_ID = "page-use-hide-highlights";
462
- /** page-agent 内置面板根节点 id(@page-agent/ui 内部常量) */
463
- const BUILTIN_PANEL_ID = "page-agent-runtime_agent-panel";
464
- const BUILTIN_PANEL_STYLE_ID = "page-use-hide-builtin-panel";
465
- const MASK_THEME_STYLE_ID = "page-use-mask-theme";
466
- const DEFAULT_PAGE_USE_SETTINGS = {
467
- showBuiltinPanel: false,
468
- showHighlights: false,
469
- showStepDetails: true,
470
- enableBeforeTask: true,
471
- enableMask: true,
472
- customMaskEffect: true,
473
- fabPos: null
474
- };
475
- function storageKey(prefix) {
476
- return `${prefix}-settings`;
1063
+ /** 结构校验:丢弃缺少字符串 label / task 的项,缺 id 时补生成;hidden 只保留字符串并去重 */
1064
+ function sanitize(raw) {
1065
+ if (!raw || typeof raw !== "object") return { ...EMPTY_STATE };
1066
+ const { custom, hidden } = raw;
1067
+ return {
1068
+ custom: (Array.isArray(custom) ? custom : []).flatMap((item) => {
1069
+ if (!item || typeof item !== "object") return [];
1070
+ const { id, label, task } = item;
1071
+ if (typeof label !== "string" || typeof task !== "string") return [];
1072
+ return [{
1073
+ id: typeof id === "string" && id ? id : createId(),
1074
+ label,
1075
+ task
1076
+ }];
1077
+ }),
1078
+ hidden: Array.isArray(hidden) ? [...new Set(hidden.filter((item) => typeof item === "string"))] : []
1079
+ };
477
1080
  }
478
1081
  function load(prefix) {
479
1082
  try {
480
1083
  const raw = window.localStorage.getItem(storageKey(prefix));
481
- if (!raw) return { ...DEFAULT_PAGE_USE_SETTINGS };
482
- return {
483
- ...DEFAULT_PAGE_USE_SETTINGS,
484
- ...JSON.parse(raw)
485
- };
1084
+ return raw ? sanitize(JSON.parse(raw)) : { ...EMPTY_STATE };
486
1085
  } catch {
487
- return { ...DEFAULT_PAGE_USE_SETTINGS };
1086
+ return { ...EMPTY_STATE };
488
1087
  }
489
1088
  }
490
- function save(prefix, settings) {
491
- try {
492
- window.localStorage.setItem(storageKey(prefix), JSON.stringify(settings));
493
- } catch {}
494
- }
495
- /** 按开关注入/移除一段覆盖样式(用于压制 page-agent 自带的 DOM,这些节点位于宿主 document 中) */
496
- function toggleOverrideStyle(styleId, css, show) {
497
- const existing = document.getElementById(styleId);
498
- if (show) {
499
- existing?.remove();
500
- return;
501
- }
502
- if (existing) return;
503
- const style = document.createElement("style");
504
- style.id = styleId;
505
- style.textContent = css;
506
- document.head.appendChild(style);
507
- }
508
- /**
509
- * 把设置同步到页面:
510
- * - 高亮:page-controller 的 doHighlightElements 内部写死为 true,只能用样式覆盖其高亮容器
511
- * - 内置面板:Panel 在 statuschange 到 running 时会自行 show(),因此用 !important 样式压制
512
- * - 蒙层主题:注入覆盖样式;关闭时移除,恢复 page-agent 默认光晕与光标
513
- */
514
- function applyToDocument(settings, prev) {
515
- if (typeof document === "undefined") return;
516
- if (!prev || prev.showHighlights !== settings.showHighlights) toggleOverrideStyle(HIGHLIGHT_STYLE_ID, `#${HIGHLIGHT_CONTAINER_ID}{display:none !important;}`, settings.showHighlights);
517
- if (!prev || prev.showBuiltinPanel !== settings.showBuiltinPanel) toggleOverrideStyle(BUILTIN_PANEL_STYLE_ID, `#${BUILTIN_PANEL_ID}{display:none !important;}`, settings.showBuiltinPanel);
518
- if (!prev || prev.customMaskEffect !== settings.customMaskEffect) toggleOverrideStyle(MASK_THEME_STYLE_ID, PAGE_USE_MASK_THEME_CSS, !settings.customMaskEffect);
519
- }
520
1089
  let prefix = DEFAULT_STORAGE_KEY_PREFIX;
521
1090
  let store$1 = null;
522
- /** 懒创建:首次访问时读取存储并把设置应用到页面 */
523
1091
  function ensureStore() {
524
- if (store$1) return store$1;
525
- const created = createStore(load(prefix));
526
- let prev = null;
527
- const sync = (next) => {
528
- applyToDocument(next, prev);
529
- prev = next;
1092
+ if (!store$1) store$1 = createStore(load(prefix));
1093
+ return store$1;
1094
+ }
1095
+ function persist(patch) {
1096
+ const current = ensureStore();
1097
+ current.set(patch);
1098
+ try {
1099
+ window.localStorage.setItem(storageKey(prefix), JSON.stringify(current.get()));
1100
+ } catch {}
1101
+ }
1102
+ /** 必填与重复校验;excludeId 为编辑中的自身,与自身相同不算重复 */
1103
+ function validate(preset, excludeId) {
1104
+ if (!preset.label.trim()) return {
1105
+ ok: false,
1106
+ reason: "label-empty"
530
1107
  };
531
- sync(created.get());
532
- created.subscribe((next) => {
533
- save(prefix, next);
534
- sync(next);
535
- });
536
- store$1 = created;
537
- return created;
1108
+ const task = preset.task.trim();
1109
+ if (!task) return {
1110
+ ok: false,
1111
+ reason: "task-empty"
1112
+ };
1113
+ return ensureStore().get().custom.some((item) => item.id !== excludeId && item.task.trim() === task) ? {
1114
+ ok: false,
1115
+ reason: "duplicate"
1116
+ } : { ok: true };
538
1117
  }
539
1118
  /** 切换存储键前缀(createPageUse 时调用);前缀变化时重新读取 */
540
- function setSettingsStoragePrefix(next) {
1119
+ function setPresetsStoragePrefix(next) {
541
1120
  if (next === prefix && store$1) return;
542
1121
  prefix = next;
543
- if (store$1) store$1.replace(load(prefix));
544
- else ensureStore();
1122
+ ensureStore().replace(load(prefix));
545
1123
  }
546
- const pageUseSettings = {
1124
+ const pageUsePresets = {
547
1125
  store: {
548
1126
  get: () => ensureStore().get(),
549
1127
  subscribe: (listener) => ensureStore().subscribe(listener)
@@ -551,12 +1129,45 @@ const pageUseSettings = {
551
1129
  get() {
552
1130
  return ensureStore().get();
553
1131
  },
554
- update(patch) {
555
- ensureStore().set(patch);
1132
+ /** 新增自定义指令,排在最前;label / task 去除首尾空白后保存 */
1133
+ add(preset) {
1134
+ const result = validate(preset);
1135
+ if (!result.ok) return result;
1136
+ persist({ custom: [{
1137
+ id: createId(),
1138
+ label: preset.label.trim(),
1139
+ task: preset.task.trim()
1140
+ }, ...ensureStore().get().custom] });
1141
+ return result;
556
1142
  },
557
- /** 把浮动入口(及随之定位的面板)恢复到默认角落位置 */
558
- resetPositions() {
559
- ensureStore().set({ fabPos: null });
1143
+ /** 编辑自定义指令,位置不变 */
1144
+ update(id, preset) {
1145
+ const { custom } = ensureStore().get();
1146
+ if (!custom.some((item) => item.id === id)) return {
1147
+ ok: false,
1148
+ reason: "not-found"
1149
+ };
1150
+ const result = validate(preset, id);
1151
+ if (!result.ok) return result;
1152
+ persist({ custom: custom.map((item) => item.id === id ? {
1153
+ id,
1154
+ label: preset.label.trim(),
1155
+ task: preset.task.trim()
1156
+ } : item) });
1157
+ return result;
1158
+ },
1159
+ remove(id) {
1160
+ persist({ custom: ensureStore().get().custom.filter((item) => item.id !== id) });
1161
+ },
1162
+ /** 隐藏一条来源指令(以 task 文本为标识);已隐藏时不重复记录 */
1163
+ hide(task) {
1164
+ const { hidden } = ensureStore().get();
1165
+ if (hidden.includes(task)) return;
1166
+ persist({ hidden: [...hidden, task] });
1167
+ },
1168
+ /** 恢复全部被隐藏的来源指令(含已失效的记录);不影响自定义指令 */
1169
+ restoreHidden() {
1170
+ persist({ hidden: [] });
560
1171
  }
561
1172
  };
562
1173
 
@@ -668,6 +1279,10 @@ let everConfigured = false;
668
1279
  let agent = null;
669
1280
  /** 创建实例时的蒙层设置;与当前设置不一致时须重建 */
670
1281
  let agentMaskFlag = null;
1282
+ /** 创建实例时的播报设置:系统提示在构造时生成,播报开关变化后须重建才能追加 / 移除步骤描述约束 */
1283
+ let agentVoiceFlag = null;
1284
+ /** 演示播报的停止函数;配置期间运行,解绑 / 重新配置时停止 */
1285
+ let stopNarration = null;
671
1286
  /** 已挂载且可作答的调试面板数量;>0 时未指定 onAskUser 的提问交给面板 */
672
1287
  let questionUiCount = 0;
673
1288
  /** 当前任务的调用方选项(提问回调、进度回调) */
@@ -752,20 +1367,25 @@ function handleAskUser(question, signal) {
752
1367
  }, { once: true });
753
1368
  });
754
1369
  }
755
- /** 懒创建单例;蒙层设置变化时重建(enableMask 属构造期选项) */
1370
+ /**
1371
+ * 懒创建单例;蒙层或播报设置变化时重建(enableMask 属构造期选项,系统提示在构造时生成)。
1372
+ * 在任务开始时调用,因此执行中切换开关只影响下一次任务。
1373
+ */
756
1374
  function ensureAgent(options) {
757
1375
  const settings = pageUseSettings.get();
758
- if (agent && !agent.disposed && agentMaskFlag === settings.enableMask) return agent;
1376
+ if (agent && !agent.disposed && agentMaskFlag === settings.enableMask && agentVoiceFlag === settings.voiceEnabled) return agent;
759
1377
  if (agent && !agent.disposed) agent.dispose();
760
1378
  const created = createPageUseAgent({
761
1379
  options,
762
1380
  enableMask: settings.enableMask,
1381
+ narration: settings.voiceEnabled,
763
1382
  shouldRunBeforeTask: () => pageUseSettings.get().enableBeforeTask
764
1383
  });
765
1384
  bindAgentEvents(created);
766
1385
  created.onAskUser = (question, askOptions) => handleAskUser(question, askOptions?.signal);
767
1386
  agent = created;
768
1387
  agentMaskFlag = settings.enableMask;
1388
+ agentVoiceFlag = settings.voiceEnabled;
769
1389
  store.set({ maskEnabled: settings.enableMask });
770
1390
  return created;
771
1391
  }
@@ -803,10 +1423,18 @@ function configurePageUse(options) {
803
1423
  everConfigured = true;
804
1424
  setSettingsStoragePrefix(options.storageKeyPrefix);
805
1425
  setHistoryStoragePrefix(options.storageKeyPrefix);
1426
+ setPresetsStoragePrefix(options.storageKeyPrefix);
806
1427
  store.set({ ready: Boolean(options.llm.baseURL) });
1428
+ stopNarration?.();
1429
+ stopNarration = startNarration({
1430
+ subscribe: subscribePageUse,
1431
+ engines: options.ttsEngines
1432
+ });
807
1433
  return () => {
808
1434
  if (config !== options) return;
809
1435
  config = null;
1436
+ stopNarration?.();
1437
+ stopNarration = null;
810
1438
  disposeAgent();
811
1439
  store.set({ ready: false });
812
1440
  };
@@ -969,6 +1597,7 @@ function disposeAgent() {
969
1597
  agent?.dispose();
970
1598
  agent = null;
971
1599
  agentMaskFlag = null;
1600
+ agentVoiceFlag = null;
972
1601
  activeOptions = null;
973
1602
  store.set({
974
1603
  maskEnabled: false,
@@ -998,6 +1627,39 @@ const pageUseCapability = {
998
1627
  subscribe: subscribePageUse
999
1628
  };
1000
1629
 
1630
+ //#endregion
1631
+ //#region src/ui/launcherControl.ts
1632
+ /**
1633
+ * 调试入口的显示控制:供业务系统通过模块导出或全局对象调出 / 隐藏调试面板。
1634
+ * 显示状态写入设置 launcherVisible(localStorage 持久化),刷新后保持;
1635
+ * 不依赖 lit,全局桥接引用本模块不会引入 UI 组件。
1636
+ */
1637
+ /** 已挂载 launcher 登记的「展开面板」回调;未挂载时为空 */
1638
+ const openers = /* @__PURE__ */ new Set();
1639
+ /** launcher 挂载时登记展开回调,返回注销函数 */
1640
+ function registerLauncherOpener(open) {
1641
+ openers.add(open);
1642
+ return () => {
1643
+ openers.delete(open);
1644
+ };
1645
+ }
1646
+ /**
1647
+ * 显示调试入口并展开面板,状态持久化。
1648
+ * 宿主未挂载调试面板时只保存状态并警告,之后挂载的面板按该状态显示。
1649
+ */
1650
+ function showPageUseLauncher() {
1651
+ pageUseSettings.update({ launcherVisible: true });
1652
+ if (!openers.size) {
1653
+ console.warn("[PageUse] 调试面板未挂载,显示状态已保存,挂载后生效(mountPageUseUI 需传入 launcher)");
1654
+ return;
1655
+ }
1656
+ openers.forEach((open) => open());
1657
+ }
1658
+ /** 隐藏调试入口(面板与浮动入口),状态持久化;效果与面板标题栏的关闭按钮相同 */
1659
+ function hidePageUseLauncher() {
1660
+ pageUseSettings.update({ launcherVisible: false });
1661
+ }
1662
+
1001
1663
  //#endregion
1002
1664
  //#region src/capability/tool.ts
1003
1665
  /** 工具名;对接方在 system 提示中引用时保持一致 */
@@ -1090,7 +1752,9 @@ function installPageUseGlobal() {
1090
1752
  const api = {
1091
1753
  ...pageUseCapability,
1092
1754
  tool: PAGE_USE_TOOL,
1093
- runTool: runPageUseTool
1755
+ runTool: runPageUseTool,
1756
+ showLauncher: showPageUseLauncher,
1757
+ hideLauncher: hidePageUseLauncher
1094
1758
  };
1095
1759
  window[PAGE_USE_GLOBAL_KEY] = api;
1096
1760
  return () => {
@@ -1392,6 +2056,10 @@ var PageUseGlowElement = class extends LitElement {
1392
2056
  }
1393
2057
  };
1394
2058
 
2059
+ //#endregion
2060
+ //#region package.json
2061
+ var version = "0.0.5";
2062
+
1395
2063
  //#endregion
1396
2064
  //#region src/ui/drag.ts
1397
2065
  /** 判定为拖拽而非点击的最小位移(px) */
@@ -1416,6 +2084,7 @@ function createDrag(options) {
1416
2084
  if (options.skipInteractive !== false) {
1417
2085
  if (event.composedPath()[0]?.closest?.("button, input, textarea, select, a, label, [data-no-drag]")) return;
1418
2086
  }
2087
+ const tapTarget = event.composedPath()[0];
1419
2088
  const handle = event.currentTarget;
1420
2089
  const rect = el.getBoundingClientRect();
1421
2090
  const startX = event.clientX;
@@ -1441,7 +2110,7 @@ function createDrag(options) {
1441
2110
  try {
1442
2111
  handle.releasePointerCapture(event.pointerId);
1443
2112
  } catch {}
1444
- if (!moved) options.onTap?.();
2113
+ if (!moved) options.onTap?.(tapTarget);
1445
2114
  };
1446
2115
  handle.setPointerCapture(event.pointerId);
1447
2116
  handle.addEventListener("pointermove", onMove);
@@ -1461,6 +2130,89 @@ function createDrag(options) {
1461
2130
  };
1462
2131
  }
1463
2132
 
2133
+ //#endregion
2134
+ //#region src/ui/formatTokens.ts
2135
+ /**
2136
+ * 标题栏 token 数格式化:< 1000 显示原值;否则以 k(千)/ M(百万)为单位,
2137
+ * 保留 1 位小数并去掉末尾的 .0,例如 1234 → 1.2k、1500000 → 1.5M。
2138
+ */
2139
+ /** 单位由小到大;四舍五入后达到下一档的倍数时进位到下一档(避免出现 1000k) */
2140
+ const UNITS = [{
2141
+ value: 1e3,
2142
+ suffix: "k"
2143
+ }, {
2144
+ value: 1e6,
2145
+ suffix: "M"
2146
+ }];
2147
+ function formatTokens(n) {
2148
+ if (n < 1e3) return String(n);
2149
+ for (let i = 0; i < UNITS.length; i++) {
2150
+ const unit = UNITS[i];
2151
+ const scaled = Number((n / unit.value).toFixed(1));
2152
+ if (scaled >= 1e3 && i < UNITS.length - 1) continue;
2153
+ return `${scaled}${unit.suffix}`;
2154
+ }
2155
+ return String(n);
2156
+ }
2157
+
2158
+ //#endregion
2159
+ //#region src/ui/icons.ts
2160
+ /**
2161
+ * 调试面板标题栏图标:24×24 线条图标,颜色跟随 currentColor,尺寸由 .icon-btn svg 控制。
2162
+ * 路径取自 Lucide(ISC 许可):settings、arrow-left、minimize-2、x、volume-2、volume-x;
2163
+ * robotIcon 为本包自绘的页面智能体形象,其 SVG 字符串以 PAGE_USE_ROBOT_ICON_SVG 对外导出。
2164
+ */
2165
+ /** 外层 <svg> 须在 html 模板中创建(lit 的 svg 模板只用于 <svg> 内部的片段) */
2166
+ function icon(content) {
2167
+ return html`<svg
2168
+ viewBox="0 0 24 24"
2169
+ fill="none"
2170
+ stroke="currentColor"
2171
+ stroke-width="2"
2172
+ stroke-linecap="round"
2173
+ stroke-linejoin="round"
2174
+ aria-hidden="true"
2175
+ >${content}</svg>`;
2176
+ }
2177
+ const settingsIcon = icon(svg`
2178
+ <path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z" />
2179
+ <circle cx="12" cy="12" r="3" />
2180
+ `);
2181
+ /** 设置 / 元信息视图中返回执行记录 */
2182
+ const backIcon = icon(svg`<path d="M19 12H5" /><path d="m12 19-7-7 7-7" />`);
2183
+ /** 收起为浮动入口 */
2184
+ const collapseIcon = icon(svg`
2185
+ <path d="M4 14h6v6" /><path d="M20 10h-6V4" /><path d="m14 10 7-7" /><path d="m3 21 7-7" />
2186
+ `);
2187
+ /** 关闭调试入口 */
2188
+ const closeIcon = icon(svg`<path d="M18 6 6 18" /><path d="m6 6 12 12" />`);
2189
+ /** 播报中(点击静音) */
2190
+ const volumeIcon = icon(svg`
2191
+ <polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5" />
2192
+ <path d="M15.54 8.46a5 5 0 0 1 0 7.07" /><path d="M19.07 4.93a10 10 0 0 1 0 14.14" />
2193
+ `);
2194
+ /** 已静音(点击恢复) */
2195
+ const mutedIcon = icon(svg`
2196
+ <polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5" />
2197
+ <line x1="22" x2="16" y1="9" y2="15" /><line x1="16" x2="22" y1="9" y2="15" />
2198
+ `);
2199
+ /**
2200
+ * 页面智能体机器人图标(天线 + 屏幕式头部 + 双眼)的 SVG 字符串,供宿主在自身界面中复用(如智能体列表卡片)。
2201
+ * 颜色全部跟随 currentColor,不含 width / height,尺寸由宿主 CSS 控制;viewBox 为 24×24、线宽 2。
2202
+ * robot-antenna / robot-eye 类名供面板在执行中做闪烁与眨眼动画,宿主不定义对应样式时无影响。
2203
+ */
2204
+ const PAGE_USE_ROBOT_ICON_SVG = `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
2205
+ <circle class="robot-antenna" cx="12" cy="3" r="1.5" fill="currentColor" stroke="none" />
2206
+ <path d="M12 4.5V7" />
2207
+ <rect x="4" y="7" width="16" height="12.5" rx="4" />
2208
+ <path d="M2 12v3.5" /><path d="M22 12v3.5" />
2209
+ <circle class="robot-eye" cx="9" cy="12.5" r="1.6" fill="currentColor" stroke="none" />
2210
+ <circle class="robot-eye" cx="15" cy="12.5" r="1.6" fill="currentColor" stroke="none" />
2211
+ <path d="M9.5 16.3h5" />
2212
+ </svg>`;
2213
+ /** 收起时的入口按钮图标:与导出的 PAGE_USE_ROBOT_ICON_SVG 同源,内容为包内常量,无注入风险 */
2214
+ const robotIcon = html`${unsafeHTML(PAGE_USE_ROBOT_ICON_SVG)}`;
2215
+
1464
2216
  //#endregion
1465
2217
  //#region src/ui/presets.ts
1466
2218
  /** 通用示例:不依赖具体站点,覆盖详情、标签、返回等典型操作 */
@@ -1478,8 +2230,11 @@ const PAGE_USE_STATIC_PRESETS = [
1478
2230
  task: "概括当前页面展示的主要内容与可执行的操作"
1479
2231
  }
1480
2232
  ];
1481
- /** 取前 N 个可见叶子菜单,生成「打开 XX」快捷指令 */
1482
- function buildMenuPresets(menus, limit = 8) {
2233
+ /**
2234
+ * 取前 N 个可见叶子菜单,生成「打开 XX」快捷指令。
2235
+ * exclude 中的 task 被跳过且不占名额,由后续菜单补位(用户隐藏菜单指令时使用)。
2236
+ */
2237
+ function buildMenuPresets(menus, limit = 8, exclude) {
1483
2238
  const out = [];
1484
2239
  const walk = (list) => {
1485
2240
  for (const node of list) {
@@ -1487,15 +2242,51 @@ function buildMenuPresets(menus, limit = 8) {
1487
2242
  if (node.hidden) continue;
1488
2243
  const children = (node.children || []).filter((child) => !child.hidden);
1489
2244
  if (children.length) walk(children);
1490
- else out.push({
1491
- label: node.label,
1492
- task: `打开「${node.label}」页面`
1493
- });
2245
+ else {
2246
+ const task = `打开「${node.label}」页面`;
2247
+ if (!exclude?.has(task)) out.push({
2248
+ label: node.label,
2249
+ task
2250
+ });
2251
+ }
1494
2252
  }
1495
2253
  };
1496
2254
  walk(menus || []);
1497
2255
  return out;
1498
2256
  }
2257
+ /** 组合快捷指令分组:展示区与编辑视图共用,保证两处结果一致 */
2258
+ function resolvePresetGroups(input) {
2259
+ const hidden = new Set(input.state.hidden);
2260
+ const visible = (list) => (list || []).filter((preset) => !hidden.has(preset.task));
2261
+ const limit = input.menuPresetLimit;
2262
+ const menu = limit > 0 ? buildMenuPresets(input.menus, limit, hidden) : [];
2263
+ const builtin = visible(PAGE_USE_STATIC_PRESETS);
2264
+ const host = visible(input.presets);
2265
+ const scenarios = visible(input.scenarios);
2266
+ const allSourceTasks = new Set([
2267
+ ...limit > 0 ? buildMenuPresets(input.menus, Number.POSITIVE_INFINITY) : [],
2268
+ ...PAGE_USE_STATIC_PRESETS,
2269
+ ...input.presets || [],
2270
+ ...input.scenarios || []
2271
+ ].map((preset) => preset.task));
2272
+ const hiddenCount = [...hidden].filter((task) => allSourceTasks.has(task)).length;
2273
+ return {
2274
+ mine: input.state.custom,
2275
+ common: [
2276
+ ...menu,
2277
+ ...builtin,
2278
+ ...host
2279
+ ],
2280
+ scenarios,
2281
+ sources: {
2282
+ menu,
2283
+ builtin,
2284
+ host,
2285
+ scenarios
2286
+ },
2287
+ hiddenCount
2288
+ };
2289
+ }
1499
2290
 
1500
2291
  //#endregion
1501
2292
  //#region src/ui/styles.ts
@@ -1514,6 +2305,11 @@ const launcherStyles = css`
1514
2305
  --text-3: #8a94a6;
1515
2306
  --border: #e6eaf0;
1516
2307
  --bg-soft: #f5f7fa;
2308
+ /* 入口按钮背景与面板边框共用的流光渐变;宿主可通过 --tz-page-use-flow-gradient 覆盖 */
2309
+ --flow-gradient: var(
2310
+ --tz-page-use-flow-gradient,
2311
+ radial-gradient(#fa6400 0%, #fa6400 10%, #fcf26e 40%, #00e754 60%, #00eef4 70%, #d519d2 100%)
2312
+ );
1517
2313
  font-family: system-ui, -apple-system, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif;
1518
2314
  font-size: 13px;
1519
2315
  line-height: 1.5;
@@ -1570,59 +2366,181 @@ const launcherStyles = css`
1570
2366
  * 运行期蒙层 z-index 为 2147483641、内置面板为 2147483642;
1571
2367
  * 入口与面板须高于两者,否则任务执行中无法点「终止」或回答模型提问。
1572
2368
  */
2369
+ /*
2370
+ * 入口按钮:多色径向渐变放大到 300%,循环移动 background-position 形成流光;
2371
+ * ::before 在内缩 3px 处叠一层 14% 黑:压暗文字背后的高亮色段(黄、青)以保证白字清晰,
2372
+ * 外圈 3px 保持原色,形成渐变描边质感。
2373
+ * 渐变见 :host 的 --flow-gradient。
2374
+ */
1573
2375
  .fab {
1574
2376
  position: fixed;
1575
2377
  z-index: 2147483643;
1576
2378
  display: flex;
1577
2379
  align-items: center;
1578
2380
  gap: 6px;
1579
- padding: 8px 14px;
2381
+ padding: 8px 16px;
1580
2382
  border: none;
1581
- border-radius: 20px;
1582
- background: var(--primary);
2383
+ border-radius: 999px;
2384
+ background: var(--flow-gradient) 0 0 / 300% 300%;
1583
2385
  color: #fff;
1584
2386
  font: inherit;
1585
2387
  font-weight: 600;
2388
+ /* 贴边的深色阴影形成细描边,配合内层压暗保证白字在高亮色段上可读 */
2389
+ text-shadow:
2390
+ 0 0 1px rgba(0, 0, 0, 0.35),
2391
+ 0 1px 2px rgba(0, 0, 0, 0.35);
1586
2392
  cursor: grab;
1587
2393
  touch-action: none;
1588
- box-shadow: 0 4px 14px color-mix(in srgb, var(--primary) 35%, transparent);
2394
+ box-shadow: -1px 2px 6px 1px rgba(0, 0, 0, 0.12);
2395
+ animation: fab-shining 15s linear infinite;
2396
+ will-change: background-position;
2397
+ /* 「执行中」与标题文案切换时的宽度过渡(由 launcher 设置起止宽度);过渡中不换行、裁掉溢出文字 */
2398
+ overflow: hidden;
2399
+ white-space: nowrap;
2400
+ transition: width 0.3s ease;
2401
+ }
2402
+ .fab::before {
2403
+ content: '';
2404
+ position: absolute;
2405
+ inset: 3px;
2406
+ border-radius: inherit;
2407
+ background: rgba(0, 0, 0, 0.14);
2408
+ pointer-events: none;
2409
+ }
2410
+ .fab > * {
2411
+ position: relative;
1589
2412
  }
1590
2413
  .fab:active {
1591
2414
  cursor: grabbing;
1592
2415
  }
2416
+ /* 执行中:流速加快(15s → 10s)并外发光 */
1593
2417
  .fab.running {
1594
- background: var(--warning);
1595
- box-shadow: 0 4px 14px color-mix(in srgb, var(--warning) 40%, transparent);
2418
+ animation-duration: 10s;
2419
+ box-shadow:
2420
+ -1px 2px 6px 1px rgba(0, 0, 0, 0.12),
2421
+ 0 0 14px 2px rgba(250, 100, 0, 0.45);
1596
2422
  }
1597
- .fab-dot {
1598
- width: 6px;
1599
- height: 6px;
1600
- border-radius: 50%;
1601
- background: #fff;
2423
+ @keyframes fab-shining {
2424
+ 0% {
2425
+ background-position: -20% -20%;
2426
+ }
2427
+ 25% {
2428
+ background-position: 30% 80%;
2429
+ }
2430
+ 50% {
2431
+ background-position: 110% 110%;
2432
+ }
2433
+ 75% {
2434
+ background-position: 80% 30%;
2435
+ }
2436
+ 100% {
2437
+ background-position: -20% -20%;
2438
+ }
2439
+ }
2440
+ @media (prefers-reduced-motion: reduce) {
2441
+ .fab {
2442
+ transition: none;
2443
+ }
2444
+ .fab,
2445
+ .panel::before,
2446
+ .fab.running .robot-antenna,
2447
+ .fab.running .robot-eye {
2448
+ animation: none;
2449
+ }
2450
+ }
2451
+ /* 入口机器人图标:执行中天线闪烁、双眼眨动 */
2452
+ .fab-icon {
2453
+ display: inline-flex;
2454
+ flex: none;
2455
+ }
2456
+ /* 图标 24px,上下 -2px 抵消超出 20px 的部分,按钮高度保持不变 */
2457
+ .fab-icon svg {
2458
+ width: 24px;
2459
+ height: 24px;
2460
+ margin: -2px 0;
2461
+ }
2462
+ .fab.running .robot-antenna {
2463
+ animation: blink 1s infinite;
2464
+ }
2465
+ .robot-eye {
2466
+ transform-box: fill-box;
2467
+ transform-origin: center;
2468
+ }
2469
+ .fab.running .robot-eye {
2470
+ animation: robot-blink 2.4s infinite;
1602
2471
  }
1603
- .fab.running .fab-dot {
1604
- animation: blink 1s infinite;
2472
+ @keyframes robot-blink {
2473
+ 0%,
2474
+ 90%,
2475
+ 100% {
2476
+ transform: scaleY(1);
2477
+ }
2478
+ 95% {
2479
+ transform: scaleY(0.15);
2480
+ }
1605
2481
  }
1606
-
2482
+ /*
2483
+ * 面板:高透毛玻璃 + 2px 流光边框。
2484
+ * 面板本体为半透明白底并对背后页面做模糊(backdrop-filter);
2485
+ * 边框由 ::before 绘制:整块铺流光渐变,再用遮罩挖掉 content-box,只留外圈 2px,
2486
+ * 因此内部保持透明,不会透出渐变。边框与入口按钮使用同一套渐变与移动轨迹(fab-shining)。
2487
+ */
1607
2488
  .panel {
1608
2489
  position: fixed;
1609
2490
  z-index: 2147483643;
1610
- /* width / height 由 panelStyle 按入口位置计算 */
2491
+ /* width / height 由 panelStyle 按入口位置计算(border-box,内边距计入宽高) */
1611
2492
  display: flex;
1612
2493
  flex-direction: column;
1613
- border-radius: 10px;
1614
- background: #fff;
1615
- box-shadow: 0 8px 28px rgba(0, 0, 0, 0.18);
2494
+ /* 为流光边框留出 2px,内容不被边框覆盖 */
2495
+ padding: 2px;
2496
+ border-radius: 12px;
2497
+ background: rgba(255, 255, 255, 0.72);
2498
+ -webkit-backdrop-filter: blur(18px) saturate(160%);
2499
+ backdrop-filter: blur(18px) saturate(160%);
2500
+ box-shadow:
2501
+ 0 8px 28px rgba(0, 0, 0, 0.18),
2502
+ 0 0 18px rgba(0, 238, 244, 0.18);
1616
2503
  overflow: hidden;
1617
2504
  }
2505
+ /* 不支持 backdrop-filter 时回退为接近不透明的白底,保证内容可读 */
2506
+ @supports not ((backdrop-filter: blur(1px)) or (-webkit-backdrop-filter: blur(1px))) {
2507
+ .panel {
2508
+ background: rgba(255, 255, 255, 0.96);
2509
+ }
2510
+ }
2511
+ .panel::before {
2512
+ content: '';
2513
+ position: absolute;
2514
+ inset: 0;
2515
+ z-index: 1;
2516
+ padding: 2px;
2517
+ border-radius: inherit;
2518
+ background: var(--flow-gradient) 0 0 / 300% 300%;
2519
+ /* 两层遮罩相减:整块减去 content-box,只保留 padding 区域的外圈 */
2520
+ -webkit-mask:
2521
+ linear-gradient(#000 0 0) content-box,
2522
+ linear-gradient(#000 0 0);
2523
+ -webkit-mask-composite: xor;
2524
+ mask:
2525
+ linear-gradient(#000 0 0) content-box exclude,
2526
+ linear-gradient(#000 0 0);
2527
+ animation: fab-shining 15s linear infinite;
2528
+ pointer-events: none;
2529
+ }
2530
+ /* 执行中:边框流速加快(15s → 10s) */
2531
+ .panel.running::before {
2532
+ animation-duration: 10s;
2533
+ }
1618
2534
 
1619
2535
  .head {
1620
2536
  display: flex;
1621
2537
  align-items: center;
1622
2538
  gap: 8px;
1623
2539
  padding: 10px 12px;
1624
- background: var(--bg-soft);
1625
- border-bottom: 1px solid var(--border);
2540
+ /* 毛玻璃面板内的半透明色块;上圆角与面板内圈(12px - 2px 边框)一致,避免直角露出 */
2541
+ border-radius: 10px 10px 0 0;
2542
+ background: rgba(226, 232, 240, 0.75);
2543
+ border-bottom: 1px solid rgba(208, 215, 226, 0.8);
1626
2544
  cursor: grab;
1627
2545
  touch-action: none;
1628
2546
  user-select: none;
@@ -1654,6 +2572,8 @@ const launcherStyles = css`
1654
2572
  .title {
1655
2573
  flex: 1 0 auto;
1656
2574
  font-weight: 600;
2575
+ /* 连续点击标题打开元信息面板,避免选中文字 */
2576
+ user-select: none;
1657
2577
  }
1658
2578
  .status {
1659
2579
  min-width: 0;
@@ -1664,6 +2584,48 @@ const launcherStyles = css`
1664
2584
  white-space: nowrap;
1665
2585
  }
1666
2586
 
2587
+ /* 标题栏图标按钮:常态为弱化的线条图标,悬停显示浅底色;关闭按钮悬停为红底白图标 */
2588
+ .head-actions {
2589
+ display: flex;
2590
+ flex: none;
2591
+ align-items: center;
2592
+ gap: 6px;
2593
+ }
2594
+ .icon-btn {
2595
+ display: inline-flex;
2596
+ align-items: center;
2597
+ justify-content: center;
2598
+ width: 26px;
2599
+ height: 26px;
2600
+ padding: 0;
2601
+ border: none;
2602
+ border-radius: 6px;
2603
+ background: transparent;
2604
+ color: var(--text-3);
2605
+ cursor: pointer;
2606
+ transition: background 0.15s, color 0.15s;
2607
+ }
2608
+ .icon-btn svg {
2609
+ width: 16px;
2610
+ height: 16px;
2611
+ }
2612
+ .icon-btn:hover {
2613
+ background: #e6eaf0;
2614
+ color: var(--text);
2615
+ }
2616
+ .icon-btn:focus-visible {
2617
+ outline: 2px solid color-mix(in srgb, var(--primary) 45%, transparent);
2618
+ outline-offset: 1px;
2619
+ }
2620
+ .icon-btn.active {
2621
+ background: color-mix(in srgb, var(--primary) 12%, transparent);
2622
+ color: var(--primary);
2623
+ }
2624
+ .icon-btn.close:hover {
2625
+ background: var(--error);
2626
+ color: #fff;
2627
+ }
2628
+
1667
2629
  /* 占满头部与输入区之间的剩余高度(min-height:0 允许在 flex 列中收缩) */
1668
2630
  .fill {
1669
2631
  flex: 1;
@@ -1687,8 +2649,28 @@ const launcherStyles = css`
1687
2649
  color: var(--text-3);
1688
2650
  font-size: 12px;
1689
2651
  }
1690
- .meta {
2652
+ .settings-status {
2653
+ color: var(--text-3);
2654
+ font-size: 12px;
2655
+ }
2656
+ /* 元信息面板:标签 / 值两列 */
2657
+ .meta-list {
2658
+ display: grid;
2659
+ grid-template-columns: auto 1fr;
2660
+ gap: 6px 12px;
2661
+ margin: 0;
2662
+ font-size: 12px;
2663
+ }
2664
+ .meta-list dt {
1691
2665
  color: var(--text-3);
2666
+ }
2667
+ .meta-list dd {
2668
+ margin: 0;
2669
+ word-break: break-all;
2670
+ }
2671
+ .meta-engines {
2672
+ margin: 0;
2673
+ padding-left: 18px;
1692
2674
  font-size: 12px;
1693
2675
  word-break: break-all;
1694
2676
  }
@@ -1937,7 +2919,8 @@ const launcherStyles = css`
1937
2919
  gap: 8px;
1938
2920
  padding: 10px 12px 12px;
1939
2921
  border-top: 1px solid var(--border);
1940
- background: #fafbfc;
2922
+ border-radius: 0 0 10px 10px;
2923
+ background: rgba(250, 251, 252, 0.5);
1941
2924
  }
1942
2925
  /* 悬浮在输入区上沿(以 .composer 为定位参照) */
1943
2926
  .jump-latest {
@@ -1974,6 +2957,141 @@ const launcherStyles = css`
1974
2957
  background: var(--bg-soft);
1975
2958
  cursor: not-allowed;
1976
2959
  }
2960
+ /* ---------- 演示播报设置 ---------- */
2961
+ .voice-warning {
2962
+ font-size: 12px;
2963
+ }
2964
+ /* 与复选框文字左对齐(复选框宽 13px + 间距 8px) */
2965
+ .voice-options {
2966
+ display: flex;
2967
+ flex-direction: column;
2968
+ gap: 8px;
2969
+ margin-left: 21px;
2970
+ }
2971
+ /* 设置页底部操作按钮:与复选框文字、播报选项输入框左对齐(同 21px 缩进),与上方拉开间距 */
2972
+ .settings-actions {
2973
+ margin-top: 8px;
2974
+ margin-left: 21px;
2975
+ }
2976
+ /* 面板内轻提示:相对面板(.panel 为 fixed 定位)底部居中,不拦截点击 */
2977
+ .toast {
2978
+ position: absolute;
2979
+ left: 50%;
2980
+ bottom: 24px;
2981
+ z-index: 1;
2982
+ max-width: calc(100% - 48px);
2983
+ padding: 6px 14px;
2984
+ border-radius: 6px;
2985
+ background: rgba(31, 45, 61, 0.88);
2986
+ color: #fff;
2987
+ font-size: 12px;
2988
+ white-space: nowrap;
2989
+ overflow: hidden;
2990
+ text-overflow: ellipsis;
2991
+ pointer-events: none;
2992
+ transform: translateX(-50%);
2993
+ animation: toast-in 0.2s ease-out;
2994
+ }
2995
+
2996
+ /* ---------- 快捷指令编辑 ---------- */
2997
+ /*
2998
+ * 滚动区与面板底边留出间距,避免列表滚动时贴住底边与圆角。
2999
+ * 用 margin 而非 padding:滚动内容会滚入滚动容器自身的 padding 区域,仍然贴边。
3000
+ */
3001
+ .fill.preset-scroll {
3002
+ margin-bottom: 10px;
3003
+ }
3004
+ /* 分组标题行:标题左、操作按钮右 */
3005
+ .preset-group-head {
3006
+ display: flex;
3007
+ align-items: center;
3008
+ justify-content: space-between;
3009
+ margin-top: 4px;
3010
+ }
3011
+ .preset-list {
3012
+ display: flex;
3013
+ flex-direction: column;
3014
+ gap: 4px;
3015
+ }
3016
+ .preset-item {
3017
+ display: flex;
3018
+ align-items: center;
3019
+ gap: 4px;
3020
+ padding: 4px 4px 4px 8px;
3021
+ border: 1px solid var(--border);
3022
+ border-radius: 6px;
3023
+ }
3024
+ /* min-width:0 使长文本在 flex 中可收缩,否则省略号不生效、按钮被挤出 */
3025
+ .preset-text {
3026
+ flex: 1;
3027
+ min-width: 0;
3028
+ display: flex;
3029
+ flex-direction: column;
3030
+ }
3031
+ .preset-label,
3032
+ .preset-task {
3033
+ overflow: hidden;
3034
+ text-overflow: ellipsis;
3035
+ white-space: nowrap;
3036
+ }
3037
+ /* 指令内容只显示一行,完整内容通过 title 悬停查看或进入编辑表单查看 */
3038
+ .preset-task {
3039
+ color: var(--text-3);
3040
+ font-size: 12px;
3041
+ }
3042
+ .preset-form {
3043
+ display: flex;
3044
+ flex-direction: column;
3045
+ gap: 6px;
3046
+ padding: 8px;
3047
+ border: 1px solid var(--primary);
3048
+ border-radius: 6px;
3049
+ }
3050
+ .preset-form .input {
3051
+ height: 28px;
3052
+ }
3053
+ .preset-error {
3054
+ font-size: 12px;
3055
+ }
3056
+ .preset-form-actions {
3057
+ justify-content: flex-end;
3058
+ }
3059
+ .preset-restore {
3060
+ align-items: center;
3061
+ margin-top: 8px;
3062
+ }
3063
+
3064
+ .field {
3065
+ display: flex;
3066
+ flex-direction: column;
3067
+ gap: 4px;
3068
+ }
3069
+ .field-label {
3070
+ color: var(--text-3);
3071
+ font-size: 12px;
3072
+ }
3073
+ .range {
3074
+ width: 100%;
3075
+ margin: 0;
3076
+ accent-color: var(--primary);
3077
+ }
3078
+ select.input {
3079
+ height: 28px;
3080
+ }
3081
+ /* 音色下拉框与试听按钮同一行:下拉框占满剩余宽度,长音色名省略显示 */
3082
+ .voice-name-row {
3083
+ display: flex;
3084
+ gap: 6px;
3085
+ }
3086
+ .voice-name-row .input {
3087
+ flex: 1;
3088
+ min-width: 0;
3089
+ text-overflow: ellipsis;
3090
+ }
3091
+ .voice-name-row .preview-btn {
3092
+ flex: none;
3093
+ }
3094
+
1977
3095
  .input-group {
1978
3096
  display: flex;
1979
3097
  gap: 6px;
@@ -1995,7 +3113,10 @@ const launcherStyles = css`
1995
3113
  overflow-y: auto;
1996
3114
  border: 1px solid var(--border);
1997
3115
  border-radius: 8px;
1998
- background: #fff;
3116
+ /* 浮在执行记录上方,透明度低于面板以保证列表可读 */
3117
+ background: rgba(255, 255, 255, 0.88);
3118
+ -webkit-backdrop-filter: blur(12px);
3119
+ backdrop-filter: blur(12px);
1999
3120
  }
2000
3121
  .history-item {
2001
3122
  display: flex;
@@ -2039,6 +3160,12 @@ const launcherStyles = css`
2039
3160
  opacity: 0.25;
2040
3161
  }
2041
3162
  }
3163
+ @keyframes toast-in {
3164
+ from {
3165
+ opacity: 0;
3166
+ transform: translate(-50%, 6px);
3167
+ }
3168
+ }
2042
3169
  @keyframes spin {
2043
3170
  to {
2044
3171
  transform: rotate(360deg);
@@ -2051,7 +3178,7 @@ const launcherStyles = css`
2051
3178
  /**
2052
3179
  * <tz-page-use-launcher>:调试入口(可拖拽浮动按钮 + 指令面板)。
2053
3180
  * 与其它智能体共用 core/service 的单例状态:其它智能体下达的任务同样在此展示,可随时终止。
2054
- * 默认隐藏 page-agent 自带面板与元素编号高亮,进度与提问改由本面板承接(设置里可开回)。
3181
+ * page-agent 自带面板固定隐藏、元素编号高亮默认隐藏(设置里可开回),进度与提问改由本面板承接。
2055
3182
  *
2056
3183
  * 宿主元素须带 data-page-agent-ignore="true"(由 mountPageUseUI 设置):
2057
3184
  * page-controller 在该属性上直接跳过整棵子树(含 shadow root),面板不会被模型当作页面元素。
@@ -2068,9 +3195,9 @@ const STATUS_LABELS = {
2068
3195
  error: "出错",
2069
3196
  stopped: "已终止"
2070
3197
  };
2071
- /** 入口默认位置:距视口右边/下边的距离(px) */
2072
- const FAB_DEFAULT_RIGHT = 28;
2073
- const FAB_DEFAULT_BOTTOM = 140;
3198
+ /** 入口默认位置:距视口右边/下边的距离(px);面板展开时与入口对齐于同一角,即面板默认贴右下角 */
3199
+ const FAB_DEFAULT_RIGHT = 24;
3200
+ const FAB_DEFAULT_BOTTOM = 24;
2074
3201
  /** 面板宽度(px),与入口对齐及视口夹取计算共用 */
2075
3202
  const PANEL_WIDTH = 380;
2076
3203
  /** 面板期望高度(px),受视口 72vh 与锚点剩余空间约束 */
@@ -2078,6 +3205,28 @@ const PANEL_HEIGHT = 560;
2078
3205
  /** 日志区距底部小于该值(px)视为贴底 */
2079
3206
  const STICK_THRESHOLD = 24;
2080
3207
  const TEXTAREA_MIN_ROWS = 2;
3208
+ /** 元信息面板解锁:任意连续 META_UNLOCK_WINDOW_MS 毫秒内点击标题达到 META_UNLOCK_CLICKS 次(滑动窗口) */
3209
+ const META_UNLOCK_WINDOW_MS = 5e3;
3210
+ const META_UNLOCK_CLICKS = 10;
3211
+ /** 面板内轻提示(toast)的显示时长(毫秒) */
3212
+ const TOAST_DURATION_MS = 2e3;
3213
+ /** 标题栏返回按钮的目标视图:按层级返回;main 无上级,此时按钮用于进入设置页 */
3214
+ const PARENT_VIEW = {
3215
+ settings: "main",
3216
+ meta: "main",
3217
+ presets: "settings"
3218
+ };
3219
+ /** 自定义指令保存失败原因 → 提示文字 */
3220
+ const PRESET_SAVE_ERRORS = {
3221
+ "label-empty": "请填写名称",
3222
+ "task-empty": "请填写指令内容",
3223
+ duplicate: "已存在指令内容相同的自定义指令",
3224
+ "not-found": "该指令已被删除,请重新新增"
3225
+ };
3226
+ /** 播报语速倍率的调节范围与步长(1 为正常语速,过快或过慢时中文语音难以听清;注入引擎自行换算为服务参数) */
3227
+ const VOICE_RATE_MIN = .5;
3228
+ const VOICE_RATE_MAX = 2;
3229
+ const VOICE_RATE_STEP = .05;
2081
3230
  /**
2082
3231
  * 步骤 → 时间线节点颜色:失败/报错为 error,完成为 success,
2083
3232
  * 向用户提问与重试为 warning,其余普通操作为 info(主色)。
@@ -2100,13 +3249,28 @@ var PageUseLauncherElement = class extends LitElement {
2100
3249
  }
2101
3250
  set config(value) {
2102
3251
  this._config = value || {};
3252
+ if (this.isConnected) this.syncPanelName();
2103
3253
  this.requestUpdate();
2104
3254
  }
3255
+ /** 撤销本面板登记的播报用面板名称 */
3256
+ unregisterPanelName = null;
3257
+ /** 把面板标题登记为播报截断提示中的面板名称(「详细内容请查看「标题」」) */
3258
+ syncPanelName() {
3259
+ this.unregisterPanelName?.();
3260
+ this.unregisterPanelName = registerNarrationPanelName(this._config.title || "Page Use");
3261
+ }
2105
3262
  open = false;
2106
- showSettings = false;
3263
+ view = "main";
3264
+ /** 标题点击时间戳(毫秒),用于元信息面板解锁的滑动窗口计数;不持久化 */
3265
+ titleTaps = [];
2107
3266
  task = "";
2108
3267
  answer = "";
2109
3268
  tray = null;
3269
+ /** 快捷指令编辑视图中正在编辑的表单;同一时刻只有一个,离开编辑视图时清空 */
3270
+ presetDraft = null;
3271
+ /** 面板内轻提示文字;空串为不显示 */
3272
+ toastText = "";
3273
+ toastTimer = null;
2110
3274
  /**
2111
3275
  * 日志区跟随滚动:默认贴底跟随新内容;用户上翻后停止跟随,
2112
3276
  * 回到底部附近(阈值内)或点击「最新」后恢复。
@@ -2121,8 +3285,8 @@ var PageUseLauncherElement = class extends LitElement {
2121
3285
  * 因此在可见时测量并保留最近一次结果;初值为「Page Use」文案下的近似尺寸。
2122
3286
  */
2123
3287
  fabSize = {
2124
- width: 110,
2125
- height: 34
3288
+ width: 122,
3289
+ height: 36
2126
3290
  };
2127
3291
  viewport = {
2128
3292
  width: window.innerWidth,
@@ -2138,6 +3302,12 @@ var PageUseLauncherElement = class extends LitElement {
2138
3302
  alignBottom: true
2139
3303
  };
2140
3304
  wasRunning = false;
3305
+ /** 入口按钮上次渲染时是否为「执行中」文案;文案切换时用于触发宽度过渡 */
3306
+ fabShowsRunning = false;
3307
+ /** 文案切换前的入口宽度(px);null 表示本次更新不需要宽度过渡 */
3308
+ fabFromWidth = null;
3309
+ /** 进行中宽度过渡的结束回调注销函数;过渡中再次切换时先注销,避免旧回调提前清除宽度 */
3310
+ fabWidthCleanup = null;
2141
3311
  cleanups = [];
2142
3312
  get fabEl() {
2143
3313
  return this.renderRoot.querySelector(".fab");
@@ -2149,7 +3319,7 @@ var PageUseLauncherElement = class extends LitElement {
2149
3319
  return this.renderRoot.querySelector(".log");
2150
3320
  }
2151
3321
  get textareaEl() {
2152
- return this.renderRoot.querySelector(".textarea");
3322
+ return this.renderRoot.querySelector(".composer .textarea");
2153
3323
  }
2154
3324
  fabDrag = createDrag({
2155
3325
  getEl: () => this.fabEl,
@@ -2167,6 +3337,9 @@ var PageUseLauncherElement = class extends LitElement {
2167
3337
  panelDrag = createDrag({
2168
3338
  getEl: () => this.panelEl,
2169
3339
  getPos: () => null,
3340
+ onTap: (target) => {
3341
+ if (target?.closest?.(".title")) this.onTitleTap();
3342
+ },
2170
3343
  setPos: ({ x, y }) => {
2171
3344
  const el = this.panelEl;
2172
3345
  if (!el) return;
@@ -2181,25 +3354,102 @@ var PageUseLauncherElement = class extends LitElement {
2181
3354
  connectedCallback() {
2182
3355
  super.connectedCallback();
2183
3356
  this.wasRunning = isRunning();
3357
+ const query = readLauncherQuery();
3358
+ if (query !== null) pageUseSettings.update({ launcherVisible: query === "1" || query === "true" });
2184
3359
  this.cleanups = [
2185
3360
  pageUseStore.subscribe((state) => this.onStateChange(state)),
2186
3361
  pageUseSettings.store.subscribe(() => this.requestUpdate()),
2187
3362
  pageUseHistory.store.subscribe(() => this.requestUpdate()),
2188
- registerQuestionUi()
3363
+ pageUsePresets.store.subscribe(() => this.requestUpdate()),
3364
+ narrationMute.subscribe(() => this.requestUpdate()),
3365
+ previewStore.subscribe(() => this.requestUpdate()),
3366
+ registerLauncherOpener(() => void this.expand()),
3367
+ registerQuestionUi(),
3368
+ this.watchVoices()
2189
3369
  ];
3370
+ this.syncPanelName();
2190
3371
  window.addEventListener("resize", this.onResize);
2191
3372
  }
2192
3373
  disconnectedCallback() {
2193
3374
  super.disconnectedCallback();
2194
3375
  this.cleanups.forEach((off) => off());
2195
3376
  this.cleanups = [];
3377
+ this.unregisterPanelName?.();
3378
+ this.unregisterPanelName = null;
3379
+ stopPreview();
2196
3380
  window.removeEventListener("resize", this.onResize);
3381
+ if (this.toastTimer) clearTimeout(this.toastTimer);
3382
+ this.toastTimer = null;
3383
+ this.toastText = "";
3384
+ }
3385
+ /** 可选播报引擎:宿主注入的引擎 + 浏览器引擎;尚未 createPageUse 时只有浏览器引擎 */
3386
+ ttsEngines() {
3387
+ return getPageUseConfig()?.ttsEngines ?? listTtsEngines(void 0);
3388
+ }
3389
+ /** 当前生效的播报引擎,与 narration 使用同一解析规则 */
3390
+ ttsEngine() {
3391
+ return resolveTtsEngine(this.ttsEngines(), pageUseSettings.get().voiceEngineId);
3392
+ }
3393
+ /**
3394
+ * Chrome 的语音列表异步加载,首次读取可能为空:加载完成后刷新语音下拉框。
3395
+ * 挂载时宿主可能尚未注入引擎,因此不按引擎判断;注入引擎时该事件只触发一次无害的重绘。
3396
+ */
3397
+ watchVoices() {
3398
+ if (!isSpeechSupported()) return () => {};
3399
+ const synth = globalThis.speechSynthesis;
3400
+ const onChange = () => this.requestUpdate();
3401
+ synth.addEventListener?.("voiceschanged", onChange);
3402
+ return () => synth.removeEventListener?.("voiceschanged", onChange);
3403
+ }
3404
+ /**
3405
+ * 入口文案在「执行中」与标题之间切换时宽度会变化;CSS 无法对 width:auto 过渡,
3406
+ * 因此在渲染新文案前记录旧宽度,渲染后由 animateFabWidth 从旧宽度过渡到新宽度。
3407
+ */
3408
+ willUpdate() {
3409
+ const running = isRunning(pageUseStore.get());
3410
+ if (running === this.fabShowsRunning) return;
3411
+ this.fabShowsRunning = running;
3412
+ const width = this.open ? 0 : this.fabEl?.offsetWidth ?? 0;
3413
+ this.fabFromWidth = width > 0 ? width : null;
3414
+ }
3415
+ /** 从记录的旧宽度过渡到新文案的自然宽度,结束后恢复自动宽度并重新测量入口尺寸 */
3416
+ animateFabWidth() {
3417
+ const from = this.fabFromWidth;
3418
+ this.fabFromWidth = null;
3419
+ const el = this.fabEl;
3420
+ if (from === null || !el) return;
3421
+ this.fabWidthCleanup?.();
3422
+ this.fabWidthCleanup = null;
3423
+ el.style.width = "";
3424
+ const to = el.offsetWidth;
3425
+ if (!to || to === from || parseFloat(getComputedStyle(el).transitionDuration) === 0) {
3426
+ this.measureFab();
3427
+ return;
3428
+ }
3429
+ el.style.width = `${from}px`;
3430
+ el.offsetWidth;
3431
+ el.style.width = `${to}px`;
3432
+ const finish = () => {
3433
+ el.removeEventListener("transitionend", onEnd);
3434
+ el.removeEventListener("transitioncancel", onEnd);
3435
+ this.fabWidthCleanup = null;
3436
+ };
3437
+ const onEnd = (event) => {
3438
+ if (event.propertyName !== "width") return;
3439
+ finish();
3440
+ el.style.width = "";
3441
+ this.measureFab();
3442
+ };
3443
+ el.addEventListener("transitionend", onEnd);
3444
+ el.addEventListener("transitioncancel", onEnd);
3445
+ this.fabWidthCleanup = finish;
2197
3446
  }
2198
3447
  firstUpdated() {
2199
3448
  this.measureFab();
2200
3449
  }
2201
3450
  updated(_changed) {
2202
3451
  this.autosizeTextarea();
3452
+ this.animateFabWidth();
2203
3453
  const state = pageUseStore.get();
2204
3454
  const signature = [
2205
3455
  state.currentTask,
@@ -2287,7 +3537,61 @@ var PageUseLauncherElement = class extends LitElement {
2287
3537
  style.height = `min(${PANEL_HEIGHT}px, 72vh, ${Math.max(240, vh - offsetY - 8)}px)`;
2288
3538
  return toStyle(style);
2289
3539
  }
3540
+ /** 调试入口是否显示:持久化状态优先,未操作过时跟随宿主 defaultHidden */
3541
+ isVisible(settings) {
3542
+ return settings.launcherVisible ?? !this._config.defaultHidden;
3543
+ }
3544
+ /** 展开面板(showPageUseLauncher 调用):等入口按新的显示状态渲染后再测量定位 */
3545
+ async expand() {
3546
+ await this.updateComplete;
3547
+ this.measureFab();
3548
+ this.resolvePanelAnchor();
3549
+ this.open = true;
3550
+ this.requestUpdate();
3551
+ }
3552
+ /** 关闭调试入口:收起面板并持久化隐藏状态,之后需调用显示方法或 URL 参数调出 */
3553
+ close() {
3554
+ stopPreview();
3555
+ this.open = false;
3556
+ this.view = "main";
3557
+ this.presetDraft = null;
3558
+ hidePageUseLauncher();
3559
+ }
3560
+ /** 标题点击计数:滑动窗口内达到次数时打开元信息面板并重新计数 */
3561
+ onTitleTap() {
3562
+ const now = Date.now();
3563
+ this.titleTaps = this.titleTaps.filter((time) => now - time < META_UNLOCK_WINDOW_MS);
3564
+ this.titleTaps.push(now);
3565
+ if (this.titleTaps.length < META_UNLOCK_CLICKS) return;
3566
+ this.titleTaps = [];
3567
+ this.view = "meta";
3568
+ this.requestUpdate();
3569
+ }
3570
+ /** 切换面板视图:离开设置页时中止音色试听,离开快捷指令编辑时丢弃未保存的草稿 */
3571
+ switchView(next) {
3572
+ if (this.view === "settings" && next !== "settings") stopPreview();
3573
+ if (this.view === "presets" && next !== "presets") this.presetDraft = null;
3574
+ this.view = next;
3575
+ this.requestUpdate();
3576
+ }
3577
+ /** 在面板内显示轻提示,TOAST_DURATION_MS 后自动消失;连续触发时以最后一次为准并重新计时 */
3578
+ showToast(text) {
3579
+ if (this.toastTimer) clearTimeout(this.toastTimer);
3580
+ this.toastText = text;
3581
+ this.toastTimer = setTimeout(() => {
3582
+ this.toastText = "";
3583
+ this.toastTimer = null;
3584
+ this.requestUpdate();
3585
+ }, TOAST_DURATION_MS);
3586
+ this.requestUpdate();
3587
+ }
3588
+ clearHistory() {
3589
+ const count = pageUseHistory.entries().length;
3590
+ pageUseHistory.clear();
3591
+ this.showToast(count ? `已清空 ${count} 条历史指令` : "暂无历史指令");
3592
+ }
2290
3593
  async collapse() {
3594
+ stopPreview();
2291
3595
  this.open = false;
2292
3596
  this.requestUpdate();
2293
3597
  await this.updateComplete;
@@ -2376,23 +3680,15 @@ var PageUseLauncherElement = class extends LitElement {
2376
3680
  ...this._config.sourceLabels
2377
3681
  }[source] || source;
2378
3682
  }
2379
- presets() {
2380
- const limit = this._config.menuPresetLimit ?? 6;
2381
- const menus = getPageUseConfig()?.getMenus();
2382
- return [
2383
- ...limit > 0 ? buildMenuPresets(menus, limit) : [],
2384
- ...PAGE_USE_STATIC_PRESETS,
2385
- ...this._config.presets || []
2386
- ];
2387
- }
2388
- endpointLabel() {
2389
- const baseURL = getPageUseConfig()?.llm.baseURL;
2390
- if (!baseURL) return "未配置";
2391
- try {
2392
- return new URL(baseURL).host;
2393
- } catch {
2394
- return baseURL;
2395
- }
3683
+ /** 快捷指令分组:来源指令排除用户隐藏项,外加用户自定义指令;展示区与编辑视图共用 */
3684
+ presetGroups() {
3685
+ return resolvePresetGroups({
3686
+ menus: getPageUseConfig()?.getMenus(),
3687
+ menuPresetLimit: this._config.menuPresetLimit ?? 6,
3688
+ presets: this._config.presets,
3689
+ scenarios: this._config.scenarios,
3690
+ state: pageUsePresets.get()
3691
+ });
2396
3692
  }
2397
3693
  renderChips(presets, accent = false) {
2398
3694
  return presets.map((preset) => html`<button
@@ -2404,18 +3700,31 @@ var PageUseLauncherElement = class extends LitElement {
2404
3700
  ${preset.label}
2405
3701
  </button>`);
2406
3702
  }
3703
+ /** 分组顺序:我的 → 常用 → 复杂场景;空分组不显示,全部为空时提示去设置中维护 */
2407
3704
  renderPresetGroups() {
2408
- const scenarios = this._config.scenarios || [];
2409
- return html`
2410
- <div class="chips">
2411
- <span class="chips-caption">常用</span>
2412
- ${this.renderChips(this.presets())}
2413
- </div>
2414
- ${scenarios.length ? html`<div class="chips">
2415
- <span class="chips-caption">复杂场景</span>
2416
- ${this.renderChips(scenarios, true)}
2417
- </div>` : nothing}
2418
- `;
3705
+ const { mine, common, scenarios } = this.presetGroups();
3706
+ const visible = [
3707
+ [
3708
+ "我的",
3709
+ mine,
3710
+ false
3711
+ ],
3712
+ [
3713
+ "常用",
3714
+ common,
3715
+ false
3716
+ ],
3717
+ [
3718
+ "复杂场景",
3719
+ scenarios,
3720
+ true
3721
+ ]
3722
+ ].filter(([, presets]) => presets.length);
3723
+ if (!visible.length) return html`<span class="hint">暂无快捷指令,可在设置中维护</span>`;
3724
+ return visible.map(([caption, presets, accent]) => html`<div class="chips">
3725
+ <span class="chips-caption">${caption}</span>
3726
+ ${this.renderChips(presets, accent)}
3727
+ </div>`);
2419
3728
  }
2420
3729
  renderCheckbox(key, label, hint, disabled = false) {
2421
3730
  const settings = pageUseSettings.get();
@@ -2432,21 +3741,282 @@ var PageUseLauncherElement = class extends LitElement {
2432
3741
  renderSettings() {
2433
3742
  const settings = pageUseSettings.get();
2434
3743
  const config = getPageUseConfig();
3744
+ const status = pageUseStore.get().status;
2435
3745
  return html`<div class="fill">
2436
3746
  <div class="settings-body">
2437
- ${this.renderCheckbox("showBuiltinPanel", "显示 page-agent 自带面板", "关闭后由本面板展示进度")}
3747
+ <div class="settings-status">当前状态:${STATUS_LABELS[status] || status}</div>
2438
3748
  ${this.renderCheckbox("showHighlights", "显示元素编号高亮", "运行时页面上带数字的彩色框")}
2439
3749
  ${this.renderCheckbox("enableMask", "运行时蒙层防误触", "下次任务开始生效")}
2440
- ${this.renderCheckbox("customMaskEffect", "定制光晕与光标配色", "关闭后恢复 page-agent 默认效果", !settings.enableMask)}
2441
3750
  ${config?.beforeTask ? this.renderCheckbox("enableBeforeTask", config.beforeTaskLabel) : nothing}
2442
3751
  ${this.renderCheckbox("showStepDetails", "步骤显示模型思考与返回")}
2443
- <div class="row">
3752
+ ${this.renderVoiceSettings(settings)}
3753
+ <div class="row settings-actions">
2444
3754
  <button type="button" class="btn" @click=${() => pageUseSettings.resetPositions()}>
2445
3755
  复位窗口
2446
3756
  </button>
2447
- <button type="button" class="btn" @click=${() => pageUseHistory.clear()}>清空历史</button>
3757
+ <button type="button" class="btn clear-history" @click=${() => this.clearHistory()}>
3758
+ 清空历史
3759
+ </button>
3760
+ <button type="button" class="btn preset-entry" @click=${() => this.switchView("presets")}>
3761
+ 快捷指令设置
3762
+ </button>
3763
+ </div>
3764
+ </div>
3765
+ </div>`;
3766
+ }
3767
+ /** 打开表单:id 为 null 时新增,否则编辑该条;替换当前未保存的草稿 */
3768
+ editPreset(id) {
3769
+ const item = id === null ? null : pageUsePresets.get().custom.find((preset) => preset.id === id);
3770
+ this.presetDraft = {
3771
+ id,
3772
+ label: item?.label ?? "",
3773
+ task: item?.task ?? "",
3774
+ error: ""
3775
+ };
3776
+ this.requestUpdate();
3777
+ }
3778
+ savePreset() {
3779
+ const draft = this.presetDraft;
3780
+ if (!draft) return;
3781
+ const preset = {
3782
+ label: draft.label,
3783
+ task: draft.task
3784
+ };
3785
+ const result = draft.id === null ? pageUsePresets.add(preset) : pageUsePresets.update(draft.id, preset);
3786
+ this.presetDraft = result.ok ? null : {
3787
+ ...draft,
3788
+ error: PRESET_SAVE_ERRORS[result.reason]
3789
+ };
3790
+ this.requestUpdate();
3791
+ }
3792
+ renderPresetForm(draft) {
3793
+ return html`<div class="preset-form">
3794
+ <input
3795
+ class="input preset-label-input"
3796
+ placeholder="名称(按钮上显示的文字)"
3797
+ .value=${draft.label}
3798
+ @input=${(e) => {
3799
+ draft.label = e.target.value;
3800
+ }}
3801
+ />
3802
+ <textarea
3803
+ class="textarea preset-task-input"
3804
+ rows="3"
3805
+ placeholder="指令内容(点击后下达的完整指令)"
3806
+ .value=${draft.task}
3807
+ @input=${(e) => {
3808
+ draft.task = e.target.value;
3809
+ }}
3810
+ ></textarea>
3811
+ ${draft.error ? html`<div class="alert error preset-error">${draft.error}</div>` : nothing}
3812
+ <div class="row preset-form-actions">
3813
+ <button
3814
+ type="button"
3815
+ class="btn tiny"
3816
+ @click=${() => {
3817
+ this.presetDraft = null;
3818
+ this.requestUpdate();
3819
+ }}
3820
+ >
3821
+ 取消
3822
+ </button>
3823
+ <button type="button" class="btn tiny primary preset-save" @click=${() => this.savePreset()}>
3824
+ 保存
3825
+ </button>
3826
+ </div>
3827
+ </div>`;
3828
+ }
3829
+ /** 列表项:名称一行,指令内容灰色单行省略(悬停查看全文),右侧为操作按钮 */
3830
+ renderPresetItem(preset, actions) {
3831
+ return html`<div class="preset-item">
3832
+ <div class="preset-text">
3833
+ <span class="preset-label">${preset.label}</span>
3834
+ <span class="preset-task" title=${preset.task}>${preset.task}</span>
3835
+ </div>
3836
+ ${actions}
3837
+ </div>`;
3838
+ }
3839
+ /**
3840
+ * 快捷指令编辑:我的指令(新增 / 编辑 / 删除)、按来源分组的来源指令(删除即隐藏)、
3841
+ * 已隐藏条数与恢复按钮。任务执行中同样可编辑,只影响后续展示。
3842
+ */
3843
+ renderPresetEditor() {
3844
+ const { mine, sources, hiddenCount } = this.presetGroups();
3845
+ const draft = this.presetDraft;
3846
+ const removeButton = (onClick) => html`<button
3847
+ type="button"
3848
+ class="btn ghost tiny circle preset-remove"
3849
+ title="删除"
3850
+ @click=${onClick}
3851
+ >
3852
+ ×
3853
+ </button>`;
3854
+ const sourceGroups = [
3855
+ ["菜单页面", sources.menu],
3856
+ ["通用示例", sources.builtin],
3857
+ ["站点常用", sources.host],
3858
+ ["复杂场景", sources.scenarios]
3859
+ ];
3860
+ return html`<div class="fill preset-scroll">
3861
+ <div class="settings-body preset-editor">
3862
+ <div class="preset-group-head">
3863
+ <span class="field-label">我的指令</span>
3864
+ <button
3865
+ type="button"
3866
+ class="btn tiny preset-add"
3867
+ ?disabled=${draft?.id === null}
3868
+ @click=${() => this.editPreset(null)}
3869
+ >
3870
+ + 新增
3871
+ </button>
3872
+ </div>
3873
+ <div class="preset-list preset-mine">
3874
+ ${draft?.id === null ? this.renderPresetForm(draft) : nothing}
3875
+ ${mine.map((item) => draft?.id === item.id ? this.renderPresetForm(draft) : this.renderPresetItem(item, html`<button
3876
+ type="button"
3877
+ class="btn ghost tiny preset-edit"
3878
+ @click=${() => this.editPreset(item.id)}
3879
+ >
3880
+ 编辑
3881
+ </button>
3882
+ ${removeButton(() => {
3883
+ pageUsePresets.remove(item.id);
3884
+ })}`))}
3885
+ ${!mine.length && !draft ? html`<span class="hint">暂无自定义指令</span>` : nothing}
3886
+ </div>
3887
+ ${sourceGroups.filter(([, list]) => list.length).map(([caption, list]) => html`<div class="field-label preset-group-head">${caption}</div>
3888
+ <div class="preset-list preset-source">
3889
+ ${list.map((preset) => this.renderPresetItem(preset, removeButton(() => pageUsePresets.hide(preset.task))))}
3890
+ </div>`)}
3891
+ <div class="row preset-restore">
3892
+ <span class="hint preset-hidden-count">已隐藏 ${hiddenCount} 条</span>
3893
+ <button
3894
+ type="button"
3895
+ class="btn tiny push-right preset-restore-btn"
3896
+ ?disabled=${hiddenCount === 0}
3897
+ @click=${() => pageUsePresets.restoreHidden()}
3898
+ >
3899
+ 恢复默认指令
3900
+ </button>
2448
3901
  </div>
2449
- <span class="meta">端点 ${this.endpointLabel()} · 模型 ${config?.llm.model ?? "-"}</span>
3902
+ </div>
3903
+ </div>`;
3904
+ }
3905
+ /**
3906
+ * 演示播报:总开关关闭时只显示开关;开启后依次为引擎下拉(可选引擎多于一个时)、
3907
+ * 语速、音色下拉与试听按钮(无音色列表时只有试听按钮)、不可用 / 无中文语音提示。
3908
+ */
3909
+ renderVoiceSettings(settings) {
3910
+ const toggle = this.renderCheckbox("voiceEnabled", "语音播报", "演示时播报执行步骤、提问与结论");
3911
+ if (!settings.voiceEnabled) return toggle;
3912
+ const engines = this.ttsEngines();
3913
+ const engine = this.ttsEngine();
3914
+ const isBrowser = engine.id === BROWSER_ENGINE_ID;
3915
+ const available = engine.isAvailable();
3916
+ const voices = available ? engine.listVoices?.() ?? [] : [];
3917
+ const voiceName = settings.voiceNames[engine.id] ?? "";
3918
+ const { playing } = previewStore.get();
3919
+ const running = isRunning(pageUseStore.get());
3920
+ const previewButton = html`<button
3921
+ type="button"
3922
+ class="btn preview-btn"
3923
+ ?disabled=${!available || running}
3924
+ title=${running ? "任务执行中不可试听" : "用当前引擎、音色与语速试听"}
3925
+ @click=${() => playing ? stopPreview() : startPreview(engine, {
3926
+ rate: settings.voiceRate,
3927
+ voice: voiceName
3928
+ })}
3929
+ >
3930
+ ${playing ? "停止" : "试听"}
3931
+ </button>`;
3932
+ const warning = !available ? isBrowser ? "当前浏览器不支持语音合成,不会播报" : `当前环境不支持${engine.label},不会播报` : isBrowser && !voices.length ? "未检测到中文语音,将使用浏览器默认语音播报" : "";
3933
+ return html`
3934
+ ${toggle}
3935
+ <div class="voice-options">
3936
+ ${engines.length > 1 ? html`<label class="field">
3937
+ <span class="field-label">播报引擎</span>
3938
+ <select
3939
+ class="input voice-engine-select"
3940
+ @change=${(e) => {
3941
+ stopPreview();
3942
+ pageUseSettings.update({ voiceEngineId: e.target.value });
3943
+ }}
3944
+ >
3945
+ ${engines.map((item) => {
3946
+ const usable = item.isAvailable();
3947
+ return html`<option
3948
+ value=${item.id}
3949
+ ?selected=${item.id === engine.id}
3950
+ ?disabled=${!usable}
3951
+ >
3952
+ ${item.label}${usable ? "" : "(不可用)"}
3953
+ </option>`;
3954
+ })}
3955
+ </select>
3956
+ </label>` : nothing}
3957
+ ${available ? html`<label class="field">
3958
+ <span class="field-label">语速 ${settings.voiceRate.toFixed(2)}</span>
3959
+ <input
3960
+ class="range"
3961
+ type="range"
3962
+ min=${VOICE_RATE_MIN}
3963
+ max=${VOICE_RATE_MAX}
3964
+ step=${VOICE_RATE_STEP}
3965
+ .value=${String(settings.voiceRate)}
3966
+ @input=${(e) => pageUseSettings.update({ voiceRate: Number(e.target.value) })}
3967
+ />
3968
+ </label>` : nothing}
3969
+ ${voices.length ? html`<div class="field">
3970
+ <span class="field-label">音色</span>
3971
+ <div class="voice-name-row">
3972
+ <select
3973
+ class="input voice-name-select"
3974
+ title=${voices.find((voice) => voice.value === voiceName)?.label ?? ""}
3975
+ @change=${(e) => {
3976
+ stopPreview();
3977
+ pageUseSettings.update({ voiceNames: {
3978
+ ...pageUseSettings.get().voiceNames,
3979
+ [engine.id]: e.target.value
3980
+ } });
3981
+ }}
3982
+ >
3983
+ <option value="" ?selected=${!voiceName}>
3984
+ ${isBrowser ? "自动(第一个中文语音)" : "自动"}
3985
+ </option>
3986
+ ${voices.map((voice) => html`<option value=${voice.value} ?selected=${voice.value === voiceName}>
3987
+ ${voice.label}
3988
+ </option>`)}
3989
+ </select>
3990
+ ${previewButton}
3991
+ </div>
3992
+ </div>` : html`<div class="voice-name-row">${previewButton}</div>`}
3993
+ ${warning ? html`<div class="alert warning voice-warning">${warning}</div>` : nothing}
3994
+ </div>
3995
+ `;
3996
+ }
3997
+ /** 元信息:与业务无关的配置与运行环境信息,仅供开发排查;API Key 只显示是否已配置 */
3998
+ renderMeta() {
3999
+ const config = getPageUseConfig();
4000
+ const settings = pageUseSettings.get();
4001
+ const engine = this.ttsEngine();
4002
+ const voiceName = settings.voiceNames[engine.id] || "自动";
4003
+ const rows = [
4004
+ ["版本", `v${version}`],
4005
+ ["端点", config?.llm.baseURL || "未配置"],
4006
+ ["模型", config?.llm.model || "未配置"],
4007
+ ["API Key", config?.llm.apiKey ? "已配置" : "未配置"],
4008
+ ["存储键前缀", config?.storageKeyPrefix || "未配置"],
4009
+ ["当前引擎", `${engine.label}(${engine.id}) · 音色 ${voiceName}`]
4010
+ ];
4011
+ return html`<div class="fill">
4012
+ <div class="settings-body">
4013
+ <dl class="meta-list">
4014
+ ${rows.map(([label, value]) => html`<dt>${label}</dt><dd>${value}</dd>`)}
4015
+ </dl>
4016
+ <div class="field-label">可选引擎</div>
4017
+ <ul class="meta-engines">
4018
+ ${this.ttsEngines().map((item) => html`<li>${item.id} · ${item.label} · ${item.isAvailable() ? "可用" : "不可用"}</li>`)}
4019
+ </ul>
2450
4020
  </div>
2451
4021
  </div>`;
2452
4022
  }
@@ -2608,9 +4178,11 @@ var PageUseLauncherElement = class extends LitElement {
2608
4178
  render() {
2609
4179
  const state = pageUseStore.get();
2610
4180
  const settings = pageUseSettings.get();
4181
+ if (!this.isVisible(settings)) return nothing;
2611
4182
  const running = isRunning(state);
2612
4183
  const title = this._config.title || "Page Use";
2613
4184
  const elapsed = getElapsedSeconds(state);
4185
+ const { muted } = narrationMute.get();
2614
4186
  const isLogEmpty = !running && !state.currentTask && !state.steps.length && !state.errorMessage;
2615
4187
  return html`
2616
4188
  <button
@@ -2618,36 +4190,56 @@ var PageUseLauncherElement = class extends LitElement {
2618
4190
  class="fab ${running ? "running" : ""}"
2619
4191
  ?hidden=${this.open}
2620
4192
  style=${this.fabStyle(settings)}
2621
- title=${running ? `执行中:${state.activityText || state.currentTask}` : `打开 ${title} 页面操作`}
4193
+ title=${running ? `执行中:${state.activityText || state.currentTask}` : `打开 ${title}`}
2622
4194
  @pointerdown=${this.fabDrag.onPointerDown}
2623
4195
  >
2624
- <span class="fab-dot"></span>
4196
+ <span class="fab-icon">${robotIcon}</span>
2625
4197
  <span>${running ? "执行中" : title}</span>
2626
4198
  </button>
2627
4199
 
2628
- <section class="panel" ?hidden=${!this.open} style=${this.open ? this.panelStyle(settings) : ""}>
4200
+ <section
4201
+ class="panel ${running ? "running" : ""}"
4202
+ ?hidden=${!this.open}
4203
+ style=${this.open ? this.panelStyle(settings) : ""}
4204
+ >
2629
4205
  <header class="head" @pointerdown=${this.panelDrag.onPointerDown}>
2630
- <span class="dot ${state.status}"></span>
4206
+ <span class="dot ${state.status}" title=${STATUS_LABELS[state.status] || state.status}></span>
2631
4207
  <span class="title">${title}</span>
2632
4208
  <span class="status">
2633
- ${STATUS_LABELS[state.status] || state.status}${state.steps.length ? ` · ${state.steps.length} 步` : ""}${state.totalTokens ? ` · ${state.totalTokens} tok` : ""}${elapsed ? ` · ${elapsed}s` : ""}
4209
+ ${state.status === "idle" ? "" : `${STATUS_LABELS[state.status] || state.status}${state.steps.length ? ` · ${state.steps.length} 步` : ""}${state.totalTokens ? ` · ${formatTokens(state.totalTokens)} tok` : ""}${elapsed ? ` · ${elapsed}s` : ""}`}
2634
4210
  </span>
2635
- <button
2636
- type="button"
2637
- class="btn ghost circle ${this.showSettings ? "active" : ""}"
2638
- title=${this.showSettings ? "返回" : "设置"}
2639
- @click=${() => {
2640
- this.showSettings = !this.showSettings;
2641
- this.requestUpdate();
2642
- }}
2643
- >
2644
- ⚙
2645
- </button>
2646
- <button type="button" class="btn ghost circle" title="收起" @click=${() => void this.collapse()}>
2647
- −
2648
- </button>
4211
+ <div class="head-actions">
4212
+ ${running && settings.voiceEnabled ? html`<button
4213
+ type="button"
4214
+ class="icon-btn mute ${muted ? "active" : ""}"
4215
+ title=${muted ? "恢复播报" : "本次任务静音"}
4216
+ @click=${() => narrationMute.set({ muted: !muted })}
4217
+ >
4218
+ ${muted ? mutedIcon : volumeIcon}
4219
+ </button>` : nothing}
4220
+ <button
4221
+ type="button"
4222
+ class="icon-btn settings-toggle ${this.view === "settings" ? "active" : ""}"
4223
+ title=${this.view === "main" ? "设置" : "返回"}
4224
+ @click=${() => this.switchView(this.view === "main" ? "settings" : PARENT_VIEW[this.view])}
4225
+ >
4226
+ ${this.view === "main" ? settingsIcon : backIcon}
4227
+ </button>
4228
+ <button type="button" class="icon-btn" title="收起" @click=${() => void this.collapse()}>
4229
+ ${collapseIcon}
4230
+ </button>
4231
+ <button
4232
+ type="button"
4233
+ class="icon-btn close"
4234
+ title="关闭(可调用 showPageUseLauncher 或加 URL 参数 pageUseDebug=1 重新打开)"
4235
+ @click=${() => this.close()}
4236
+ >
4237
+ ${closeIcon}
4238
+ </button>
4239
+ </div>
2649
4240
  </header>
2650
- ${this.showSettings ? this.renderSettings() : html`${this.renderLog(state, isLogEmpty)}${this.renderComposer(state, isLogEmpty)}`}
4241
+ ${this.view === "settings" ? this.renderSettings() : this.view === "presets" ? this.renderPresetEditor() : this.view === "meta" ? this.renderMeta() : html`${this.renderLog(state, isLogEmpty)}${this.renderComposer(state, isLogEmpty)}`}
4242
+ ${this.toastText ? html`<div class="toast" role="status">${this.toastText}</div>` : nothing}
2651
4243
  </section>
2652
4244
  `;
2653
4245
  }
@@ -2690,4 +4282,4 @@ function mountPageUseUI(options = {}) {
2690
4282
  }
2691
4283
 
2692
4284
  //#endregion
2693
- export { DEFAULT_MODEL as A, pageUseStore as C, SIMULATOR_MASK_ID as D, PAGE_USE_MASK_COLORS as E, DEFAULT_STORAGE_KEY_PREFIX as M, normalizeOptions as N, pageUseHistory as O, readLauncherSwitch as P, isRunning as S, pageUseSettings as T, pageUseCapability as _, PAGE_USE_STATIC_PRESETS as a, getPageUseConfig as b, PageUseGlowElement as c, PAGE_USE_COMMAND_TYPE as d, PAGE_USE_TOOL as f, runPageUseTool as g, isPageUseCommand as h, PageUseLauncherElement as i, DEFAULT_SESSION_HEADER as j, DEFAULT_MAX_STEPS as k, createPageUse as l, formatPageUseResultForModel as m, mountPageUseUI as n, buildMenuPresets as o, PAGE_USE_TOOL_NAME as p, PAGE_USE_LAUNCHER_TAG as r, PAGE_USE_GLOW_TAG as s, definePageUseElements as t, PAGE_USE_GLOBAL_KEY as u, clearPageUseLog as v, DEFAULT_PAGE_USE_SETTINGS as w, isPageUseConfigured as x, getElapsedSeconds as y };
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 };