@falling-ts/dsh-web-ding 0.1.1 → 0.1.2
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 +23 -9
- package/README.md +29 -11
- package/package.json +1 -1
- package/src/core/settings.js +37 -23
- package/src/hooks/idle.js +2 -2
- package/web/client.js +133 -45
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
|
-
| `
|
|
34
|
-
| `
|
|
35
|
-
| `
|
|
36
|
-
| `
|
|
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
|
-
|
|
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
|
-

|
|
64
77
|
|
|
65
|
-
|
|
66
|
-
|
|
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
|
-
| `
|
|
34
|
-
| `
|
|
35
|
-
| `
|
|
36
|
-
| `
|
|
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
|
-
|
|
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
|
|
48
|
-
|
|
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
|
-

|
|
74
91
|
|
|
75
|
-
*Settings page: the
|
|
76
|
-
frequency and decay length
|
|
77
|
-
**试听** (preview) button to warm the browser
|
|
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.
|
|
3
|
+
"version": "0.1.2",
|
|
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",
|
package/src/core/settings.js
CHANGED
|
@@ -1,20 +1,22 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* dsh-web-ding settings — the "
|
|
2
|
+
* dsh-web-ding settings — the "提示音" (ding) surface, split into TWO blocks:
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
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
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
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
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
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, '
|
|
58
|
+
const enabled = await readRawSetting(ctx, 'turnEndEnabled')
|
|
59
59
|
if (enabled === false) {
|
|
60
|
-
ctx.logger.debug(`[web-ding] ${sid}: idle transition ignored —
|
|
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
|
|
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),"试听"按钮点击本身
|
|
@@ -468,8 +471,8 @@ window.__ModuleLoader__.load({
|
|
|
468
471
|
}
|
|
469
472
|
if (!(at > lastAt)) return;
|
|
470
473
|
lastAt = at;
|
|
471
|
-
if (value.
|
|
472
|
-
playDing({ volume: value.
|
|
474
|
+
if (value.turnEndEnabled !== false) {
|
|
475
|
+
playDing({ volume: value.turnEndVolume, freq: value.turnEndFreq, decayMs: value.turnEndDecayMs });
|
|
473
476
|
const sessionId = typeof sig.sessionId === "string" ? sig.sessionId : undefined;
|
|
474
477
|
// 信号里只有 sessionId:异步查一次真实标题再落缓存与弹 toast。
|
|
475
478
|
void (async () => {
|
|
@@ -481,6 +484,44 @@ window.__ModuleLoader__.load({
|
|
|
481
484
|
}
|
|
482
485
|
}
|
|
483
486
|
|
|
487
|
+
// ── 弹出用户选择检测(DOM 锚点 [data-question-key],纯前端)────────────────────
|
|
488
|
+
// harness 的用户选择题(ask_user_question)在对话区由 QuestionComposer 渲染,根
|
|
489
|
+
// 节点带稳定的 data-question-key 属性(CSS Modules 类名是哈希的,不可用——与
|
|
490
|
+
// TurnStatus 用 role/aria 识别同思路)。宿主半部看不到 question/requested 帧
|
|
491
|
+
// (那走 connection 层 MuxFrame,Host 插件不可订阅),所以这一块完全在浏览器侧
|
|
492
|
+
// 完成:MutationObserver 观察新出现的 [data-question-key] 节点,首次出现即播
|
|
493
|
+
// 放 Block 1(弹出用户选择)的那声"叮"。非 timer、无持久态,同 lastAt 首帧
|
|
494
|
+
// 基线语义:加载时已存在的 key 只记不响,之后新弹出的 key 才响。
|
|
495
|
+
let seenQuestionKeys = new Set(); // 已响应过的 question key(去重,防重复响)
|
|
496
|
+
let questionObserver = null; // DOM 观察器(非 timer)
|
|
497
|
+
let questionValueRef = null; // 最新快照引用,供 observer 回调读取
|
|
498
|
+
function maybePlayQuestionFromDom(value) {
|
|
499
|
+
if (typeof document === "undefined") return;
|
|
500
|
+
const nodes = document.querySelectorAll('[data-question-key]');
|
|
501
|
+
if (nodes.length === 0) return;
|
|
502
|
+
const v = (value && typeof value === "object") ? value : {};
|
|
503
|
+
const enabled = v.questionEnabled !== false;
|
|
504
|
+
for (const el of nodes) {
|
|
505
|
+
const key = el.getAttribute("data-question-key");
|
|
506
|
+
if (!key || seenQuestionKeys.has(key)) continue;
|
|
507
|
+
seenQuestionKeys.add(key);
|
|
508
|
+
if (enabled) {
|
|
509
|
+
playDing({ volume: v.questionVolume, freq: v.questionFreq, decayMs: v.questionDecayMs });
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
function installQuestionObserver(value) {
|
|
514
|
+
if (questionObserver || typeof MutationObserver === "undefined" || typeof document === "undefined") return;
|
|
515
|
+
// 基线:先记下当前已存在的 key(不响),之后新 key 才响(同 lastAt 首帧语义)。
|
|
516
|
+
const nodes = document.querySelectorAll('[data-question-key]');
|
|
517
|
+
for (const el of nodes) {
|
|
518
|
+
const key = el.getAttribute("data-question-key");
|
|
519
|
+
if (key) seenQuestionKeys.add(key);
|
|
520
|
+
}
|
|
521
|
+
questionObserver = new MutationObserver(() => maybePlayQuestionFromDom(questionValueRef));
|
|
522
|
+
questionObserver.observe(document.body, { childList: true, subtree: true });
|
|
523
|
+
}
|
|
524
|
+
|
|
484
525
|
// ── 设置分区 UI --------------------------------------------------------------
|
|
485
526
|
const divider = "rgba(0,0,0,0.08)";
|
|
486
527
|
const hintColor = "rgba(0,0,0,0.45)";
|
|
@@ -497,6 +538,44 @@ window.__ModuleLoader__.load({
|
|
|
497
538
|
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
539
|
const inputRangeStyle = { flex: 1, minWidth: 140 };
|
|
499
540
|
|
|
541
|
+
/**
|
|
542
|
+
* 缓冲滑块(React state 本地缓冲,最流畅):拖动期间 onChange 只更新本地 state
|
|
543
|
+
* (仅重渲染这一个 input + 显示值,不写设置、不触发 document-updated 广播),
|
|
544
|
+
* 松手(mouseup/touchend)/失焦(blur)/键盘结束(keyup)时才提交一次 scope.set。
|
|
545
|
+
* 原实现 onChange 每动一格都 update → settings.yaml 写盘 + 全量广播 + 整块面板
|
|
546
|
+
* 重渲染,所以拖动卡。
|
|
547
|
+
* @param {{fieldKey:string, labelText:string, min:number, max:number, step:number,
|
|
548
|
+
* value:number, disabled:boolean, display:(n:number)=>string, onSubmit:(n:number)=>void}} props
|
|
549
|
+
*/
|
|
550
|
+
function BufferedSlider(props) {
|
|
551
|
+
const [v, setV] = React.useState(Number.isFinite(Number(props.value)) ? Number(props.value) : 0);
|
|
552
|
+
// 外部值变化(另一标签/外部编辑)时同步进本地缓冲。拖动中 onChange 只 setV
|
|
553
|
+
// 不写盘,props.value 不变,此 effect 不会打断拖动;提交后值已一致,幂等。
|
|
554
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
555
|
+
React.useEffect(() => { setV(Number.isFinite(Number(props.value)) ? Number(props.value) : 0); }, [props.value]);
|
|
556
|
+
const commit = () => props.onSubmit(v);
|
|
557
|
+
return h("div", { style: rowStyle },
|
|
558
|
+
h("span", { style: labelStyle }, props.labelText),
|
|
559
|
+
h("span", { style: controlStyle },
|
|
560
|
+
h("input", {
|
|
561
|
+
type: "range",
|
|
562
|
+
min: props.min, max: props.max, step: props.step,
|
|
563
|
+
value: v,
|
|
564
|
+
disabled: props.disabled,
|
|
565
|
+
style: inputRangeStyle,
|
|
566
|
+
// 拖动中:只更新本地 state(不写盘)。React 对 range 的 onChange 即 input
|
|
567
|
+
// 事件,随拖动连续触发——现在每次只是 setState,不重渲染整块面板。
|
|
568
|
+
onChange: (ev) => setV(Number(ev.target.value)),
|
|
569
|
+
// 松手 / 失焦 / 键盘操作结束时才提交一次。
|
|
570
|
+
onMouseUp: commit,
|
|
571
|
+
onTouchEnd: commit,
|
|
572
|
+
onBlur: commit,
|
|
573
|
+
onKeyUp: commit,
|
|
574
|
+
}),
|
|
575
|
+
h("span", { style: valueStyle }, props.display(v))),
|
|
576
|
+
);
|
|
577
|
+
}
|
|
578
|
+
|
|
500
579
|
function DingSection(props) {
|
|
501
580
|
// The renderer binds the injected hooks compartment ('ding' key) into a
|
|
502
581
|
// use<Name> hook; the store is the bare observable, so a selector reads
|
|
@@ -507,56 +586,57 @@ window.__ModuleLoader__.load({
|
|
|
507
586
|
const ph = hintStyle;
|
|
508
587
|
if (snap.status === "unavailable") {
|
|
509
588
|
return h("div", { style: wrapStyle },
|
|
510
|
-
h("h2", { style: titleStyle }, "
|
|
589
|
+
h("h2", { style: titleStyle }, "提示音配置"),
|
|
511
590
|
h("p", { style: ph }, "设置不可用(宿主端未注册 falling-ts-web-ding 命名空间)。"));
|
|
512
591
|
}
|
|
513
592
|
if (snap.status === "loading" || value === undefined) {
|
|
514
593
|
return h("div", { style: wrapStyle },
|
|
515
|
-
h("h2", { style: titleStyle }, "
|
|
594
|
+
h("h2", { style: titleStyle }, "提示音配置"),
|
|
516
595
|
h("p", { style: ph }, "加载中…"));
|
|
517
596
|
}
|
|
518
597
|
const disabled = !snap.writable;
|
|
519
598
|
const v = (value && typeof value === "object") ? value : {};
|
|
520
599
|
const pct = (n) => Math.round((Number(n) || 0) * 100) + "%";
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
600
|
+
// 两块配置,共用模板:第一块"弹出用户选择"(question),第二块"回合结束"(turnEnd)。
|
|
601
|
+
const block = (blk, title, desc, previewBtn) => {
|
|
602
|
+
const E = "question" === blk ? "questionEnabled" : "turnEndEnabled";
|
|
603
|
+
const Vol = "question" === blk ? "questionVolume" : "turnEndVolume";
|
|
604
|
+
const Freq = "question" === blk ? "questionFreq" : "turnEndFreq";
|
|
605
|
+
const Decay = "question" === blk ? "questionDecayMs" : "turnEndDecayMs";
|
|
606
|
+
return [
|
|
607
|
+
h("h2", { key: blk + "-title", style: titleStyle }, title),
|
|
608
|
+
h("p", { key: blk + "-intro", style: introStyle }, desc),
|
|
609
|
+
h("div", { key: blk + "-enabled", style: rowStyle },
|
|
610
|
+
h("span", { style: labelStyle }, "启用"),
|
|
611
|
+
h("span", { style: controlStyle },
|
|
612
|
+
h("input", {
|
|
613
|
+
type: "checkbox",
|
|
614
|
+
checked: v[E] !== false,
|
|
615
|
+
disabled: disabled,
|
|
616
|
+
onChange: (ev) => update(E, ev.target.checked),
|
|
617
|
+
})),
|
|
618
|
+
),
|
|
619
|
+
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) }),
|
|
620
|
+
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) }),
|
|
621
|
+
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) }),
|
|
622
|
+
h("div", { key: blk + "-preview", style: lastRowStyle },
|
|
623
|
+
h("span", { style: labelStyle }, "试听"),
|
|
624
|
+
h("span", { style: controlStyle },
|
|
625
|
+
h("button", { style: buttonStyle, disabled: disabled, onClick: previewBtn }, "播放一声")),
|
|
626
|
+
),
|
|
627
|
+
];
|
|
628
|
+
};
|
|
527
629
|
return h("div", { style: wrapStyle },
|
|
528
|
-
h("h2", { style: titleStyle }, "
|
|
529
|
-
h("p", { style: introStyle }, "
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
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
|
-
),
|
|
630
|
+
h("h2", { style: { ...titleStyle, fontSize: 16 } }, "提示音配置"),
|
|
631
|
+
h("p", { style: introStyle }, "两种场景各自提示音,由浏览器 JS 纯前端 Web Audio 合成——宿主不发声、不弹 Windows/系统通知。设置写入 $DSH_HOME/settings.yaml 的 falling-ts-web-ding 段。"),
|
|
632
|
+
...block("question", "弹出用户选择",
|
|
633
|
+
"harness 弹出用户选择题(浏览器对话区的选择题卡片)时播放一声“叮”,提醒你回来作答。检测走浏览器 DOM(QuestionComposer 的 data-question-key 锚点),宿主端不参与。",
|
|
634
|
+
() => play({ volume: v.questionVolume, freq: v.questionFreq, decayMs: v.questionDecayMs })),
|
|
635
|
+
...block("turnEnd", "回合结束",
|
|
636
|
+
"agent 回合结束时(agent/status 转入 idle)播放一声“叮”。宿主只在命中 idle 转换时发信号,声音由浏览器合成。",
|
|
637
|
+
() => play({ volume: v.turnEndVolume, freq: v.turnEndFreq, decayMs: v.turnEndDecayMs })),
|
|
558
638
|
h("p", { style: { gridColumn: "1 / 3", color: hintColor, fontSize: 12, lineHeight: 1.55 } },
|
|
559
|
-
"浏览器自动播放策略:首次与页面交互(点击/按键)
|
|
639
|
+
"浏览器自动播放策略:首次与页面交互(点击/按键)或点击任一试听后,提示音才会出声。"));
|
|
560
640
|
}
|
|
561
641
|
|
|
562
642
|
/**
|
|
@@ -575,12 +655,20 @@ window.__ModuleLoader__.load({
|
|
|
575
655
|
d.value = s.value;
|
|
576
656
|
d.writable = s.writable;
|
|
577
657
|
});
|
|
658
|
+
questionValueRef = s.value; // 供 question 观察器回调读取
|
|
659
|
+
installQuestionObserver(s.value); // 一次性安装 DOM 观察器(幂等)
|
|
578
660
|
if (s.status === "ready") maybePlayFromValue(s.value);
|
|
579
661
|
} catch { /* never let a cosmetic derive take down the panel */ }
|
|
580
662
|
};
|
|
581
663
|
const unsub = scope.subscribe(derive);
|
|
582
664
|
derive();
|
|
583
665
|
ctx.effect(() => unsub, "web-ding: scope subscription");
|
|
666
|
+
ctx.effect(() => () => {
|
|
667
|
+
if (questionObserver) {
|
|
668
|
+
questionObserver.disconnect();
|
|
669
|
+
questionObserver = null;
|
|
670
|
+
}
|
|
671
|
+
}, "web-ding: question observer cleanup");
|
|
584
672
|
const injected = () => ({
|
|
585
673
|
hooks: { ding: store },
|
|
586
674
|
update: (field, value) => scope.set(field, value),
|
|
@@ -593,7 +681,7 @@ window.__ModuleLoader__.load({
|
|
|
593
681
|
name: "settings.section",
|
|
594
682
|
id: "web-ding",
|
|
595
683
|
order: 80,
|
|
596
|
-
label: () => "
|
|
684
|
+
label: () => "提示音配置",
|
|
597
685
|
inject: injected,
|
|
598
686
|
}, DingSection));
|
|
599
687
|
}
|