@falling-ts/dsh-web-ding 0.1.1 → 0.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.cn.md CHANGED
@@ -28,16 +28,29 @@ dsh plugin --profile web add github:falling-ts/dsh-web-ding
28
28
 
29
29
  ## 配置(`falling-ts-web-ding` 命名空间,$DSH_HOME/settings.yaml)
30
30
 
31
+ 两块独立配置,各自有开关与音色:
32
+
33
+ **块 1 — 弹出用户选择** —— harness 弹出用户选择题时在浏览器播放一声"叮"。
34
+ 检测在浏览器侧完成(DOM 上 QuestionComposer 的 `[data-question-key]` 锚点):
35
+ 宿主看不到 question 帧,故本块完全由前端实现。
36
+
31
37
  | 字段 | 类型 | 默认 | 含义 |
32
38
  |------|------|------|------|
33
- | `enabled` | boolean | `true` | 总开关(关闭时 Host 跳过发布)。 |
34
- | `volume` | number 0..1 | `0.7` | Web Audio 播放音量。 |
35
- | `freq` | number 80..4000 | `880` | "叮"的基频(Hz)。 |
36
- | `decayMs` | number 100..4000 | `900` | 音色衰减时长(ms)。 |
39
+ | `questionEnabled` | boolean | `true` | 弹出用户选择提示音开关。 |
40
+ | `questionVolume` | number 0..1 | `0.7` | Web Audio 播放音量。 |
41
+ | `questionFreq` | number 80..4000 | `880` | "叮"的基频(Hz)。 |
42
+ | `questionDecayMs` | number 100..4000 | `900` | 音色衰减时长(ms)。 |
37
43
 
38
- 也可以在 **设置 回合结束提示音** 面板里调整,面板带"试听"按钮。
44
+ **块 2 回合结束** —— 经典的 agent/status idle 转变提示音。Host 观察
45
+ `agent/status`,在 idle 转变(全部轮次与子代理结束、下一次人为对话之前;
46
+ 新建后从未运行过的会话与重复 idle tick 保持静默)时发布 `done` 信号。
39
47
 
40
- 任何会话的 agent 回合结束(转入空闲)都会提示;同一会话内重复 idle tick 与新建后从未运行过的会话保持静默。
48
+ | 字段 | 类型 | 默认 | 含义 |
49
+ |------|------|------|------|
50
+ | `turnEndEnabled` | boolean | `true` | 回合结束提示音开关(关闭时 Host 跳过发布)。 |
51
+ | `turnEndVolume` | number 0..1 | `0.7` | Web Audio 播放音量。 |
52
+ | `turnEndFreq` | number 80..4000 | `880` | "叮"的基频(Hz)。 |
53
+ | `turnEndDecayMs` | number 100..4000 | `900` | 音色衰减时长(ms)。 |
41
54
 
42
55
  ## 浏览器自动播放策略
43
56
 
@@ -60,10 +73,11 @@ dsh plugin --profile web add /path/to/dsh-web-ding
60
73
 
61
74
  ## 效果截图
62
75
 
63
- ![设置页——「回合结束提示音」分区,各旋钮免重启实时可调](assets/setting-ding.png)
76
+ ![设置页——「提示音配置」分区,两块各自开关与音色,免重启实时可调](assets/setting-ding.png)
64
77
 
65
- *设置页——「回合结束提示音」分区:总开关、音量、频率、衰减时长全部免重启
66
- 实时可调,并带"试听"按钮预热浏览器 AudioContext。*
78
+ *设置页——「提示音配置」分区:「弹出用户选择」与「回合结束」两块,各自
79
+ 开关、音量、频率、衰减时长,免重启实时可调,各带"试听"按钮预热浏览器
80
+ AudioContext。*
67
81
 
68
82
  ---
69
83
 
