@tanzerfe/page-use 0.0.4 → 0.0.6

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,75 @@
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
5
73
  //#region src/core/maskTheme.ts
6
74
  /**
7
75
  * 运行期蒙层与模拟光标的定制主题(覆盖 page-agent SimulatorMask 的默认视觉)。
@@ -93,6 +161,38 @@ ${CURSOR}[class*='clicking'] > :nth-child(1)::before {
93
161
  }
94
162
  `;
95
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
+
96
196
  //#endregion
97
197
  //#region src/core/options.ts
98
198
  /** 默认模型,仅为示例取值;接入其它端点时应通过 llm.model 显式指定支持 function calling 的模型 */
@@ -147,12 +247,14 @@ function normalizeOptions(options) {
147
247
  beforeTaskLabel: options.beforeTaskLabel || "任务开始前执行宿主预处理",
148
248
  storageKeyPrefix: options.storageKeyPrefix || "tz-page-use",
149
249
  pageAgentOptions: options.pageAgentOptions ?? {},
150
- exposeGlobal: options.exposeGlobal !== false
250
+ exposeGlobal: options.exposeGlobal !== false,
251
+ ttsEngines: listTtsEngines(options.tts)
151
252
  };
152
253
  }
153
254
  /** URL 查询参数:?pageUseDebug=1 打开调试面板,?pageUseDebug=0 关闭;结果记入 sessionStorage */
154
255
  const LAUNCHER_QUERY_KEY = "pageUseDebug";
155
256
  /** 从 location 读取调试开关(hash 路由的查询参数在 # 之后,两处都查) */