package/README.md CHANGED
@@ -28,14 +28,31 @@ Requires the web app to ship the client bundle (the `dsh.client` declaration in
28
28
 
29
29
  ## Configuration (`falling-ts-web-ding` namespace, $DSH_HOME/settings.yaml)
30
30
 
31
+ Two independent blocks, each with its own switch and its own tone:
32
+
33
+ **Block 1 — 弹出用户选择 (question popup ding)** — plays when the harness pops a
34
+ user-question chooser in the browser. Detection is client-side on the DOM
35
+ (`[data-question-key]` anchor of the QuestionComposer): the Host half sees no
36
+ question frame, so this block is entirely browser-side.
37
+
31
38
  | Field | Type | Default | Meaning |
32
39
  |-------|------|---------|---------|
33
- | `enabled` | boolean | `true` | Master switch (Host skips publishing when off). |
34
- | `volume` | number 0..1 | `0.7` | Web Audio playback gain. |
35
- | `freq` | number 80..4000 | `880` | Fundamental frequency of the ding (Hz). |
36
- | `decayMs` | number 100..4000 | `900` | Tone decay length (ms). |
40
+ | `questionEnabled` | boolean | `true` | Switch for the question-popup ding. |
41
+ | `questionVolume` | number 0..1 | `0.7` | Web Audio playback gain. |
42
+ | `questionFreq` | number 80..4000 | `880` | Fundamental frequency of the ding (Hz). |
43
+ | `questionDecayMs` | number 100..4000 | `900` | Tone decay length (ms). |
44
+
45
+ **Block 2 — 回合结束 (turn-end ding)** — the classic agent/status idle-transition
46
+ tone. The Host observes `agent/status`; on the idle transition (all turns done,
47
+ including sub-agents, before the next human turn; fresh sessions that never ran
48
+ and repeated idle ticks stay silent) it publishes the `done` signal.
37
49
 
38
- Every session's agent turn end (transition to idle) rings; repeated idle ticks of an already-idle session and brand-new sessions that never ran stay silent.
50
+ | Field | Type | Default | Meaning |
51
+ |-------|------|---------|---------|
52
+ | `turnEndEnabled` | boolean | `true` | Switch for the turn-end ding (Host skips publishing when off). |
53
+ | `turnEndVolume` | number 0..1 | `0.7` | Web Audio playback gain. |
54
+ | `turnEndFreq` | number 80..4000 | `880` | Fundamental frequency of the ding (Hz). |
55
+ | `turnEndDecayMs` | number 100..4000 | `900` | Tone decay length (ms). |
39
56
 
40
57
  Besides the ring, each turn end drops a bottom-right toast (Win11-style,
41
58
  auto-dismisses after 6 s). Click it to open the right-side notification
@@ -44,8 +61,8 @@ drawer: the last 100 turn-end messages are kept in the browser's
44
61
  deleted individually, and a "全部删除" button clears them all. The message
45
62
  log is front-end only — the Host never reads or writes it.
46
63
 
47
- You can also adjust these from the **设置 → 回合结束提示音** panel, which
48
- includes a "试听" (preview) button.
64
+ You can also adjust both blocks from the **设置 → 提示音配置** panel,
65
+ each with its own switch and its own "试听" (preview) button.
49
66
 
50
67
  ## Browser autoplay policy
51
68
 
@@ -70,11 +87,12 @@ browser).
70
87
 
71
88
  ## Screenshots
72
89
 
73
- ![Settings page: the **ding** (回合结束提示音) section, live-editable controls](assets/setting-ding.png)
90
+ ![Settings page: the **提示音配置** section, two blocks each with its own switch and tone](assets/setting-ding.png)
74
91
 
75
- *Settings page: the **回合结束提示音** section. Master switch, volume,
76
- frequency and decay length are all live-editable without a restart, with a
77
- **试听** (preview) button to warm the browser AudioContext.*
92
+ *Settings page: the **提示音配置** section "弹出用户选择" and "回合结束" blocks,
93
+ each with its own switch, volume, frequency and decay length, all live-editable
94
+ without a restart, each with a **试听** (preview) button to warm the browser
95
+ AudioContext.*
78
96
 
79
97
  ---
80
98
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@falling-ts/dsh-web-ding",
3
- "version": "0.1.1",
3
+ "version": "0.1.3",
4
4
  "description": "DSH Cordis plugin: announces the moment an agent finishes (turns idle) by pushing a 'done' signal from the Host half into the falling-ts-web-ding settings namespace, which the browser client half mirrors live and answers with a synthesized 'ding' tone via the Web Audio API. The tone is played ENTIRELY by front-end JS in the browser — the Host/node backend never emits audio and no Windows/system notification is used.",
5
5
  "type": "module",
6
6
  "main": "./index.js",
@@ -17,6 +17,15 @@
17
17
  "cordis.patch.yml"
18
18
  ],
19
19
  "license": "MIT",
20
+ "peerDependencies": {
21
+ "@deepseek-ai/cordis": "^4.0.1",
22
+ "@deepseek-ai/dsh-settings": "^0.1.0-rc.7 || ^0.1.1-rc.2"
23
+ },
24
+ "peerDependenciesMeta": {
25
+ "@deepseek-ai/dsh-settings": {
26
+ "optional": true
27
+ }
28
+ },
20
29
  "publishConfig": {
21
30
  "access": "public"
22
31
  },
@@ -27,7 +36,8 @@
27
36
  "client": {
28
37
  "platform": "web",
29
38
  "inject": [
30
- "@deepseek-ai/dsh-client-ui-settings"
39
+ "@deepseek-ai/dsh-client-ui-settings",
40
+ "@deepseek-ai/dsh-client-store"
31
41
  ]
32
42
  }
33
43
  },
@@ -1,20 +1,22 @@
1
1
  /**
2
- * dsh-web-ding settings — the "回合结束提示音" (turn-end ding) surface.
2
+ * dsh-web-ding settings — the "提示音" (ding) surface, split into TWO blocks:
3
3
  *
4
- * User-tunable parameters are registered under the `falling-ts-web-ding`
5
- * settings namespace (the `falling-ts-` prefix prevents collisions with
6
- * other plugins' keys):
4
+ * Block 1 弹出用户选择 (question popup ding): plays when the harness pops a
5
+ * user-question chooser in the browser (detected client-side on the DOM
6
+ * `[data-question-key]` anchor the host half sees no question frame).
7
+ * `questionEnabled` (boolean, default `true`) is its master switch; when
8
+ * `false` the browser skips the question-popup ding.
9
+ * `questionVolume` / `questionFreq` / `questionDecayMs` tune its tone.
7
10
  *
8
- * - `enabled` (boolean, default `true`): master switch. When `false` the
9
- * Host half still observes `agent/status` but skips publishing the signal,
10
- * so the browser never hears a ding.
11
- * - `volume` (number 0..1, default `0.7`): playback gain applied by the
12
- * browser client's Web Audio synth (a per-oscillator peak, not the master).
13
- * - `freq` (number 80..4000, default `880`): fundamental frequency of
14
- * the synthesized "ding" in Hz. The client stacks a soft higher-octave and
15
- * a faint bell partial on top automatically.
16
- * - `decayMs` (number 100..4000, default `900`): exponential-decay length
17
- * of the tone in milliseconds.
11
+ * Block 2 — 回合结束 (turn-end ding): the classic agent/status idle-transition
12
+ * tone. `turnEndEnabled` (boolean, default `true`) is its master switch;
13
+ * when `false` the Host half still observes `agent/status` but skips
14
+ * publishing the signal, so the browser never hears a turn-end ding.
15
+ * `turnEndVolume` / `turnEndFreq` / `turnEndDecayMs` tune its tone.
16
+ *
17
+ * Per-block fields are mirrored (the same `volume` 0..1 / `freq` 80..4000 /
18
+ * `decayMs` 100..4000 meanings as the original single block), so the two
19
+ * dings can carry distinct pitches and loudness.
18
20
  *
19
21
  * The `signal` field is the PLUGIN-PRIVATE host→browser messenger (the same
20
22
  * pattern dsh-force-compact uses for its `liveUi` field): the Host half is
@@ -31,12 +33,18 @@ export const NS = 'falling-ts-web-ding'
31
33
  /** The settings field carrying the host→browser turn-end signal. */
32
34
  export const SIGNAL_FIELD = 'signal'
33
35
 