257
+ /** 读取 URL 中的 pageUseDebug 参数(search 或 hash 内的查询串);未携带时为 null。供 launcher 判定显示状态 */
156
258
  function readLauncherQuery() {
157
259
  const fromSearch = new URLSearchParams(window.location.search).get(LAUNCHER_QUERY_KEY);
158
260
  if (fromSearch !== null) return fromSearch;
@@ -220,7 +322,7 @@ function createStore(initial) {
220
322
  //#region src/core/settings.ts
221
323
  /**
222
324
  * Page Use 本地设置(localStorage 持久化,单例)。
223
- * 含 page-agent 内置面板/元素编号高亮开关、蒙层与光晕、演示播报、调试入口拖拽位置。
325
+ * 含 page-agent 内置面板/元素编号高亮开关、蒙层与光晕、演示播报、调试入口拖拽位置与显示状态。
224
326
  * 存储键为 `${storageKeyPrefix}-settings`。
225
327
  */
226
328
  /** page-agent 元素高亮容器 id(page-controller 内部常量,随版本可能变化) */
@@ -239,7 +341,9 @@ const DEFAULT_PAGE_USE_SETTINGS = {
239
341
  customMaskEffect: true,
240
342
  voiceEnabled: false,
241
343
  voiceRate: 1.1,
242
- voiceName: "",
344
+ voiceEngineId: "",
345
+ voiceNames: {},
346
+ launcherVisible: null,
243
347
  fabPos: null
244
348
  };
245
349
  /**
@@ -251,16 +355,25 @@ const LOCKED_SETTINGS = {
251
355
  showBuiltinPanel: false,
252
356
  customMaskEffect: true
253
357
  };
254
- function storageKey$1(prefix) {
358
+ function storageKey$2(prefix) {
255
359
  return `${prefix}-settings`;
256
360
  }
257
- function load$1(prefix) {
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) {
258
371
  try {
259
- const raw = window.localStorage.getItem(storageKey$1(prefix));
372
+ const raw = window.localStorage.getItem(storageKey$2(prefix));
260
373
  if (!raw) return { ...DEFAULT_PAGE_USE_SETTINGS };
261
374
  return {
262
375
  ...DEFAULT_PAGE_USE_SETTINGS,
263
- ...JSON.parse(raw),
376
+ ...migrate(JSON.parse(raw)),
264
377
  ...LOCKED_SETTINGS
265
378
  };
266
379
  } catch {
@@ -269,7 +382,7 @@ function load$1(prefix) {
269
382
  }
270
383
  function save(prefix, settings) {
271
384
  try {
272
- window.localStorage.setItem(storageKey$1(prefix), JSON.stringify(settings));
385
+ window.localStorage.setItem(storageKey$2(prefix), JSON.stringify(settings));
273
386
  } catch {}
274
387
  }
275
388
  /** 按开关注入/移除一段覆盖样式(用于压制 page-agent 自带的 DOM,这些节点位于宿主 document 中) */
@@ -297,12 +410,12 @@ function applyToDocument(settings, prev) {
297
410
  if (!prev || prev.showBuiltinPanel !== settings.showBuiltinPanel) toggleOverrideStyle(BUILTIN_PANEL_STYLE_ID, `#${BUILTIN_PANEL_ID}{display:none !important;}`, settings.showBuiltinPanel);
298
411
  if (!prev || prev.customMaskEffect !== settings.customMaskEffect) toggleOverrideStyle(MASK_THEME_STYLE_ID, PAGE_USE_MASK_THEME_CSS, !settings.customMaskEffect);
299
412
  }
300
- let prefix$1 = DEFAULT_STORAGE_KEY_PREFIX;
301
- let store$2 = null;
413
+ let prefix$2 = DEFAULT_STORAGE_KEY_PREFIX;
414
+ let store$4 = null;
302
415
  /** 懒创建:首次访问时读取存储并把设置应用到页面 */
303
- function ensureStore$1() {
304
- if (store$2) return store$2;
305
- const created = createStore(load$1(prefix$1));
416
+ function ensureStore$2() {
417
+ if (store$4) return store$4;
418
+ const created = createStore(load$2(prefix$2));
306
419
  let prev = null;
307
420
  const sync = (next) => {
308
421
  applyToDocument(next, prev);
@@ -310,94 +423,123 @@ function ensureStore$1() {
310
423
  };
311
424
  sync(created.get());
312
425
  created.subscribe((next) => {
313
- save(prefix$1, next);
426
+ save(prefix$2, next);
314
427
  sync(next);
315
428
  });
316
- store$2 = created;
429
+ store$4 = created;
317
430
  return created;
318
431
  }
319
432
  /** 切换存储键前缀(createPageUse 时调用);前缀变化时重新读取 */
320
433
  function setSettingsStoragePrefix(next) {
321
- if (next === prefix$1 && store$2) return;
322
- prefix$1 = next;
323
- if (store$2) store$2.replace(load$1(prefix$1));
324
- else ensureStore$1();
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();
325
438
  }
326
439
  const pageUseSettings = {
327
440
  store: {
328
- get: () => ensureStore$1().get(),
329
- subscribe: (listener) => ensureStore$1().subscribe(listener)
441
+ get: () => ensureStore$2().get(),
442
+ subscribe: (listener) => ensureStore$2().subscribe(listener)
330
443
  },
331
444
  get() {
332
- return ensureStore$1().get();
445
+ return ensureStore$2().get();
333
446
  },
334
447
  /** 固定项(LOCKED_SETTINGS)的修改被忽略 */
335
448
  update(patch) {
336
- ensureStore$1().set({
449
+ ensureStore$2().set({
337
450
  ...patch,
338
451
  ...LOCKED_SETTINGS
339
452
  });
340
453
  },
341
454
  /** 把浮动入口(及随之定位的面板)恢复到默认角落位置 */
342
455
  resetPositions() {
343
- ensureStore$1().set({ fabPos: null });
456
+ ensureStore$2().set({ fabPos: null });
344
457
  }
345
458
  };
346
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
+
347
504
  //#endregion
348
505
  //#region src/voice/speaker.ts
349
506
  /**
350
- * 浏览器语音合成(speechSynthesis)封装:演示播报的播放队列。
507
+ * 演示播报的播放队列,发声委托给当前语音合成引擎(由 getEngine 提供,可在播报间切换)。
351
508
  *
352
- * 队列只有两部分:当前正在播报的一组句子,以及一条待播的步骤文本。
509
+ * 队列只有两部分:当前正在播报的一组文本,以及一条待播的步骤文本。
353
510
  * - 步骤播报不打断当前播报,只覆盖待播槽(语音跟不上执行时丢弃中间步骤);
354
511
  * - 提问 / 结论先清空再播报;
355
- * - 每次清空递增代次,旧代次的 onend / onerror / 超时回调一律忽略,避免 cancel() 后迟到的回调误触发下一条。
512
+ * - 每组开始时读取一次引擎并在组内固定使用(splitSentences 与中止对象保持一致);
513
+ * - 每组持有一个 AbortController,清空时中止,引擎据此停止自身的合成与播放;
514
+ * - 每次清空递增代次,旧代次 speak() 的迟到 resolve 一律忽略,避免中止后误触发下一条。
356
515
  *
357
- * 不支持语音合成的环境中所有方法静默返回。
516
+ * 当前引擎不可用时 enqueue / interrupt 静默返回。
358
517
  */
359
- /** 播报语言 */
360
- const SPEECH_LANG = "zh-CN";
361
- /** 超时兜底的语速估算:中文语音在 rate=1 时约每秒 4 字 */
362
- const CHARS_PER_SECOND = 4;
363
- /** 超时兜底的余量(毫秒):覆盖语音引擎启动延迟;onend 不触发时按估算时长 + 余量视为播完 */
364
- const TIMEOUT_MARGIN_MS = 2e3;
365
- /** 当前环境是否支持语音合成 */
366
- function isSpeechSupported() {
367
- return typeof globalThis.speechSynthesis !== "undefined" && typeof globalThis.SpeechSynthesisUtterance !== "undefined";
368
- }
369
- /** 可用的中文语音(lang 以 zh 开头,兼容 zh-CN / zh_CN / zh-HK 等写法);Chrome 首次调用可能为空,需监听 voiceschanged */
370
- function getChineseVoices() {
371
- if (!isSpeechSupported()) return [];
372
- return globalThis.speechSynthesis.getVoices().filter((voice) => /^zh(?:$|[-_])/i.test(voice.lang));
373
- }
374
- /** 语音选择:指定名称 → 第一个中文语音 → undefined(浏览器默认语音) */
375
- function resolveVoice(voiceName) {
376
- if (!isSpeechSupported()) return void 0;
377
- const voices = globalThis.speechSynthesis.getVoices();
378
- return voiceName && voices.find((voice) => voice.name === voiceName) || getChineseVoices()[0];
379
- }
380
- function createSpeaker(getSettings) {
518
+ /**
519
+ * @param getSettings 每次调用引擎前读取;参数为当前组使用的引擎,调用方据此取该引擎的音色
520
+ * @param getEngine 每组开始时读取当前生效引擎
521
+ */
522
+ function createSpeaker(getSettings, getEngine = () => browserTtsEngine) {
381
523
  let generation = 0;
382
- /** 当前组剩余的句子;null 表示空闲 */
524
+ /** 当前组剩余的待调用文本;null 表示空闲 */
383
525
  let current = null;
384
526
  let pending = null;
385
- let timer = null;
386
- function clearTimer() {
387
- if (timer !== null) {
388
- clearTimeout(timer);
389
- timer = null;
390
- }
391
- }
527
+ /** 当前组的中止控制;空闲时为 null,clear 据此判断是否需要中止引擎 */
528
+ let controller = null;
529
+ /** 当前组使用的引擎;组内不随设置切换 */
530
+ let engine = getEngine();
392
531
  function playGroup(sentences) {
393
- current = [...sentences];
532
+ engine = getEngine();
533
+ if (!engine.isAvailable()) return;
534
+ current = engine.splitSentences ? [...sentences] : [sentences.join("")];
535
+ controller = new AbortController();
394
536
  playNext();
395
537
  }
396
538
  function playNext() {
397
- clearTimer();
398
- const sentence = current?.shift();
399
- if (sentence === void 0) {
539
+ const text = current?.shift();
540
+ if (text === void 0 || !controller) {
400
541
  current = null;
542
+ controller = null;
401
543
  if (pending) {
402
544
  const next = pending;
403
545
  pending = null;
@@ -405,35 +547,32 @@ function createSpeaker(getSettings) {
405
547
  }
406
548
  return;
407
549
  }
408
- const { rate, voiceName } = getSettings();
409
- const utterance = new SpeechSynthesisUtterance(sentence);
410
- utterance.lang = SPEECH_LANG;
411
- utterance.rate = rate > 0 ? rate : 1;
412
- const voice = resolveVoice(voiceName);
413
- if (voice) utterance.voice = voice;
414
550
  const owner = generation;
415
- let settled = false;
416
- const finish = () => {
417
- if (settled || owner !== generation) return;
418
- settled = true;
419
- playNext();
420
- };
421
- utterance.onend = finish;
422
- utterance.onerror = finish;
423
- timer = setTimeout(finish, sentence.length / (CHARS_PER_SECOND * utterance.rate) * 1e3 + TIMEOUT_MARGIN_MS);
424
- globalThis.speechSynthesis.speak(utterance);
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
+ });
425
565
  }
426
566
  function clear() {
427
- const active = current !== null;
428
567
  generation += 1;
568
+ controller?.abort();
569
+ controller = null;
429
570
  current = null;
430
571
  pending = null;
431
- clearTimer();
432
- if (active && isSpeechSupported()) globalThis.speechSynthesis.cancel();
433
572
  }
434
573
  return {
435
574
  enqueue(sentences) {
436
- if (!isSpeechSupported() || !sentences.length) return;
575
+ if (!sentences.length) return;
437
576
  if (current !== null) {
438
577
  pending = sentences;
439
578
  return;
@@ -441,7 +580,6 @@ function createSpeaker(getSettings) {
441
580
  playGroup(sentences);
442
581
  },
443
582
  interrupt(sentences) {
444
- if (!isSpeechSupported()) return;
445
583
  clear();
446
584
  if (sentences.length) playGroup(sentences);
447
585
  },
@@ -455,10 +593,15 @@ function createSpeaker(getSettings) {
455
593
  * 播报文本清洗(纯函数,无 DOM 依赖):把模型输出的步骤描述 / 结论转成适合语音合成的句子数组。
456
594
  * 结果只用于播报,不回写任务结果与面板展示内容。
457
595
  */
458
- /** 单条播报清洗后的字数上限:中文语音约 4 字/秒,120 字约 30 秒 */
459
- const SPEECH_MAX_CHARS = 120;
460
- /** 超过上限截断时追加的提示语 */
596
+ /** 单条播报清洗后的字数上限:中文语音约 4 字/秒,240 字约 1 分钟(语速 1 时) */
597
+ const SPEECH_MAX_CHARS = 240;
598
+ /** 超过上限截断时追加的提示语(未挂载调试面板、不知道面板名称时使用) */
461
599
  const SPEECH_TRUNCATED_HINT = "详细内容请查看面板";
600
+ /** 截断提示语:有面板名称(调试面板标题)时指明名称,如「详细内容请查看「页面智能体」」 */
601
+ function buildTruncatedHint(panelName) {
602
+ const name = panelName?.trim();
603
+ return name ? `详细内容请查看「${name}」` : SPEECH_TRUNCATED_HINT;
604
+ }
462
605
  /**
463
606
  * 单次合成的字数上限:Chrome 的在线语音单条超过约 15 秒会被截断,
464
607
  * 超过该长度的句子再按逗号等停顿切分(约 4 字/秒 × 12 秒)
@@ -526,7 +669,7 @@ function joinLines(lines) {
526
669
  return result.replace(/([。!?!?;;,,、::])[。!?!?;;,,、::\s]+/g, "$1").replace(/^[。!?!?;;,,、::\s]+/, "").trim();
527
670
  }
528
671
  /** 超过上限时在上限内最后一个句末标点处截断,找不到则硬截断,并追加提示语 */
529
- function truncate(text, maxChars) {
672
+ function truncate(text, maxChars, hint) {
530
673
  if (text.length <= maxChars) return text;
531
674
  const head = text.slice(0, maxChars);
532
675
  let cut = -1;
@@ -537,7 +680,7 @@ function truncate(text, maxChars) {
537
680
  let kept = cut > 0 ? head.slice(0, cut + 1) : head;
538
681
  kept = kept.replace(/[;;,,、::]$/, "");
539
682
  if (!/[。!?!?]$/.test(kept)) kept += "。";
540
- return `${kept}${SPEECH_TRUNCATED_HINT}`;
683
+ return `${kept}${hint}`;
541
684
  }
542
685
  /** 按句末标点切句;过长的句子再按逗号等停顿切分 */
543
686
  function splitSentences(text) {
@@ -566,10 +709,10 @@ function splitSentences(text) {
566
709
  * 处理顺序:代码块 / HTML → 表格 → 行内 Markdown 与符号 → 拼行 → 截断 → 切句。
567
710
  */
568
711
  function toSpeechText(text, options = {}) {
569
- const maxChars = options.maxChars ?? 120;
712
+ const maxChars = options.maxChars ?? 240;
570
713
  const joined = joinLines(toLines((text || "").replace(/```[\s\S]*?(?:```|$)/g, "\n").replace(/<\/?[a-zA-Z][^>]*>/g, "")));
571
714
  if (!joined) return [];
572
- return splitSentences(truncate(joined, maxChars));
715
+ return splitSentences(truncate(joined, maxChars, options.truncatedHint ?? "详细内容请查看面板"));
573
716
  }
574
717
 
575
718
  //#endregion
@@ -577,6 +720,7 @@ function toSpeechText(text, options = {}) {
577
720
  /**
578
721
  * 演示播报:订阅进度事件,按设置把步骤描述、提问与结论交给 speaker 播报。
579
722
  * 只订阅事件,不影响任务执行节奏;对所有任务来源生效。
723
+ * 是否播报 = 设置 voiceEnabled(总开关,持久化)且未处于本次任务静音(narrationMute,运行期)。
580
724
  */
581
725
  /**
582
726
  * 不播报的步骤:滚动、等待没有演示价值;done 由结论播报替代;
@@ -591,40 +735,65 @@ const SILENT_STEP_TOOLS = /* @__PURE__ */ new Set([
591
735
  "error",
592
736
  "retry"
593
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 });
594
755
  /** 启动播报,返回停止函数(取消订阅并停止播报) */
595
- function startNarration({ subscribe, speaker: injected }) {
596
- const speaker = injected || createSpeaker(() => {
597
- const { voiceRate, voiceName } = pageUseSettings.get();
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();
598
760
  return {
599
761
  rate: voiceRate,
600
- voiceName
762
+ voiceName: voiceNames[engine.id] ?? ""
601
763
  };
602
- });
603
- const enabled = () => pageUseSettings.get().voiceEnabled;
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) });
604
767
  const offEvents = subscribe((event) => {
605
768
  switch (event.type) {
606
769
  case "started":
770
+ narrationMute.set({ muted: false });
771
+ stopPreview();
607
772
  speaker.clear();
608
773
  break;
609
774
  case "step":
610
775
  if (!enabled() || SILENT_STEP_TOOLS.has(event.step.tool)) return;
611
- speaker.enqueue(toSpeechText(event.step.goal));
776
+ speaker.enqueue(speech(event.step.goal));
612
777
  break;
613
778
  case "question":
614
- if (enabled()) speaker.interrupt(toSpeechText(event.question));
779
+ if (enabled()) speaker.interrupt(speech(event.question));
615
780
  break;
616
781
  case "finished": if (event.result.status === "stopped" || !enabled()) speaker.clear();
617
- else speaker.interrupt(toSpeechText(event.result.summary));
782
+ else speaker.interrupt(speech(event.result.summary));
618
783
  }
619
784
  });
620
- let wasEnabled = enabled();
785
+ let prev = pageUseSettings.get();
621
786
  const offSettings = pageUseSettings.store.subscribe((settings) => {
622
- if (wasEnabled && !settings.voiceEnabled) speaker.clear();
623
- wasEnabled = settings.voiceEnabled;
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();
624
792
  });
625
793
  return () => {
626
794
  offEvents();
627
795
  offSettings();
796
+ offMute();
628
797
  speaker.clear();
629
798
  };
630
799
  }
@@ -819,56 +988,186 @@ function createPageUseAgent({ options, enableMask, narration = false, shouldRunB
819
988
  * 用于快速重放上次指令,以及查看任务结果。存储键为 `${storageKeyPrefix}-history`。
820
989
  */
821
990
  const MAX_ENTRIES = 30;
822
- function storageKey(prefix) {
991
+ function storageKey$1(prefix) {
823
992
  return `${prefix}-history`;
824
993
  }
825
- function load(prefix) {
994
+ function load$1(prefix) {
826
995
  try {
827
- const raw = window.localStorage.getItem(storageKey(prefix));
996
+ const raw = window.localStorage.getItem(storageKey$1(prefix));
828
997
  const parsed = raw ? JSON.parse(raw) : [];
829
998
  return Array.isArray(parsed) ? parsed : [];
830
999
  } catch {
831
1000
  return [];
832
1001
  }
833
1002
  }
834
- let prefix = DEFAULT_STORAGE_KEY_PREFIX;
1003
+ let prefix$1 = DEFAULT_STORAGE_KEY_PREFIX;
835
1004
  /** 懒创建:避免模块加载即访问 window(SSR / 测试环境) */
836
- let store$1 = null;
837
- function ensureStore() {
838
- if (!store$1) store$1 = createStore({ entries: load(prefix) });
839
- return store$1;
1005
+ let store$2 = null;
1006
+ function ensureStore$1() {
1007
+ if (!store$2) store$2 = createStore({ entries: load$1(prefix$1) });
1008
+ return store$2;
840
1009
  }
841
- function persist(entries) {
842
- ensureStore().set({ entries });
1010
+ function persist$1(entries) {
1011
+ ensureStore$1().set({ entries });
843
1012
  try {
844
- window.localStorage.setItem(storageKey(prefix), JSON.stringify(entries));
1013
+ window.localStorage.setItem(storageKey$1(prefix$1), JSON.stringify(entries));
845
1014
  } catch {}
846
1015
  }
847
1016
  /** 切换存储键前缀(createPageUse 时调用);前缀变化时重新读取 */
848
1017
  function setHistoryStoragePrefix(next) {
849
- if (next === prefix && store$1) return;
850
- prefix = next;
851
- ensureStore().replace({ entries: load(prefix) });
1018
+ if (next === prefix$1 && store$2) return;
1019
+ prefix$1 = next;
1020
+ ensureStore$1().replace({ entries: load$1(prefix$1) });
852
1021
  }
853
1022
  const pageUseHistory = {
854
1023
  store: {
855
- get: () => ensureStore().get(),
856
- subscribe: (listener) => ensureStore().subscribe(listener)
1024
+ get: () => ensureStore$1().get(),
1025
+ subscribe: (listener) => ensureStore$1().subscribe(listener)
857
1026
  },
858
1027
  entries() {
859
- return ensureStore().get().entries;
1028
+ return ensureStore$1().get().entries;
860
1029
  },
861
1030
  add(entry) {
862
- persist([{
1031
+ persist$1([{
863
1032
  id: `${entry.at}-${Math.random().toString(36).slice(2, 8)}`,
864
1033
  ...entry
865
- }, ...ensureStore().get().entries].slice(0, MAX_ENTRIES));
1034
+ }, ...ensureStore$1().get().entries].slice(0, MAX_ENTRIES));
866
1035
  },
867
1036
  remove(id) {
868
- persist(ensureStore().get().entries.filter((item) => item.id !== id));
1037
+ persist$1(ensureStore$1().get().entries.filter((item) => item.id !== id));
869
1038
  },
870
1039
  clear() {
871
- persist([]);
1040
+ persist$1([]);
1041
+ }
1042
+ };
1043
+
1044
+ //#endregion
1045
+ //#region src/core/presetStore.ts
1046
+ /**
1047
+ * Page Use 快捷指令的用户维护数据(localStorage,懒创建单例)。
1048
+ * - custom:用户自定义指令,新增排在最前
1049
+ * - hidden:被用户删除(隐藏)的来源指令(菜单生成 / 内置 / 宿主 presets / scenarios)的 task 文本
1050
+ * 来源指令本身不落盘;宿主修改 task 后旧的隐藏记录自然失效,不主动清理。
1051
+ * 存储键为 `${storageKeyPrefix}-presets`。
1052
+ */
1053
+ const EMPTY_STATE = {
1054
+ custom: [],
1055
+ hidden: []
1056
+ };
1057
+ function storageKey(prefix) {
1058
+ return `${prefix}-presets`;
1059
+ }
1060
+ function createId() {
1061
+ return `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
1062
+ }
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
+ };
1080
+ }
1081
+ function load(prefix) {
1082
+ try {
1083
+ const raw = window.localStorage.getItem(storageKey(prefix));
1084
+ return raw ? sanitize(JSON.parse(raw)) : { ...EMPTY_STATE };
1085
+ } catch {
1086
+ return { ...EMPTY_STATE };
1087
+ }
1088
+ }
1089
+ let prefix = DEFAULT_STORAGE_KEY_PREFIX;
1090
+ let store$1 = null;
1091
+ function ensureStore() {
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"
1107
+ };
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 };
1117
+ }
1118
+ /** 切换存储键前缀(createPageUse 时调用);前缀变化时重新读取 */
1119
+ function setPresetsStoragePrefix(next) {
1120
+ if (next === prefix && store$1) return;
1121
+ prefix = next;
1122
+ ensureStore().replace(load(prefix));
1123
+ }
1124
+ const pageUsePresets = {
1125
+ store: {
1126
+ get: () => ensureStore().get(),
1127
+ subscribe: (listener) => ensureStore().subscribe(listener)
1128
+ },
1129
+ get() {
1130
+ return ensureStore().get();
1131
+ },
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;
1142
+ },
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: [] });
872
1171
  }
873
1172
  };
874
1173
 
@@ -1124,9 +1423,13 @@ function configurePageUse(options) {
1124
1423
  everConfigured = true;
1125
1424
  setSettingsStoragePrefix(options.storageKeyPrefix);
1126
1425
  setHistoryStoragePrefix(options.storageKeyPrefix);
1426
+ setPresetsStoragePrefix(options.storageKeyPrefix);
1127
1427
  store.set({ ready: Boolean(options.llm.baseURL) });
1128
1428
  stopNarration?.();
1129
- stopNarration = startNarration({ subscribe: subscribePageUse });
1429
+ stopNarration = startNarration({
1430
+ subscribe: subscribePageUse,
1431
+ engines: options.ttsEngines
1432
+ });
1130
1433
  return () => {
1131
1434
  if (config !== options) return;
1132
1435
  config = null;
@@ -1324,6 +1627,39 @@ const pageUseCapability = {
1324
1627
  subscribe: subscribePageUse
1325
1628
  };
1326
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
+
1327
1663
  //#endregion
1328
1664
  //#region src/capability/tool.ts
1329
1665
  /** 工具名;对接方在 system 提示中引用时保持一致 */
@@ -1416,7 +1752,9 @@ function installPageUseGlobal() {
1416
1752
  const api = {
1417
1753
  ...pageUseCapability,
1418
1754
  tool: PAGE_USE_TOOL,
1419
- runTool: runPageUseTool
1755
+ runTool: runPageUseTool,
1756
+ showLauncher: showPageUseLauncher,
1757
+ hideLauncher: hidePageUseLauncher
1420
1758
  };
1421
1759
  window[PAGE_USE_GLOBAL_KEY] = api;
1422
1760
  return () => {
@@ -1718,6 +2056,10 @@ var PageUseGlowElement = class extends LitElement {
1718
2056
  }
1719
2057
  };
1720
2058
 
2059
+ //#endregion
2060
+ //#region package.json
2061
+ var version = "0.0.6";
2062
+
1721
2063
  //#endregion
1722
2064
  //#region src/ui/drag.ts
1723
2065
  /** 判定为拖拽而非点击的最小位移(px) */
@@ -1742,6 +2084,7 @@ function createDrag(options) {
1742
2084
  if (options.skipInteractive !== false) {
1743
2085
  if (event.composedPath()[0]?.closest?.("button, input, textarea, select, a, label, [data-no-drag]")) return;
1744
2086
  }
2087
+ const tapTarget = event.composedPath()[0];
1745
2088
  const handle = event.currentTarget;
1746
2089
  const rect = el.getBoundingClientRect();
1747
2090
  const startX = event.clientX;
@@ -1767,7 +2110,7 @@ function createDrag(options) {
1767
2110
  try {
1768
2111
  handle.releasePointerCapture(event.pointerId);
1769
2112
  } catch {}
1770
- if (!moved) options.onTap?.();
2113
+ if (!moved) options.onTap?.(tapTarget);
1771
2114
  };
1772
2115
  handle.setPointerCapture(event.pointerId);
1773
2116
  handle.addEventListener("pointermove", onMove);
@@ -1787,6 +2130,89 @@ function createDrag(options) {
1787
2130
  };
1788
2131
  }
1789
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
+
1790
2216
  //#endregion
1791
2217
  //#region src/ui/presets.ts
1792
2218
  /** 通用示例:不依赖具体站点,覆盖详情、标签、返回等典型操作 */
@@ -1804,8 +2230,11 @@ const PAGE_USE_STATIC_PRESETS = [
1804
2230
  task: "概括当前页面展示的主要内容与可执行的操作"
1805
2231
  }
1806
2232
  ];
1807
- /** 取前 N 个可见叶子菜单,生成「打开 XX」快捷指令 */
1808
- function buildMenuPresets(menus, limit = 8) {
2233
+ /**
2234
+ * 取前 N 个可见叶子菜单,生成「打开 XX」快捷指令。
2235
+ * exclude 中的 task 被跳过且不占名额,由后续菜单补位(用户隐藏菜单指令时使用)。
2236
+ */
2237
+ function buildMenuPresets(menus, limit = 8, exclude) {
1809
2238
  const out = [];
1810
2239
  const walk = (list) => {
1811
2240
  for (const node of list) {
@@ -1813,15 +2242,51 @@ function buildMenuPresets(menus, limit = 8) {
1813
2242
  if (node.hidden) continue;
1814
2243
  const children = (node.children || []).filter((child) => !child.hidden);
1815
2244
  if (children.length) walk(children);
1816
- else out.push({
1817
- label: node.label,
1818
- task: `打开「${node.label}」页面`
1819
- });
2245
+ else {
2246
+ const task = `打开「${node.label}」页面`;
2247
+ if (!exclude?.has(task)) out.push({
2248
+ label: node.label,
2249
+ task
2250
+ });
2251
+ }
1820
2252
  }
1821
2253
  };
1822
2254
  walk(menus || []);
1823
2255
  return out;
1824
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
+ }
1825
2290
 
1826
2291
  //#endregion
1827
2292
  //#region src/ui/styles.ts
@@ -1840,6 +2305,11 @@ const launcherStyles = css`
1840
2305
  --text-3: #8a94a6;
1841
2306
  --border: #e6eaf0;
1842
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
+ );
1843
2313
  font-family: system-ui, -apple-system, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif;
1844
2314
  font-size: 13px;
1845
2315
  line-height: 1.5;
@@ -1896,59 +2366,181 @@ const launcherStyles = css`
1896
2366
  * 运行期蒙层 z-index 为 2147483641、内置面板为 2147483642;
1897
2367
  * 入口与面板须高于两者,否则任务执行中无法点「终止」或回答模型提问。
1898
2368
  */
2369
+ /*
2370
+ * 入口按钮:多色径向渐变放大到 300%,循环移动 background-position 形成流光;
2371
+ * ::before 在内缩 3px 处叠一层 14% 黑:压暗文字背后的高亮色段(黄、青)以保证白字清晰,
2372
+ * 外圈 3px 保持原色,形成渐变描边质感。
2373
+ * 渐变见 :host 的 --flow-gradient。
2374
+ */
1899
2375
  .fab {
1900
2376
  position: fixed;
1901
2377
  z-index: 2147483643;
1902
2378
  display: flex;
1903
2379
  align-items: center;
1904
2380
  gap: 6px;
1905
- padding: 8px 14px;
2381
+ padding: 8px 16px;
1906
2382
  border: none;
1907
- border-radius: 20px;
1908
- background: var(--primary);
2383
+ border-radius: 999px;
2384
+ background: var(--flow-gradient) 0 0 / 300% 300%;
1909
2385
  color: #fff;
1910
2386
  font: inherit;
1911
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);
1912
2392
  cursor: grab;
1913
2393
  touch-action: none;
1914
- 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;
1915
2412
  }
1916
2413
  .fab:active {
1917
2414
  cursor: grabbing;
1918
2415
  }
2416
+ /* 执行中:流速加快(15s → 10s)并外发光 */
1919
2417
  .fab.running {
1920
- background: var(--warning);
1921
- 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);
1922
2422
  }
1923
- .fab-dot {
1924
- width: 6px;
1925
- height: 6px;
1926
- border-radius: 50%;
1927
- 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
+ }
1928
2439
  }
1929
- .fab.running .fab-dot {
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 {
1930
2463
  animation: blink 1s infinite;
1931
2464
  }
1932
-
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;
2471
+ }
2472
+ @keyframes robot-blink {
2473
+ 0%,
2474
+ 90%,
2475
+ 100% {
2476
+ transform: scaleY(1);
2477
+ }
2478
+ 95% {
2479
+ transform: scaleY(0.15);
2480
+ }
2481
+ }
2482
+ /*
2483
+ * 面板:高透毛玻璃 + 2px 流光边框。
2484
+ * 面板本体为半透明白底并对背后页面做模糊(backdrop-filter);
2485
+ * 边框由 ::before 绘制:整块铺流光渐变,再用遮罩挖掉 content-box,只留外圈 2px,
2486
+ * 因此内部保持透明,不会透出渐变。边框与入口按钮使用同一套渐变与移动轨迹(fab-shining)。
2487
+ */
1933
2488
  .panel {
1934
2489
  position: fixed;
1935
2490
  z-index: 2147483643;
1936
- /* width / height 由 panelStyle 按入口位置计算 */
2491
+ /* width / height 由 panelStyle 按入口位置计算(border-box,内边距计入宽高) */
1937
2492
  display: flex;
1938
2493
  flex-direction: column;
1939
- border-radius: 10px;
1940
- background: #fff;
1941
- 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);
1942
2503
  overflow: hidden;
1943
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
+ }
1944
2534
 
1945
2535
  .head {
1946
2536
  display: flex;
1947
2537
  align-items: center;
1948
2538
  gap: 8px;
1949
2539
  padding: 10px 12px;
1950
- background: var(--bg-soft);
1951
- 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);
1952
2544
  cursor: grab;
1953
2545
  touch-action: none;
1954
2546
  user-select: none;
@@ -1980,6 +2572,8 @@ const launcherStyles = css`
1980
2572
  .title {
1981
2573
  flex: 1 0 auto;
1982
2574
  font-weight: 600;
2575
+ /* 连续点击标题打开元信息面板,避免选中文字 */
2576
+ user-select: none;
1983
2577
  }
1984
2578
  .status {
1985
2579
  min-width: 0;
@@ -1990,6 +2584,48 @@ const launcherStyles = css`
1990
2584
  white-space: nowrap;
1991
2585
  }
1992
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
+
1993
2629
  /* 占满头部与输入区之间的剩余高度(min-height:0 允许在 flex 列中收缩) */
1994
2630
  .fill {
1995
2631
  flex: 1;
@@ -2013,8 +2649,28 @@ const launcherStyles = css`
2013
2649
  color: var(--text-3);
2014
2650
  font-size: 12px;
2015
2651
  }
2016
- .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 {
2017
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;
2018
2674
  font-size: 12px;
2019
2675
  word-break: break-all;
2020
2676
  }
@@ -2263,7 +2919,8 @@ const launcherStyles = css`
2263
2919
  gap: 8px;
2264
2920
  padding: 10px 12px 12px;
2265
2921
  border-top: 1px solid var(--border);
2266
- background: #fafbfc;
2922
+ border-radius: 0 0 10px 10px;
2923
+ background: rgba(250, 251, 252, 0.5);
2267
2924
  }
2268
2925
  /* 悬浮在输入区上沿(以 .composer 为定位参照) */
2269
2926
  .jump-latest {
@@ -2302,7 +2959,6 @@ const launcherStyles = css`
2302
2959
  }
2303
2960
  /* ---------- 演示播报设置 ---------- */
2304
2961
  .voice-warning {
2305
- margin-left: 21px;
2306
2962
  font-size: 12px;
2307
2963
  }
2308
2964
  /* 与复选框文字左对齐(复选框宽 13px + 间距 8px) */
@@ -2312,6 +2968,99 @@ const launcherStyles = css`
2312
2968
  gap: 8px;
2313
2969
  margin-left: 21px;
2314
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
+
2315
3064
  .field {
2316
3065
  display: flex;
2317
3066
  flex-direction: column;
@@ -2329,6 +3078,19 @@ const launcherStyles = css`
2329
3078
  select.input {
2330
3079
  height: 28px;
2331
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
+ }
2332
3094
 
2333
3095
  .input-group {
2334
3096
  display: flex;
@@ -2351,7 +3113,10 @@ const launcherStyles = css`
2351
3113
  overflow-y: auto;
2352
3114
  border: 1px solid var(--border);
2353
3115
  border-radius: 8px;
2354
- background: #fff;
3116
+ /* 浮在执行记录上方,透明度低于面板以保证列表可读 */
3117
+ background: rgba(255, 255, 255, 0.88);
3118
+ -webkit-backdrop-filter: blur(12px);
3119
+ backdrop-filter: blur(12px);
2355
3120
  }
2356
3121
  .history-item {
2357
3122
  display: flex;
@@ -2395,6 +3160,12 @@ const launcherStyles = css`
2395
3160
  opacity: 0.25;
2396
3161
  }
2397
3162
  }
3163
+ @keyframes toast-in {
3164
+ from {
3165
+ opacity: 0;
3166
+ transform: translate(-50%, 6px);
3167
+ }
3168
+ }
2398
3169
  @keyframes spin {
2399
3170
  to {
2400
3171
  transform: rotate(360deg);
@@ -2424,9 +3195,9 @@ const STATUS_LABELS = {
2424
3195
  error: "出错",
2425
3196
  stopped: "已终止"
2426
3197
  };
2427
- /** 入口默认位置:距视口右边/下边的距离(px) */
2428
- const FAB_DEFAULT_RIGHT = 28;
2429
- const FAB_DEFAULT_BOTTOM = 140;
3198
+ /** 入口默认位置:距视口右边/下边的距离(px);面板展开时与入口对齐于同一角,即面板默认贴右下角 */
3199
+ const FAB_DEFAULT_RIGHT = 24;
3200
+ const FAB_DEFAULT_BOTTOM = 24;
2430
3201
  /** 面板宽度(px),与入口对齐及视口夹取计算共用 */
2431
3202
  const PANEL_WIDTH = 380;
2432
3203
  /** 面板期望高度(px),受视口 72vh 与锚点剩余空间约束 */
@@ -2434,7 +3205,25 @@ const PANEL_HEIGHT = 560;
2434
3205
  /** 日志区距底部小于该值(px)视为贴底 */
2435
3206
  const STICK_THRESHOLD = 24;
2436
3207
  const TEXTAREA_MIN_ROWS = 2;
2437
- /** 播报语速调节范围与步长(SpeechSynthesisUtterance.rate;1 为正常语速,过快或过慢时中文语音难以听清) */
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 为正常语速,过快或过慢时中文语音难以听清;注入引擎自行换算为服务参数) */
2438
3227
  const VOICE_RATE_MIN = .5;
2439
3228
  const VOICE_RATE_MAX = 2;
2440
3229
  const VOICE_RATE_STEP = .05;
@@ -2460,13 +3249,28 @@ var PageUseLauncherElement = class extends LitElement {
2460
3249
  }
2461
3250
  set config(value) {
2462
3251
  this._config = value || {};
3252
+ if (this.isConnected) this.syncPanelName();
2463
3253
  this.requestUpdate();
2464
3254
  }
3255
+ /** 撤销本面板登记的播报用面板名称 */
3256
+ unregisterPanelName = null;
3257
+ /** 把面板标题登记为播报截断提示中的面板名称(「详细内容请查看「标题」」) */
3258
+ syncPanelName() {
3259
+ this.unregisterPanelName?.();
3260
+ this.unregisterPanelName = registerNarrationPanelName(this._config.title || "Page Use");
3261
+ }
2465
3262
  open = false;
2466
- showSettings = false;
3263
+ view = "main";
3264
+ /** 标题点击时间戳(毫秒),用于元信息面板解锁的滑动窗口计数;不持久化 */
3265
+ titleTaps = [];
2467
3266
  task = "";
2468
3267
  answer = "";
2469
3268
  tray = null;
3269
+ /** 快捷指令编辑视图中正在编辑的表单;同一时刻只有一个,离开编辑视图时清空 */
3270
+ presetDraft = null;
3271
+ /** 面板内轻提示文字;空串为不显示 */
3272
+ toastText = "";
3273
+ toastTimer = null;
2470
3274
  /**
2471
3275
  * 日志区跟随滚动:默认贴底跟随新内容;用户上翻后停止跟随,
2472
3276
  * 回到底部附近(阈值内)或点击「最新」后恢复。
@@ -2481,8 +3285,8 @@ var PageUseLauncherElement = class extends LitElement {
2481
3285
  * 因此在可见时测量并保留最近一次结果;初值为「Page Use」文案下的近似尺寸。
2482
3286
  */
2483
3287
  fabSize = {
2484
- width: 110,
2485
- height: 34
3288
+ width: 122,
3289
+ height: 36
2486
3290
  };
2487
3291
  viewport = {
2488
3292
  width: window.innerWidth,
@@ -2498,6 +3302,12 @@ var PageUseLauncherElement = class extends LitElement {
2498
3302
  alignBottom: true
2499
3303
  };
2500
3304
  wasRunning = false;
3305
+ /** 入口按钮上次渲染时是否为「执行中」文案;文案切换时用于触发宽度过渡 */
3306
+ fabShowsRunning = false;
3307
+ /** 文案切换前的入口宽度(px);null 表示本次更新不需要宽度过渡 */
3308
+ fabFromWidth = null;
3309
+ /** 进行中宽度过渡的结束回调注销函数;过渡中再次切换时先注销,避免旧回调提前清除宽度 */
3310
+ fabWidthCleanup = null;
2501
3311
  cleanups = [];
2502
3312
  get fabEl() {
2503
3313
  return this.renderRoot.querySelector(".fab");
@@ -2509,7 +3319,7 @@ var PageUseLauncherElement = class extends LitElement {
2509
3319
  return this.renderRoot.querySelector(".log");
2510
3320
  }
2511
3321
  get textareaEl() {
2512
- return this.renderRoot.querySelector(".textarea");
3322
+ return this.renderRoot.querySelector(".composer .textarea");
2513
3323
  }
2514
3324
  fabDrag = createDrag({
2515
3325
  getEl: () => this.fabEl,
@@ -2527,6 +3337,9 @@ var PageUseLauncherElement = class extends LitElement {
2527
3337
  panelDrag = createDrag({
2528
3338
  getEl: () => this.panelEl,
2529
3339
  getPos: () => null,
3340
+ onTap: (target) => {
3341
+ if (target?.closest?.(".title")) this.onTitleTap();
3342
+ },
2530
3343
  setPos: ({ x, y }) => {
2531
3344
  const el = this.panelEl;
2532
3345
  if (!el) return;
@@ -2541,22 +3354,46 @@ var PageUseLauncherElement = class extends LitElement {
2541
3354
  connectedCallback() {
2542
3355
  super.connectedCallback();
2543
3356
  this.wasRunning = isRunning();
3357
+ const query = readLauncherQuery();
3358
+ if (query !== null) pageUseSettings.update({ launcherVisible: query === "1" || query === "true" });
2544
3359
  this.cleanups = [
2545
3360
  pageUseStore.subscribe((state) => this.onStateChange(state)),
2546
3361
  pageUseSettings.store.subscribe(() => this.requestUpdate()),
2547
3362
  pageUseHistory.store.subscribe(() => this.requestUpdate()),
3363
+ pageUsePresets.store.subscribe(() => this.requestUpdate()),
3364
+ narrationMute.subscribe(() => this.requestUpdate()),
3365
+ previewStore.subscribe(() => this.requestUpdate()),
3366
+ registerLauncherOpener(() => void this.expand()),
2548
3367
  registerQuestionUi(),
2549
3368
  this.watchVoices()
2550
3369
  ];
3370
+ this.syncPanelName();
2551
3371
  window.addEventListener("resize", this.onResize);
2552
3372
  }
2553
3373
  disconnectedCallback() {
2554
3374
  super.disconnectedCallback();
2555
3375
  this.cleanups.forEach((off) => off());
2556
3376
  this.cleanups = [];
3377
+ this.unregisterPanelName?.();
3378
+ this.unregisterPanelName = null;
3379
+ stopPreview();
2557
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);
2558
3392
  }
2559
- /** Chrome 的语音列表异步加载,首次读取可能为空:加载完成后刷新语音下拉框 */
3393
+ /**
3394
+ * Chrome 的语音列表异步加载,首次读取可能为空:加载完成后刷新语音下拉框。
3395
+ * 挂载时宿主可能尚未注入引擎,因此不按引擎判断;注入引擎时该事件只触发一次无害的重绘。
3396
+ */
2560
3397
  watchVoices() {
2561
3398
  if (!isSpeechSupported()) return () => {};
2562
3399
  const synth = globalThis.speechSynthesis;
@@ -2564,11 +3401,55 @@ var PageUseLauncherElement = class extends LitElement {
2564
3401
  synth.addEventListener?.("voiceschanged", onChange);
2565
3402
  return () => synth.removeEventListener?.("voiceschanged", onChange);
2566
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;
3446
+ }
2567
3447
  firstUpdated() {
2568
3448
  this.measureFab();
2569
3449
  }
2570
3450
  updated(_changed) {
2571
3451
  this.autosizeTextarea();
3452
+ this.animateFabWidth();
2572
3453
  const state = pageUseStore.get();
2573
3454
  const signature = [
2574
3455
  state.currentTask,
@@ -2656,7 +3537,61 @@ var PageUseLauncherElement = class extends LitElement {
2656
3537
  style.height = `min(${PANEL_HEIGHT}px, 72vh, ${Math.max(240, vh - offsetY - 8)}px)`;
2657
3538
  return toStyle(style);
2658
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
+ }
2659
3593
  async collapse() {
3594
+ stopPreview();
2660
3595
  this.open = false;
2661
3596
  this.requestUpdate();
2662
3597
  await this.updateComplete;
@@ -2745,23 +3680,15 @@ var PageUseLauncherElement = class extends LitElement {
2745
3680
  ...this._config.sourceLabels
2746
3681
  }[source] || source;
2747
3682
  }
2748
- presets() {
2749
- const limit = this._config.menuPresetLimit ?? 6;
2750
- const menus = getPageUseConfig()?.getMenus();
2751
- return [
2752
- ...limit > 0 ? buildMenuPresets(menus, limit) : [],
2753
- ...PAGE_USE_STATIC_PRESETS,
2754
- ...this._config.presets || []
2755
- ];
2756
- }
2757
- endpointLabel() {
2758
- const baseURL = getPageUseConfig()?.llm.baseURL;
2759
- if (!baseURL) return "未配置";
2760
- try {
2761
- return new URL(baseURL).host;
2762
- } catch {
2763
- return baseURL;
2764
- }
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
+ });
2765
3692
  }
2766
3693
  renderChips(presets, accent = false) {
2767
3694
  return presets.map((preset) => html`<button
@@ -2773,18 +3700,31 @@ var PageUseLauncherElement = class extends LitElement {
2773
3700
  ${preset.label}
2774
3701
  </button>`);
2775
3702
  }
3703
+ /** 分组顺序:我的 → 常用 → 复杂场景;空分组不显示,全部为空时提示去设置中维护 */
2776
3704
  renderPresetGroups() {
2777
- const scenarios = this._config.scenarios || [];
2778
- return html`
2779
- <div class="chips">
2780
- <span class="chips-caption">常用</span>
2781
- ${this.renderChips(this.presets())}
2782
- </div>
2783
- ${scenarios.length ? html`<div class="chips">
2784
- <span class="chips-caption">复杂场景</span>
2785
- ${this.renderChips(scenarios, true)}
2786
- </div>` : nothing}
2787
- `;
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>`);
2788
3728
  }
2789
3729
  renderCheckbox(key, label, hint, disabled = false) {
2790
3730
  const settings = pageUseSettings.get();
@@ -2801,33 +3741,220 @@ var PageUseLauncherElement = class extends LitElement {
2801
3741
  renderSettings() {
2802
3742
  const settings = pageUseSettings.get();
2803
3743
  const config = getPageUseConfig();
3744
+ const status = pageUseStore.get().status;
2804
3745
  return html`<div class="fill">
2805
3746
  <div class="settings-body">
3747
+ <div class="settings-status">当前状态:${STATUS_LABELS[status] || status}</div>
2806
3748
  ${this.renderCheckbox("showHighlights", "显示元素编号高亮", "运行时页面上带数字的彩色框")}
2807
3749
  ${this.renderCheckbox("enableMask", "运行时蒙层防误触", "下次任务开始生效")}
2808
3750
  ${config?.beforeTask ? this.renderCheckbox("enableBeforeTask", config.beforeTaskLabel) : nothing}
2809
3751
  ${this.renderCheckbox("showStepDetails", "步骤显示模型思考与返回")}
2810
3752
  ${this.renderVoiceSettings(settings)}
2811
- <div class="row">
3753
+ <div class="row settings-actions">
2812
3754
  <button type="button" class="btn" @click=${() => pageUseSettings.resetPositions()}>
2813
3755
  复位窗口
2814
3756
  </button>
2815
- <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>
2816
3763
  </div>
2817
- <span class="meta">端点 ${this.endpointLabel()} · 模型 ${config?.llm.model ?? "-"}</span>
2818
3764
  </div>
2819
3765
  </div>`;
2820
3766
  }
2821
- /** 演示播报:开关 + 兼容提示;开启后显示语速与中文语音选择 */
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>
3901
+ </div>
3902
+ </div>
3903
+ </div>`;
3904
+ }
3905
+ /**
3906
+ * 演示播报:总开关关闭时只显示开关;开启后依次为引擎下拉(可选引擎多于一个时)、
3907
+ * 语速、音色下拉与试听按钮(无音色列表时只有试听按钮)、不可用 / 无中文语音提示。
3908
+ */
2822
3909
  renderVoiceSettings(settings) {
2823
- const supported = isSpeechSupported();
2824
- const voices = getChineseVoices();
2825
- const warning = !supported ? "当前浏览器不支持语音合成,开启后不会播报" : voices.length ? "" : "未检测到中文语音,将使用浏览器默认语音播报";
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 ? "未检测到中文语音,将使用浏览器默认语音播报" : "";
2826
3933
  return html`
2827
- ${this.renderCheckbox("voiceEnabled", "语音播报", "演示时播报执行步骤、提问与结论")}
2828
- ${warning ? html`<div class="alert warning voice-warning">${warning}</div>` : nothing}
2829
- ${settings.voiceEnabled && supported ? html`<div class="voice-options">
2830
- <label class="field">
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">
2831
3958
  <span class="field-label">语速 ${settings.voiceRate.toFixed(2)}</span>
2832
3959
  <input
2833
3960
  class="range"
@@ -2838,21 +3965,60 @@ var PageUseLauncherElement = class extends LitElement {
2838
3965
  .value=${String(settings.voiceRate)}
2839
3966
  @input=${(e) => pageUseSettings.update({ voiceRate: Number(e.target.value) })}
2840
3967
  />
2841
- </label>
2842
- <label class="field">
2843
- <span class="field-label">语音</span>
2844
- <select
2845
- class="input"
2846
- @change=${(e) => pageUseSettings.update({ voiceName: e.target.value })}
2847
- >
2848
- <option value="" ?selected=${!settings.voiceName}>自动(第一个中文语音)</option>
2849
- ${voices.map((voice) => html`<option value=${voice.name} ?selected=${voice.name === settings.voiceName}>
2850
- ${voice.name}
2851
- </option>`)}
2852
- </select>
2853
- </label>
2854
- </div>` : nothing}
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>
2855
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>
4020
+ </div>
4021
+ </div>`;
2856
4022
  }
2857
4023
  renderLog(state, isLogEmpty) {
2858
4024
  const running = isRunning(state);
@@ -3012,9 +4178,11 @@ var PageUseLauncherElement = class extends LitElement {
3012
4178
  render() {
3013
4179
  const state = pageUseStore.get();
3014
4180
  const settings = pageUseSettings.get();
4181
+ if (!this.isVisible(settings)) return nothing;
3015
4182
  const running = isRunning(state);
3016
4183
  const title = this._config.title || "Page Use";
3017
4184
  const elapsed = getElapsedSeconds(state);
4185
+ const { muted } = narrationMute.get();
3018
4186
  const isLogEmpty = !running && !state.currentTask && !state.steps.length && !state.errorMessage;
3019
4187
  return html`
3020
4188
  <button
@@ -3022,36 +4190,56 @@ var PageUseLauncherElement = class extends LitElement {
3022
4190
  class="fab ${running ? "running" : ""}"
3023
4191
  ?hidden=${this.open}
3024
4192
  style=${this.fabStyle(settings)}
3025
- title=${running ? `执行中:${state.activityText || state.currentTask}` : `打开 ${title} 页面操作`}
4193
+ title=${running ? `执行中:${state.activityText || state.currentTask}` : `打开 ${title}`}
3026
4194
  @pointerdown=${this.fabDrag.onPointerDown}
3027
4195
  >
3028
- <span class="fab-dot"></span>
4196
+ <span class="fab-icon">${robotIcon}</span>
3029
4197
  <span>${running ? "执行中" : title}</span>
3030
4198
  </button>
3031
4199
 
3032
- <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
+ >
3033
4205
  <header class="head" @pointerdown=${this.panelDrag.onPointerDown}>
3034
- <span class="dot ${state.status}"></span>
4206
+ <span class="dot ${state.status}" title=${STATUS_LABELS[state.status] || state.status}></span>
3035
4207
  <span class="title">${title}</span>
3036
4208
  <span class="status">
3037
- ${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` : ""}`}
3038
4210
  </span>
3039
- <button
3040
- type="button"
3041
- class="btn ghost circle ${this.showSettings ? "active" : ""}"
3042
- title=${this.showSettings ? "返回" : "设置"}
3043
- @click=${() => {
3044
- this.showSettings = !this.showSettings;
3045
- this.requestUpdate();
3046
- }}
3047
- >
3048
- ⚙
3049
- </button>
3050
- <button type="button" class="btn ghost circle" title="收起" @click=${() => void this.collapse()}>
3051
- −
3052
- </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="关闭"
4235
+ @click=${() => this.close()}
4236
+ >
4237
+ ${closeIcon}
4238
+ </button>
4239
+ </div>
3053
4240
  </header>
3054
- ${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}
3055
4243
  </section>
3056
4244
  `;
3057
4245
  }
@@ -3094,4 +4282,4 @@ function mountPageUseUI(options = {}) {
3094
4282
  }
3095
4283
 
3096
4284
  //#endregion
3097
- export { DEFAULT_STORAGE_KEY_PREFIX as A, pageUseStore as C, DEFAULT_MAX_STEPS as D, pageUseSettings as E, readLauncherSwitch as M, PAGE_USE_MASK_COLORS as N, DEFAULT_MODEL as O, SIMULATOR_MASK_ID as P, isRunning as S, DEFAULT_PAGE_USE_SETTINGS 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, normalizeOptions as j, DEFAULT_SESSION_HEADER 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, pageUseHistory 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 };