34
- /** Defaults — also the base passed to settings.register. */
36
+ /** Defaults — also the base passed to settings.register. Two blocks, each with its own switch and tone. */
35
37
  export const DEFAULTS = Object.freeze({
36
- enabled: true,
37
- volume: 0.7,
38
- freq: 880,
39
- decayMs: 900,
38
+ // Block 1 — 弹出用户选择 (question popup ding), detected client-side on the DOM.
39
+ questionEnabled: true,
40
+ questionVolume: 0.7,
41
+ questionFreq: 880,
42
+ questionDecayMs: 900,
43
+ // Block 2 — 回合结束 (turn-end ding), Host agent/status idle transition.
44
+ turnEndEnabled: true,
45
+ turnEndVolume: 0.7,
46
+ turnEndFreq: 880,
47
+ turnEndDecayMs: 900,
40
48
  })
41
49
 
42
50
  /**
@@ -104,10 +112,16 @@ export async function buildSchema() {
104
112
  const z = await resolveZ()
105
113
  if (z === undefined) return null
106
114
  return z.object({
107
- enabled: z.boolean().default(DEFAULTS.enabled),
108
- volume: z.number().default(DEFAULTS.volume),
109
- freq: z.number().default(DEFAULTS.freq),
110
- decayMs: z.number().default(DEFAULTS.decayMs),
115
+ // Block 1 — 弹出用户选择 (question popup ding), client-side DOM detection.
116
+ questionEnabled: z.boolean().default(DEFAULTS.questionEnabled),
117
+ questionVolume: z.number().default(DEFAULTS.questionVolume),
118
+ questionFreq: z.number().default(DEFAULTS.questionFreq),
119
+ questionDecayMs: z.number().default(DEFAULTS.questionDecayMs),
120
+ // Block 2 — 回合结束 (turn-end ding), Host agent/status idle transition.
121
+ turnEndEnabled: z.boolean().default(DEFAULTS.turnEndEnabled),
122
+ turnEndVolume: z.number().default(DEFAULTS.turnEndVolume),
123
+ turnEndFreq: z.number().default(DEFAULTS.turnEndFreq),
124
+ turnEndDecayMs: z.number().default(DEFAULTS.turnEndDecayMs),
111
125
  // TRANSIENT host→browser messenger (src/core/signal.js): host-written
112
126
  // { phase:'done', at, sessionId }. z.any() because the vendored
113
127
  // schemastery exposes only object/any/string/number/boolean/array.
package/src/hooks/idle.js CHANGED
@@ -55,9 +55,9 @@ export async function handleAgentStatus(ctx, payload) {
55
55
  if (prev === 'idle') return // repeated idle tick — already handled
56
56
  if (!everBusy.has(sid)) return // fresh session that never ran — no ding
57
57
 
58
- const enabled = await readRawSetting(ctx, 'enabled')
58
+ const enabled = await readRawSetting(ctx, 'turnEndEnabled')
59
59
  if (enabled === false) {
60
- ctx.logger.debug(`[web-ding] ${sid}: idle transition ignored — enabled=false`)
60
+ ctx.logger.debug(`[web-ding] ${sid}: idle transition ignored — turnEndEnabled=false`)
61
61
  return
62
62
  }
63
63
  await publishDingSignal(ctx, sid)
package/web/client.js CHANGED
@@ -1,5 +1,6 @@
1
1
  /**
2
- * dsh-web-ding 浏览器半部:回合结束提示音(设置分区 + Web Audio 播放器)。
2
+ * dsh-web-ding 浏览器半部:提示音配置(设置分区 + Web Audio 播放器,两块:弹出
3
+ * 用户选择 / 回合结束)。
3
4
  *
4
5
  * 这是一个闭包工厂 artifact:window.__ModuleLoader__.load({ id, factory }),
5
6
  * factory(require) 通过注入的 require 解析外部模块(react、client-runtime),
@@ -15,8 +16,10 @@
15
16
  * 基线不播放,重启残留/重复快照都不会重复响)后,用 Web Audio API 合成
16
17
  * 一声"叮"。声音 100% 由浏览器 JS 生成——宿主 Node 端从不发声,也不发
17
18
  * Windows/系统通知。
18
- * 3. 注册 settings.section "回合结束提示音" 分区:开关、音量、音色频率、
19
- * 时长与"试听"按钮(点击试听同时完成音频解锁)。
19
+ * 3. 注册 settings.section "提示音配置" 分区:两块(弹出用户选择 / 回合结束),
20
+ * 每块都有开关、音量、音色频率、时长与"试听"按钮(点击试听同时完成音频解锁)。
21
+ * 滑块用 BufferedSlider 本地缓冲——拖动只刷本地 state,松手/失焦才提交,
22
+ * 避免每次拖动写盘 + 广播 + 整块面板重渲染导致卡顿。
20
23
  *
21
24
  * 浏览器自动播放策略:AudioContext 需要一次用户手势才能出声。首次
22
25
  * pointerdown/keydown 做一次性预热(创建并 resume),"试听"按钮点击本身
@@ -34,7 +37,10 @@ window.__ModuleLoader__.load({
34
37
  const React = require("react");
35
38
  const h = React.createElement;
36
39
  // 基线外部(web 平台预载):把 settingsScope 镜像成 uSES 安全的 SnapshotStore。
37
- const { createSnapshotStore } = require("@deepseek-ai/dsh-client-runtime/client");
40
+ // `createSnapshotStore` 的正确来源是 PLATFORM_MODULES seed 表内的静态包
41
+ // `@deepseek-ai/dsh-client-store`;`@deepseek-ai/dsh-client-runtime` 不在共享模块表
42
+ // 里,require 它会命中 client-modules 的 "missed the module table" 落空错误。
43
+ const { createSnapshotStore } = require("@deepseek-ai/dsh-client-store");
38
44
 
39
45
  /** 宿主侧设置命名空间(settings.get 读取的键)。 */
40
46
  const NS_SETTINGS = "falling-ts-web-ding";
@@ -468,8 +474,8 @@ window.__ModuleLoader__.load({
468
474
  }
469
475
  if (!(at > lastAt)) return;
470
476
  lastAt = at;
471
- if (value.enabled !== false) {
472
- playDing({ volume: value.volume, freq: value.freq, decayMs: value.decayMs });
477
+ if (value.turnEndEnabled !== false) {
478
+ playDing({ volume: value.turnEndVolume, freq: value.turnEndFreq, decayMs: value.turnEndDecayMs });
473
479
  const sessionId = typeof sig.sessionId === "string" ? sig.sessionId : undefined;
474
480
  // 信号里只有 sessionId:异步查一次真实标题再落缓存与弹 toast。
475
481
  void (async () => {
@@ -481,6 +487,44 @@ window.__ModuleLoader__.load({
481
487
  }
482
488
  }
483
489
 
490
+ // ── 弹出用户选择检测(DOM 锚点 [data-question-key],纯前端)────────────────────
491
+ // harness 的用户选择题(ask_user_question)在对话区由 QuestionComposer 渲染,根
492
+ // 节点带稳定的 data-question-key 属性(CSS Modules 类名是哈希的,不可用——与
493
+ // TurnStatus 用 role/aria 识别同思路)。宿主半部看不到 question/requested 帧
494
+ // (那走 connection 层 MuxFrame,Host 插件不可订阅),所以这一块完全在浏览器侧
495
+ // 完成:MutationObserver 观察新出现的 [data-question-key] 节点,首次出现即播
496
+ // 放 Block 1(弹出用户选择)的那声"叮"。非 timer、无持久态,同 lastAt 首帧
497
+ // 基线语义:加载时已存在的 key 只记不响,之后新弹出的 key 才响。
498
+ let seenQuestionKeys = new Set(); // 已响应过的 question key(去重,防重复响)
499
+ let questionObserver = null; // DOM 观察器(非 timer)
500
+ let questionValueRef = null; // 最新快照引用,供 observer 回调读取
501
+ function maybePlayQuestionFromDom(value) {
502
+ if (typeof document === "undefined") return;
503
+ const nodes = document.querySelectorAll('[data-question-key]');
504
+ if (nodes.length === 0) return;
505
+ const v = (value && typeof value === "object") ? value : {};
506
+ const enabled = v.questionEnabled !== false;
507
+ for (const el of nodes) {
508
+ const key = el.getAttribute("data-question-key");
509
+ if (!key || seenQuestionKeys.has(key)) continue;
510
+ seenQuestionKeys.add(key);
511
+ if (enabled) {
512
+ playDing({ volume: v.questionVolume, freq: v.questionFreq, decayMs: v.questionDecayMs });
513
+ }
514
+ }
515
+ }
516
+ function installQuestionObserver(value) {
517
+ if (questionObserver || typeof MutationObserver === "undefined" || typeof document === "undefined") return;
518
+ // 基线:先记下当前已存在的 key(不响),之后新 key 才响(同 lastAt 首帧语义)。
519
+ const nodes = document.querySelectorAll('[data-question-key]');
520
+ for (const el of nodes) {
521
+ const key = el.getAttribute("data-question-key");
522
+ if (key) seenQuestionKeys.add(key);
523
+ }
524
+ questionObserver = new MutationObserver(() => maybePlayQuestionFromDom(questionValueRef));
525
+ questionObserver.observe(document.body, { childList: true, subtree: true });
526
+ }
527
+
484
528
  // ── 设置分区 UI --------------------------------------------------------------
485
529
  const divider = "rgba(0,0,0,0.08)";
486
530
  const hintColor = "rgba(0,0,0,0.45)";
@@ -497,6 +541,44 @@ window.__ModuleLoader__.load({
497
541
  const buttonStyle = { padding: "6px 14px", borderRadius: 8, border: "1px solid rgba(0,0,0,0.22)", background: "transparent", cursor: "pointer", fontSize: 13, fontWeight: 500 };
498
542
  const inputRangeStyle = { flex: 1, minWidth: 140 };
499
543
 
544
+ /**
545
+ * 缓冲滑块(React state 本地缓冲,最流畅):拖动期间 onChange 只更新本地 state
546
+ * (仅重渲染这一个 input + 显示值,不写设置、不触发 document-updated 广播),
547
+ * 松手(mouseup/touchend)/失焦(blur)/键盘结束(keyup)时才提交一次 scope.set。
548
+ * 原实现 onChange 每动一格都 update → settings.yaml 写盘 + 全量广播 + 整块面板
549
+ * 重渲染,所以拖动卡。
550
+ * @param {{fieldKey:string, labelText:string, min:number, max:number, step:number,
551
+ * value:number, disabled:boolean, display:(n:number)=>string, onSubmit:(n:number)=>void}} props
552
+ */
553
+ function BufferedSlider(props) {
554
+ const [v, setV] = React.useState(Number.isFinite(Number(props.value)) ? Number(props.value) : 0);
555
+ // 外部值变化(另一标签/外部编辑)时同步进本地缓冲。拖动中 onChange 只 setV
556
+ // 不写盘,props.value 不变,此 effect 不会打断拖动;提交后值已一致,幂等。
557
+ // eslint-disable-next-line react-hooks/exhaustive-deps
558
+ React.useEffect(() => { setV(Number.isFinite(Number(props.value)) ? Number(props.value) : 0); }, [props.value]);
559
+ const commit = () => props.onSubmit(v);
560
+ return h("div", { style: rowStyle },
561
+ h("span", { style: labelStyle }, props.labelText),
562
+ h("span", { style: controlStyle },
563
+ h("input", {
564
+ type: "range",
565
+ min: props.min, max: props.max, step: props.step,
566
+ value: v,
567
+ disabled: props.disabled,
568
+ style: inputRangeStyle,
569
+ // 拖动中:只更新本地 state(不写盘)。React 对 range 的 onChange 即 input
570
+ // 事件,随拖动连续触发——现在每次只是 setState,不重渲染整块面板。
571
+ onChange: (ev) => setV(Number(ev.target.value)),
572
+ // 松手 / 失焦 / 键盘操作结束时才提交一次。
573
+ onMouseUp: commit,
574
+ onTouchEnd: commit,
575
+ onBlur: commit,
576
+ onKeyUp: commit,
577
+ }),
578
+ h("span", { style: valueStyle }, props.display(v))),
579
+ );
580
+ }
581
+
500
582
  function DingSection(props) {
501
583
  // The renderer binds the injected hooks compartment ('ding' key) into a
502
584
  // use<Name> hook; the store is the bare observable, so a selector reads
@@ -507,56 +589,57 @@ window.__ModuleLoader__.load({
507
589
  const ph = hintStyle;
508
590
  if (snap.status === "unavailable") {
509
591
  return h("div", { style: wrapStyle },
510
- h("h2", { style: titleStyle }, "回合结束提示音"),
592
+ h("h2", { style: titleStyle }, "提示音配置"),
511
593
  h("p", { style: ph }, "设置不可用(宿主端未注册 falling-ts-web-ding 命名空间)。"));
512
594
  }
513
595
  if (snap.status === "loading" || value === undefined) {
514
596
  return h("div", { style: wrapStyle },
515
- h("h2", { style: titleStyle }, "回合结束提示音"),
597
+ h("h2", { style: titleStyle }, "提示音配置"),
516
598
  h("p", { style: ph }, "加载中…"));
517
599
  }
518
600
  const disabled = !snap.writable;
519
601
  const v = (value && typeof value === "object") ? value : {};
520
602
  const pct = (n) => Math.round((Number(n) || 0) * 100) + "%";
521
- const sliderRow = (labelText, props2, display) => h("div", { key: labelText, style: rowStyle },
522
- h("span", { style: labelStyle }, labelText),
523
- h("span", { style: controlStyle },
524
- h("input", { type: "range", disabled: disabled, style: inputRangeStyle, ...props2 }),
525
- h("span", { style: valueStyle }, display)),
526
- );
603
+ // 两块配置,共用模板:第一块"弹出用户选择"(question),第二块"回合结束"(turnEnd)。
604
+ const block = (blk, title, desc, previewBtn) => {
605
+ const E = "question" === blk ? "questionEnabled" : "turnEndEnabled";
606
+ const Vol = "question" === blk ? "questionVolume" : "turnEndVolume";
607
+ const Freq = "question" === blk ? "questionFreq" : "turnEndFreq";
608
+ const Decay = "question" === blk ? "questionDecayMs" : "turnEndDecayMs";
609
+ return [
610
+ h("h2", { key: blk + "-title", style: titleStyle }, title),
611
+ h("p", { key: blk + "-intro", style: introStyle }, desc),
612
+ h("div", { key: blk + "-enabled", style: rowStyle },
613
+ h("span", { style: labelStyle }, "启用"),
614
+ h("span", { style: controlStyle },
615
+ h("input", {
616
+ type: "checkbox",
617
+ checked: v[E] !== false,
618
+ disabled: disabled,
619
+ onChange: (ev) => update(E, ev.target.checked),
620
+ })),
621
+ ),
622
+ h(BufferedSlider, { key: blk + "-vol", labelText: "音量", min: 0, max: 1, step: 0.05, value: Number(v[Vol]) || 0.7, disabled: disabled, display: pct, onSubmit: (n) => update(Vol, n) }),
623
+ h(BufferedSlider, { key: blk + "-freq", labelText: "音色频率(Hz)", min: 120, max: 2000, step: 10, value: Number(v[Freq]) || 880, disabled: disabled, display: (n) => Math.round(n) + " Hz", onSubmit: (n) => update(Freq, n) }),
624
+ h(BufferedSlider, { key: blk + "-decay", labelText: "衰减时长(ms)", min: 100, max: 2000, step: 50, value: Number(v[Decay]) || 900, disabled: disabled, display: (n) => Math.round(n) + " ms", onSubmit: (n) => update(Decay, n) }),
625
+ h("div", { key: blk + "-preview", style: lastRowStyle },
626
+ h("span", { style: labelStyle }, "试听"),
627
+ h("span", { style: controlStyle },
628
+ h("button", { style: buttonStyle, disabled: disabled, onClick: previewBtn }, "播放一声")),
629
+ ),
630
+ ];
631
+ };
527
632
  return h("div", { style: wrapStyle },
528
- h("h2", { style: titleStyle }, "回合结束提示音"),
529
- h("p", { style: introStyle }, "agent 回合结束时(转入空闲)由浏览器 JS 播放一声合成“叮”——纯前端 Web Audio,宿主不发声、不弹 Windows/系统通知。"),
530
- h("div", { key: "enabled", style: rowStyle },
531
- h("span", { style: labelStyle }, "启用"),
532
- h("span", { style: controlStyle },
533
- h("input", {
534
- type: "checkbox",
535
- checked: v.enabled !== false,
536
- disabled: disabled,
537
- onChange: (ev) => update("enabled", ev.target.checked),
538
- })),
539
- ),
540
- sliderRow("音量",
541
- { min: 0, max: 1, step: 0.05, value: Number(v.volume) || 0.7, disabled: disabled, onChange: (ev) => update("volume", Number(ev.target.value)) },
542
- pct(v.volume)),
543
- sliderRow("音色频率(Hz)",
544
- { min: 120, max: 2000, step: 10, value: Number(v.freq) || 880, disabled: disabled, onChange: (ev) => update("freq", Number(ev.target.value)) },
545
- Math.round(Number(v.freq) || 880) + " Hz"),
546
- sliderRow("衰减时长(ms)",
547
- { min: 100, max: 2000, step: 50, value: Number(v.decayMs) || 900, disabled: disabled, onChange: (ev) => update("decayMs", Number(ev.target.value)) },
548
- Math.round(Number(v.decayMs) || 900) + " ms"),
549
- h("div", { key: "preview", style: lastRowStyle },
550
- h("span", { style: labelStyle }, "试听"),
551
- h("span", { style: controlStyle },
552
- h("button", {
553
- style: buttonStyle,
554
- disabled: disabled,
555
- onClick: () => play({ volume: v.volume, freq: v.freq, decayMs: v.decayMs }),
556
- }, "播放一声")),
557
- ),
633
+ h("h2", { style: { ...titleStyle, fontSize: 16 } }, "提示音配置"),
634
+ h("p", { style: introStyle }, "两种场景各自提示音,由浏览器 JS 纯前端 Web Audio 合成——宿主不发声、不弹 Windows/系统通知。设置写入 $DSH_HOME/settings.yaml 的 falling-ts-web-ding 段。"),
635
+ ...block("question", "弹出用户选择",
636
+ "harness 弹出用户选择题(浏览器对话区的选择题卡片)时播放一声“叮”,提醒你回来作答。检测走浏览器 DOM(QuestionComposer data-question-key 锚点),宿主端不参与。",
637
+ () => play({ volume: v.questionVolume, freq: v.questionFreq, decayMs: v.questionDecayMs })),
638
+ ...block("turnEnd", "回合结束",
639
+ "agent 回合结束时(agent/status 转入 idle)播放一声“叮”。宿主只在命中 idle 转换时发信号,声音由浏览器合成。",
640
+ () => play({ volume: v.turnEndVolume, freq: v.turnEndFreq, decayMs: v.turnEndDecayMs })),
558
641
  h("p", { style: { gridColumn: "1 / 3", color: hintColor, fontSize: 12, lineHeight: 1.55 } },
559
- "浏览器自动播放策略:首次与页面交互(点击/按键)或点击试听后,回合结束提示音才会出声。设置写入 $DSH_HOME/settings.yaml 的 falling-ts-web-ding 段。"));
642
+ "浏览器自动播放策略:首次与页面交互(点击/按键)或点击任一试听后,提示音才会出声。"));
560
643
  }
561
644
 
562
645
  /**
@@ -575,12 +658,20 @@ window.__ModuleLoader__.load({
575
658
  d.value = s.value;
576
659
  d.writable = s.writable;
577
660
  });
661
+ questionValueRef = s.value; // 供 question 观察器回调读取
662
+ installQuestionObserver(s.value); // 一次性安装 DOM 观察器(幂等)
578
663
  if (s.status === "ready") maybePlayFromValue(s.value);
579
664
  } catch { /* never let a cosmetic derive take down the panel */ }
580
665
  };
581
666
  const unsub = scope.subscribe(derive);
582
667
  derive();
583
668
  ctx.effect(() => unsub, "web-ding: scope subscription");
669
+ ctx.effect(() => () => {
670
+ if (questionObserver) {
671
+ questionObserver.disconnect();
672
+ questionObserver = null;
673
+ }
674
+ }, "web-ding: question observer cleanup");
584
675
  const injected = () => ({
585
676
  hooks: { ding: store },
586
677
  update: (field, value) => scope.set(field, value),
@@ -593,7 +684,7 @@ window.__ModuleLoader__.load({
593
684
  name: "settings.section",
594
685
  id: "web-ding",
595
686
  order: 80,
596
- label: () => "回合结束提示音",
687
+ label: () => "提示音配置",
597
688
  inject: injected,
598
689
  }, DingSection));
599
690
  }