@falling-ts/dsh-web-ding 0.1.0 → 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 +32 -7
- package/README.md +43 -8
- package/assets/setting-ding.png +0 -0
- package/package.json +2 -1
- package/src/core/settings.js +37 -23
- package/src/hooks/idle.js +2 -2
- package/web/client.js +492 -46
package/README.cn.md
CHANGED
|
@@ -13,7 +13,9 @@ Node/后端从不发声,也不走 Windows/系统通知。
|
|
|
13
13
|
| 层 | 行为 |
|
|
14
14
|
|----|------|
|
|
15
15
|
| Host(`index.js` + `src/`) | 监听 `agent/status` 的 **idle 转变**(全部轮次与子代理结束、下一次人为对话之前;新建会话从未运行过的首次 idle 与重复 idle tick 都保持静默)。向 `falling-ts-web-ding` 设置命名空间写入 `{ phase:'done', at, sessionId }`。绝不发声、绝不调用系统。 |
|
|
16
|
-
| 浏览器(`web/client.js`) | 经 `settingsScope` 实时镜像命名空间;收到 `at` 严格更新的 `done` 信号后用 Web Audio API 合成一声短"叮"(
|
|
16
|
+
| 浏览器(`web/client.js`) | 经 `settingsScope` 实时镜像命名空间;收到 `at` 严格更新的 `done` 信号后用 Web Audio API 合成一声短"叮",经标签页播放——同一瞬间在右下角弹出 Win11 风格 toast。点击 toast 展开右侧消息列表,展示全部回合结束消息(存在浏览器 localStorage,上限 100、按 signal `at` 去重),支持单条删除与全部删除。全程纯前端:后端不发声、不弹系统通知。 |
|
|
17
|
+
|
|
18
|
+
除了提示音,每次回合结束还会在右下角弹出一条 Win11 风格 toast(6 秒自动消失)。点击它即可打开右侧消息列表:最近 100 条回合结束消息保存在浏览器 localStorage(键 falling-ts-web-ding.notify.v1),每条可单独删除,顶部"全部删除"按钮一键清空。消息记录纯前端——Host 不读也不写。
|
|
17
19
|
|
|
18
20
|
## 安装
|
|
19
21
|
|
|
@@ -26,16 +28,29 @@ dsh plugin --profile web add github:falling-ts/dsh-web-ding
|
|
|
26
28
|
|
|
27
29
|
## 配置(`falling-ts-web-ding` 命名空间,$DSH_HOME/settings.yaml)
|
|
28
30
|
|
|
31
|
+
两块独立配置,各自有开关与音色:
|
|
32
|
+
|
|
33
|
+
**块 1 — 弹出用户选择** —— harness 弹出用户选择题时在浏览器播放一声"叮"。
|
|
34
|
+
检测在浏览器侧完成(DOM 上 QuestionComposer 的 `[data-question-key]` 锚点):
|
|
35
|
+
宿主看不到 question 帧,故本块完全由前端实现。
|
|
36
|
+
|
|
29
37
|
| 字段 | 类型 | 默认 | 含义 |
|
|
30
38
|
|------|------|------|------|
|
|
31
|
-
| `
|
|
32
|
-
| `
|
|
33
|
-
| `
|
|
34
|
-
| `
|
|
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)。 |
|
|
35
43
|
|
|
36
|
-
|
|
44
|
+
**块 2 — 回合结束** —— 经典的 agent/status idle 转变提示音。Host 观察
|
|
45
|
+
`agent/status`,在 idle 转变(全部轮次与子代理结束、下一次人为对话之前;
|
|
46
|
+
新建后从未运行过的会话与重复 idle tick 保持静默)时发布 `done` 信号。
|
|
37
47
|
|
|
38
|
-
|
|
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)。 |
|
|
39
54
|
|
|
40
55
|
## 浏览器自动播放策略
|
|
41
56
|
|
|
@@ -56,6 +71,16 @@ dsh plugin --profile web add /path/to/dsh-web-ding
|
|
|
56
71
|
插件自身规则见 `AGENTS.md`(纯 Host 监听器、后端不发声、不弹系统通知、只用
|
|
57
72
|
官方 settings 镜像通道通向浏览器)。
|
|
58
73
|
|
|
74
|
+
## 效果截图
|
|
75
|
+
|
|
76
|
+

|
|
77
|
+
|
|
78
|
+
*设置页——「提示音配置」分区:「弹出用户选择」与「回合结束」两块,各自
|
|
79
|
+
开关、音量、频率、衰减时长,免重启实时可调,各带"试听"按钮预热浏览器
|
|
80
|
+
AudioContext。*
|
|
81
|
+
|
|
82
|
+
---
|
|
83
|
+
|
|
59
84
|
## License
|
|
60
85
|
|
|
61
86
|
MIT
|
package/README.md
CHANGED
|
@@ -15,7 +15,7 @@ a pure-listener Host half + a browser client half connected by the official
|
|
|
15
15
|
| Layer | What happens |
|
|
16
16
|
|-------|--------------|
|
|
17
17
|
| Host (`index.js` + `src/`) | Listens for the `agent/status` **idle transition** (all turns done, including sub-agents, before the next human turn; a fresh idle session that never ran, and repeated idle ticks, stay silent). Publishes `{ phase:'done', at, sessionId }` into the `falling-ts-web-ding` settings namespace. Never emits audio, never calls the OS. |
|
|
18
|
-
| Browser (`web/client.js`) | Mirrors the namespace live via `settingsScope`. On a strictly-newer `done` signal it synthesizes a short `ding` (three sine oscillators + exponential decay envelopes) with the Web Audio API and plays it through the tab. |
|
|
18
|
+
| Browser (`web/client.js`) | Mirrors the namespace live via `settingsScope`. On a strictly-newer `done` signal it synthesizes a short `ding` (three sine oscillators + exponential decay envelopes) with the Web Audio API and plays it through the tab — and, at the same moment, pops a Win11-style toast in the bottom-right corner. Clicking the toast slides in a notification drawer listing every turn-end message (kept in browser `localStorage`, capped at 100, deduped by signal `at`), with per-message delete and a clear-all button. Everything stays front-end: no backend audio, no OS notification. |
|
|
19
19
|
|
|
20
20
|
## Install
|
|
21
21
|
|
|
@@ -28,17 +28,41 @@ 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
|
+
|
|
38
|
+
| Field | Type | Default | Meaning |
|
|
39
|
+
|-------|------|---------|---------|
|
|
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.
|
|
49
|
+
|
|
31
50
|
| Field | Type | Default | Meaning |
|
|
32
51
|
|-------|------|---------|---------|
|
|
33
|
-
| `
|
|
34
|
-
| `
|
|
35
|
-
| `
|
|
36
|
-
| `
|
|
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). |
|
|
37
56
|
|
|
38
|
-
|
|
57
|
+
Besides the ring, each turn end drops a bottom-right toast (Win11-style,
|
|
58
|
+
auto-dismisses after 6 s). Click it to open the right-side notification
|
|
59
|
+
drawer: the last 100 turn-end messages are kept in the browser's
|
|
60
|
+
`localStorage` (key `falling-ts-web-ding.notify.v1`), each row can be
|
|
61
|
+
deleted individually, and a "全部删除" button clears them all. The message
|
|
62
|
+
log is front-end only — the Host never reads or writes it.
|
|
39
63
|
|
|
40
|
-
You can also adjust
|
|
41
|
-
|
|
64
|
+
You can also adjust both blocks from the **设置 → 提示音配置** panel,
|
|
65
|
+
each with its own switch and its own "试听" (preview) button.
|
|
42
66
|
|
|
43
67
|
## Browser autoplay policy
|
|
44
68
|
|
|
@@ -61,6 +85,17 @@ See `AGENTS.md` for the plugin's own rules (pure Host listener, no backend
|
|
|
61
85
|
audio, no OS notification, only sanctioned settings-mirror channel to the
|
|
62
86
|
browser).
|
|
63
87
|
|
|
88
|
+
## Screenshots
|
|
89
|
+
|
|
90
|
+

|
|
91
|
+
|
|
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.*
|
|
96
|
+
|
|
97
|
+
---
|
|
98
|
+
|
|
64
99
|
## License
|
|
65
100
|
|
|
66
101
|
MIT
|
|
Binary file
|
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",
|
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
"index.js",
|
|
14
14
|
"src/",
|
|
15
15
|
"web/",
|
|
16
|
+
"assets/",
|
|
16
17
|
"cordis.patch.yml"
|
|
17
18
|
],
|
|
18
19
|
"license": "MIT",
|
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),"试听"按钮点击本身
|
|
@@ -102,7 +105,357 @@ window.__ModuleLoader__.load({
|
|
|
102
105
|
return true;
|
|
103
106
|
}
|
|
104
107
|
|
|
105
|
-
|
|
108
|
+
// ── 回合结束消息缓存(浏览器侧,localStorage 持久化) ------------------------
|
|
109
|
+
// 每次回合结束写入一条消息:{ at, timeText, sessionId }。缓存按 at 去重、上限
|
|
110
|
+
// NOTIFY_CAP 条,存 localStorage;右下角弹窗与右侧消息列表通过 subscribeNotify
|
|
111
|
+
// 订阅变更。这是纯前端数据(零后端、零系统通知),与 Web Audio 播放同源。
|
|
112
|
+
const NOTIFY_KEY = "falling-ts-web-ding.notify.v1";
|
|
113
|
+
const NOTIFY_CAP = 100;
|
|
114
|
+
let notifyCache = null; // null = 尚未加载(惰性)
|
|
115
|
+
const notifyListeners = new Set();
|
|
116
|
+
|
|
117
|
+
function loadNotifyCache() {
|
|
118
|
+
if (notifyCache !== null) return notifyCache;
|
|
119
|
+
try {
|
|
120
|
+
const raw = window.localStorage.getItem(NOTIFY_KEY);
|
|
121
|
+
const arr = raw ? JSON.parse(raw) : [];
|
|
122
|
+
notifyCache = Array.isArray(arr) ? arr.filter((m) => m && typeof m.at === "number") : [];
|
|
123
|
+
} catch {
|
|
124
|
+
notifyCache = [];
|
|
125
|
+
}
|
|
126
|
+
return notifyCache;
|
|
127
|
+
}
|
|
128
|
+
function saveNotifyCache() {
|
|
129
|
+
try {
|
|
130
|
+
window.localStorage.setItem(NOTIFY_KEY, JSON.stringify(notifyCache.slice(0, NOTIFY_CAP)));
|
|
131
|
+
} catch { /* 无存储可用(private 模式等)时仅保留内存副本 */ }
|
|
132
|
+
}
|
|
133
|
+
function emitNotifyChange() {
|
|
134
|
+
notifyCache = loadNotifyCache();
|
|
135
|
+
const list = notifyCache;
|
|
136
|
+
notifyListeners.forEach((fn) => { try { fn(list); } catch {} });
|
|
137
|
+
}
|
|
138
|
+
function subscribeNotify(fn) {
|
|
139
|
+
notifyListeners.add(fn);
|
|
140
|
+
return () => notifyListeners.delete(fn);
|
|
141
|
+
}
|
|
142
|
+
/** 记录一条"回合结束"消息(同一 at 只记一次)。title 为会话真实标题(查不到时省略)。 */
|
|
143
|
+
function recordTurnEnd(at, sessionId, title) {
|
|
144
|
+
const list = loadNotifyCache();
|
|
145
|
+
if (list.some((m) => m.at === at)) return;
|
|
146
|
+
const d = new Date(at);
|
|
147
|
+
const pad = (n) => String(n).padStart(2, "0");
|
|
148
|
+
const timeText = d.getFullYear() + "-" + pad(d.getMonth() + 1) + "-" + pad(d.getDate()) +
|
|
149
|
+
" " + pad(d.getHours()) + ":" + pad(d.getMinutes()) + ":" + pad(d.getSeconds());
|
|
150
|
+
list.unshift({
|
|
151
|
+
at,
|
|
152
|
+
timeText,
|
|
153
|
+
sessionId: typeof sessionId === "string" ? sessionId : undefined,
|
|
154
|
+
title: typeof title === "string" && title.trim() ? title : undefined,
|
|
155
|
+
});
|
|
156
|
+
if (list.length > NOTIFY_CAP) list.length = NOTIFY_CAP;
|
|
157
|
+
saveNotifyCache();
|
|
158
|
+
emitNotifyChange();
|
|
159
|
+
}
|
|
160
|
+
/**
|
|
161
|
+
* 通过 session.list 查询会话的真实标题(projections.values.title)。
|
|
162
|
+
* 信号里只有 sessionId,标题不在事件负载中;失败或未找到时返回
|
|
163
|
+
* undefined,调用方回退到无标题展示。
|
|
164
|
+
* @param {string|undefined} sessionId
|
|
165
|
+
* @returns {Promise<string|undefined>}
|
|
166
|
+
*/
|
|
167
|
+
async function fetchSessionTitle(sessionId) {
|
|
168
|
+
if (!sessionId) return undefined;
|
|
169
|
+
try {
|
|
170
|
+
const res = await fetch(location.origin + "/api/session.list", {
|
|
171
|
+
method: "POST",
|
|
172
|
+
headers: { "Content-Type": "application/json" },
|
|
173
|
+
body: JSON.stringify({
|
|
174
|
+
type: "client-request",
|
|
175
|
+
rpcId: (window.crypto && window.crypto.randomUUID ? window.crypto.randomUUID() : String(Date.now())),
|
|
176
|
+
method: "session.list",
|
|
177
|
+
payload: {},
|
|
178
|
+
}),
|
|
179
|
+
});
|
|
180
|
+
const json = await res.json();
|
|
181
|
+
const result = json && json.result;
|
|
182
|
+
const items = result && result.ok && Array.isArray(result.value && result.value.items) ? result.value.items : [];
|
|
183
|
+
const row = items.find((it) => it && it.sessionId === sessionId);
|
|
184
|
+
const title = row && row.projections && row.projections.values && row.projections.values.title;
|
|
185
|
+
return typeof title === "string" && title.trim() ? title : undefined;
|
|
186
|
+
} catch {
|
|
187
|
+
return undefined;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
/** 删除单条"回合结束"消息(按 at 匹配)。 */
|
|
191
|
+
function removeRecord(at) {
|
|
192
|
+
const list = loadNotifyCache();
|
|
193
|
+
const next = list.filter((m) => m.at !== at);
|
|
194
|
+
if (next.length === list.length) return;
|
|
195
|
+
notifyCache = next;
|
|
196
|
+
saveNotifyCache();
|
|
197
|
+
emitNotifyChange();
|
|
198
|
+
}
|
|
199
|
+
/** 清空全部"回合结束"消息。 */
|
|
200
|
+
function clearAllRecords() {
|
|
201
|
+
if (!loadNotifyCache().length) return;
|
|
202
|
+
notifyCache = [];
|
|
203
|
+
saveNotifyCache();
|
|
204
|
+
emitNotifyChange();
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// ── 右下角通知弹窗(Win11 风格,纯内联样式/零资产) --------------------------
|
|
208
|
+
// 回合结束 ding 的同时弹出;6 秒自动消失,可手动关闭。点击主体在 C3 起
|
|
209
|
+
// 展开右侧消息列表。所有样式内联,不引入任何图片/CSS 资产。
|
|
210
|
+
const TOAST_TINT = "linear-gradient(135deg, rgba(0,120,212,0.16), rgba(0,120,212,0.05))";
|
|
211
|
+
let toastLayer = null;
|
|
212
|
+
function ensureToastLayer() {
|
|
213
|
+
if (toastLayer && document.body.contains(toastLayer)) return toastLayer;
|
|
214
|
+
toastLayer = document.createElement("div");
|
|
215
|
+
Object.assign(toastLayer.style, {
|
|
216
|
+
position: "fixed", right: "20px", bottom: "20px", zIndex: 2147483000,
|
|
217
|
+
display: "flex", flexDirection: "column", alignItems: "flex-end", gap: "12px",
|
|
218
|
+
pointerEvents: "none",
|
|
219
|
+
});
|
|
220
|
+
document.body.appendChild(toastLayer);
|
|
221
|
+
return toastLayer;
|
|
222
|
+
}
|
|
223
|
+
function dismissToast(el) {
|
|
224
|
+
el.style.transition = "opacity 0.22s ease, transform 0.28s cubic-bezier(0.16, 1, 0.3, 1)";
|
|
225
|
+
el.style.opacity = "0";
|
|
226
|
+
el.style.transform = "translateX(28px) scale(0.97)";
|
|
227
|
+
window.setTimeout(() => { if (el.parentNode) el.parentNode.removeChild(el); }, 300);
|
|
228
|
+
}
|
|
229
|
+
function showTurnEndToast(msg) {
|
|
230
|
+
const layer = ensureToastLayer();
|
|
231
|
+
const d = new Date(msg.at);
|
|
232
|
+
const pad = (n) => String(n).padStart(2, "0");
|
|
233
|
+
const timeText = msg.timeText ||
|
|
234
|
+
(d.getFullYear() + "-" + pad(d.getMonth() + 1) + "-" + pad(d.getDate()) +
|
|
235
|
+
" " + pad(d.getHours()) + ":" + pad(d.getMinutes()) + ":" + pad(d.getSeconds()));
|
|
236
|
+
const el = document.createElement("div");
|
|
237
|
+
Object.assign(el.style, {
|
|
238
|
+
pointerEvents: "auto", position: "relative", width: 360, minHeight: 88,
|
|
239
|
+
background: "rgba(255,255,255,0.88)", backdropFilter: "blur(20px) saturate(1.5)",
|
|
240
|
+
WebkitBackdropFilter: "blur(20px) saturate(1.5)",
|
|
241
|
+
border: "1px solid rgba(0,0,0,0.05)", borderRadius: 16,
|
|
242
|
+
boxShadow: "0 1px 2px rgba(0,0,0,0.04), 0 6px 16px rgba(0,0,0,0.08), 0 20px 48px rgba(0,0,0,0.14)",
|
|
243
|
+
overflow: "hidden",
|
|
244
|
+
display: "flex", alignItems: "stretch", cursor: "pointer",
|
|
245
|
+
opacity: "0", transform: "translateX(28px) scale(0.97)",
|
|
246
|
+
transition: "opacity 0.22s ease, transform 0.3s cubic-bezier(0.16, 1, 0.3, 1)",
|
|
247
|
+
fontFamily: "-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Microsoft YaHei', sans-serif",
|
|
248
|
+
color: "#1f1f1f", fontSize: 13,
|
|
249
|
+
});
|
|
250
|
+
const accent = document.createElement("div");
|
|
251
|
+
Object.assign(accent.style, {
|
|
252
|
+
width: 4, flexShrink: 0, background: "#0078d4",
|
|
253
|
+
});
|
|
254
|
+
const body = document.createElement("div");
|
|
255
|
+
Object.assign(body.style, { padding: "17px 18px 16px", minWidth: 0 });
|
|
256
|
+
const head = document.createElement("div");
|
|
257
|
+
Object.assign(head.style, {
|
|
258
|
+
display: "flex", alignItems: "center", justifyContent: "space-between", gap: 10,
|
|
259
|
+
});
|
|
260
|
+
const title = document.createElement("span");
|
|
261
|
+
title.textContent = "回合结束";
|
|
262
|
+
Object.assign(title.style, { fontSize: 14, fontWeight: 600, letterSpacing: 0.2 });
|
|
263
|
+
const closeBtn = document.createElement("button");
|
|
264
|
+
closeBtn.type = "button";
|
|
265
|
+
closeBtn.textContent = "×";
|
|
266
|
+
Object.assign(closeBtn.style, {
|
|
267
|
+
border: "none", background: "transparent", cursor: "pointer",
|
|
268
|
+
fontSize: 17, lineHeight: 1, padding: "4px 8px", borderRadius: 8,
|
|
269
|
+
color: "rgba(0,0,0,0.55)", transition: "background 0.15s ease",
|
|
270
|
+
});
|
|
271
|
+
closeBtn.addEventListener("mouseenter", () => { closeBtn.style.background = "rgba(0,0,0,0.06)"; });
|
|
272
|
+
closeBtn.addEventListener("mouseleave", () => { closeBtn.style.background = "transparent"; });
|
|
273
|
+
closeBtn.addEventListener("click", (ev) => {
|
|
274
|
+
ev.stopPropagation();
|
|
275
|
+
window.clearTimeout(el._timer);
|
|
276
|
+
dismissToast(el);
|
|
277
|
+
});
|
|
278
|
+
head.appendChild(title);
|
|
279
|
+
head.appendChild(closeBtn);
|
|
280
|
+
const text = document.createElement("div");
|
|
281
|
+
text.textContent = msg.title ? msg.title + " 已完成" : "agent 已完成回合——浏览器播放一声“叮”。";
|
|
282
|
+
Object.assign(text.style, { marginTop: 10, lineHeight: 1.65, color: "rgba(0,0,0,0.66)" });
|
|
283
|
+
const foot = document.createElement("div");
|
|
284
|
+
foot.textContent = timeText;
|
|
285
|
+
Object.assign(foot.style, { marginTop: 12, fontSize: 12, color: "rgba(0,0,0,0.48)" });
|
|
286
|
+
body.appendChild(head);
|
|
287
|
+
body.appendChild(text);
|
|
288
|
+
body.appendChild(foot);
|
|
289
|
+
el.appendChild(accent);
|
|
290
|
+
el.appendChild(body);
|
|
291
|
+
layer.appendChild(el);
|
|
292
|
+
void el.offsetHeight; // force reflow → 触发进入动画
|
|
293
|
+
el.style.opacity = "1";
|
|
294
|
+
el.style.transform = "translateX(0) scale(1)";
|
|
295
|
+
el._timer = window.setTimeout(() => dismissToast(el), 6000);
|
|
296
|
+
el.addEventListener("click", () => {
|
|
297
|
+
window.clearTimeout(el._timer);
|
|
298
|
+
openNotifyDrawer();
|
|
299
|
+
});
|
|
300
|
+
return el;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// ── 右侧消息列表面板(点击 toast 展开;纯前端 DOM,零资产) ------------------
|
|
304
|
+
// 覆盖层 + 右侧滑入面板:列出缓存里的回合结束消息(时间、会话摘要),空态
|
|
305
|
+
// 提示;通过 subscribeNotify 订阅缓存变更实时重绘。点击遮罩或 × 关闭。
|
|
306
|
+
let drawerHost = null;
|
|
307
|
+
let drawerUnsub = null;
|
|
308
|
+
function closeNotifyDrawer() {
|
|
309
|
+
if (drawerUnsub) { drawerUnsub(); drawerUnsub = null; }
|
|
310
|
+
if (!drawerHost || !document.body.contains(drawerHost)) { drawerHost = null; return; }
|
|
311
|
+
const overlay = drawerHost;
|
|
312
|
+
const panel = overlay._panel;
|
|
313
|
+
panel.style.transform = "translateX(100%)";
|
|
314
|
+
overlay.style.opacity = "0";
|
|
315
|
+
window.setTimeout(() => {
|
|
316
|
+
if (overlay.parentNode) overlay.parentNode.removeChild(overlay);
|
|
317
|
+
if (drawerHost === overlay) drawerHost = null;
|
|
318
|
+
}, 300);
|
|
319
|
+
}
|
|
320
|
+
function renderDrawerList(container) {
|
|
321
|
+
container.textContent = "";
|
|
322
|
+
const list = loadNotifyCache();
|
|
323
|
+
if (!list.length) {
|
|
324
|
+
const empty = document.createElement("div");
|
|
325
|
+
empty.textContent = "暂无回合结束消息";
|
|
326
|
+
Object.assign(empty.style, {
|
|
327
|
+
padding: "56px 20px", textAlign: "center",
|
|
328
|
+
color: "rgba(0,0,0,0.42)", fontSize: 13, lineHeight: 1.6,
|
|
329
|
+
});
|
|
330
|
+
container.appendChild(empty);
|
|
331
|
+
return;
|
|
332
|
+
}
|
|
333
|
+
list.forEach((m) => {
|
|
334
|
+
const row = document.createElement("div");
|
|
335
|
+
Object.assign(row.style, {
|
|
336
|
+
display: "flex", alignItems: "center", gap: 12,
|
|
337
|
+
padding: "14px 16px", borderRadius: 12,
|
|
338
|
+
marginBottom: 6, cursor: "default",
|
|
339
|
+
background: "rgba(0,0,0,0)", transition: "background 0.16s ease",
|
|
340
|
+
});
|
|
341
|
+
row.addEventListener("mouseenter", () => { row.style.background = "rgba(0,0,0,0.045)"; });
|
|
342
|
+
row.addEventListener("mouseleave", () => { row.style.background = "rgba(0,0,0,0)"; });
|
|
343
|
+
const info = document.createElement("div");
|
|
344
|
+
Object.assign(info.style, { flex: 1, minWidth: 0 });
|
|
345
|
+
const timeEl = document.createElement("div");
|
|
346
|
+
timeEl.textContent = m.timeText;
|
|
347
|
+
Object.assign(timeEl.style, { fontSize: 13, fontWeight: 600, fontVariantNumeric: "tabular-nums" });
|
|
348
|
+
const subEl = document.createElement("div");
|
|
349
|
+
subEl.textContent = m.title ? m.title + " 已完成" : (m.sessionId ? "会话 " + String(m.sessionId).slice(0, 12) + "…" : "回合结束");
|
|
350
|
+
Object.assign(subEl.style, { fontSize: 12.5, color: "rgba(0,0,0,0.5)", marginTop: 4 });
|
|
351
|
+
info.appendChild(timeEl);
|
|
352
|
+
info.appendChild(subEl);
|
|
353
|
+
const del = document.createElement("button");
|
|
354
|
+
del.type = "button";
|
|
355
|
+
del.textContent = "删除";
|
|
356
|
+
Object.assign(del.style, {
|
|
357
|
+
border: "1px solid rgba(0,0,0,0.14)", background: "transparent",
|
|
358
|
+
borderRadius: 8, padding: "5px 12px", cursor: "pointer",
|
|
359
|
+
fontSize: 12.5, color: "rgba(0,0,0,0.6)", flexShrink: 0,
|
|
360
|
+
transition: "background 0.16s ease, border-color 0.16s ease",
|
|
361
|
+
});
|
|
362
|
+
del.addEventListener("mouseenter", () => {
|
|
363
|
+
del.style.background = "rgba(0,0,0,0.06)";
|
|
364
|
+
del.style.borderColor = "rgba(0,0,0,0.26)";
|
|
365
|
+
});
|
|
366
|
+
del.addEventListener("mouseleave", () => {
|
|
367
|
+
del.style.background = "transparent";
|
|
368
|
+
del.style.borderColor = "rgba(0,0,0,0.14)";
|
|
369
|
+
});
|
|
370
|
+
del.addEventListener("click", (ev) => { ev.stopPropagation(); removeRecord(m.at); });
|
|
371
|
+
row.appendChild(info);
|
|
372
|
+
row.appendChild(del);
|
|
373
|
+
container.appendChild(row);
|
|
374
|
+
});
|
|
375
|
+
}
|
|
376
|
+
function openNotifyDrawer() {
|
|
377
|
+
if (drawerHost && document.body.contains(drawerHost)) { closeNotifyDrawer(); return; }
|
|
378
|
+
const overlay = document.createElement("div");
|
|
379
|
+
Object.assign(overlay.style, {
|
|
380
|
+
position: "fixed", inset: 0, zIndex: 2147483001,
|
|
381
|
+
background: "rgba(0,0,0,0.36)",
|
|
382
|
+
pointerEvents: "auto", opacity: 0,
|
|
383
|
+
transition: "opacity 0.2s ease",
|
|
384
|
+
});
|
|
385
|
+
overlay.addEventListener("click", closeNotifyDrawer);
|
|
386
|
+
const panel = document.createElement("div");
|
|
387
|
+
Object.assign(panel.style, {
|
|
388
|
+
position: "absolute", top: 0, right: 0, height: "100%", width: 400, maxWidth: "94vw",
|
|
389
|
+
background: "rgba(255,255,255,0.94)",
|
|
390
|
+
backdropFilter: "blur(24px) saturate(1.5)",
|
|
391
|
+
WebkitBackdropFilter: "blur(24px) saturate(1.5)",
|
|
392
|
+
boxShadow: "-2px 0 6px rgba(0,0,0,0.04), -10px 0 32px rgba(0,0,0,0.10), -32px 0 80px rgba(0,0,0,0.12)",
|
|
393
|
+
transform: "translateX(100%)",
|
|
394
|
+
transition: "transform 0.26s cubic-bezier(0.16, 1, 0.3, 1)",
|
|
395
|
+
display: "flex", flexDirection: "column",
|
|
396
|
+
fontFamily: "-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Microsoft YaHei', sans-serif",
|
|
397
|
+
color: "#1f1f1f",
|
|
398
|
+
});
|
|
399
|
+
const header = document.createElement("div");
|
|
400
|
+
Object.assign(header.style, {
|
|
401
|
+
display: "flex", alignItems: "center", justifyContent: "space-between",
|
|
402
|
+
padding: "18px 20px 16px", borderBottom: "1px solid rgba(0,0,0,0.07)",
|
|
403
|
+
flexShrink: 0,
|
|
404
|
+
});
|
|
405
|
+
const titleEl = document.createElement("span");
|
|
406
|
+
titleEl.textContent = "回合结束消息";
|
|
407
|
+
Object.assign(titleEl.style, { fontSize: 16, fontWeight: 700, letterSpacing: 0.2 });
|
|
408
|
+
const clearBtn = document.createElement("button");
|
|
409
|
+
clearBtn.type = "button";
|
|
410
|
+
clearBtn.textContent = "全部删除";
|
|
411
|
+
Object.assign(clearBtn.style, {
|
|
412
|
+
border: "1px solid rgba(0,0,0,0.14)", background: "transparent",
|
|
413
|
+
borderRadius: 8, padding: "5px 12px", cursor: "pointer",
|
|
414
|
+
fontSize: 12.5, color: "rgba(0,0,0,0.6)",
|
|
415
|
+
transition: "background 0.16s ease, border-color 0.16s ease",
|
|
416
|
+
});
|
|
417
|
+
clearBtn.addEventListener("mouseenter", () => {
|
|
418
|
+
clearBtn.style.background = "rgba(0,0,0,0.06)";
|
|
419
|
+
clearBtn.style.borderColor = "rgba(0,0,0,0.26)";
|
|
420
|
+
});
|
|
421
|
+
clearBtn.addEventListener("mouseleave", () => {
|
|
422
|
+
clearBtn.style.background = "transparent";
|
|
423
|
+
clearBtn.style.borderColor = "rgba(0,0,0,0.14)";
|
|
424
|
+
});
|
|
425
|
+
clearBtn.addEventListener("click", (ev) => { ev.stopPropagation(); clearAllRecords(); });
|
|
426
|
+
const closeBtn = document.createElement("button");
|
|
427
|
+
closeBtn.type = "button";
|
|
428
|
+
closeBtn.textContent = "×";
|
|
429
|
+
Object.assign(closeBtn.style, {
|
|
430
|
+
border: "none", background: "transparent", cursor: "pointer",
|
|
431
|
+
fontSize: 19, lineHeight: 1, padding: "4px 9px", borderRadius: 8,
|
|
432
|
+
color: "rgba(0,0,0,0.55)", transition: "background 0.15s ease",
|
|
433
|
+
});
|
|
434
|
+
closeBtn.addEventListener("mouseenter", () => { closeBtn.style.background = "rgba(0,0,0,0.06)"; });
|
|
435
|
+
closeBtn.addEventListener("mouseleave", () => { closeBtn.style.background = "transparent"; });
|
|
436
|
+
closeBtn.addEventListener("click", (ev) => { ev.stopPropagation(); closeNotifyDrawer(); });
|
|
437
|
+
const headerActions = document.createElement("div");
|
|
438
|
+
Object.assign(headerActions.style, { display: "flex", alignItems: "center", gap: 8 });
|
|
439
|
+
headerActions.appendChild(clearBtn);
|
|
440
|
+
headerActions.appendChild(closeBtn);
|
|
441
|
+
header.appendChild(titleEl);
|
|
442
|
+
header.appendChild(headerActions);
|
|
443
|
+
const listEl = document.createElement("div");
|
|
444
|
+
Object.assign(listEl.style, { flex: 1, overflowY: "auto", padding: "10px 12px 14px" });
|
|
445
|
+
panel.appendChild(header);
|
|
446
|
+
panel.appendChild(listEl);
|
|
447
|
+
overlay.appendChild(panel);
|
|
448
|
+
document.body.appendChild(overlay);
|
|
449
|
+
overlay._panel = panel;
|
|
450
|
+
drawerHost = overlay;
|
|
451
|
+
renderDrawerList(listEl);
|
|
452
|
+
drawerUnsub = subscribeNotify(() => renderDrawerList(listEl));
|
|
453
|
+
void overlay.offsetHeight;
|
|
454
|
+
overlay.style.opacity = "1";
|
|
455
|
+
panel.style.transform = "translateX(0)";
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
// ── 命名空间快照 → 信号检测 → 播放 ------------------------------------------
|
|
106
459
|
// lastAt:本页面最后响应过的 signal.at。首帧(页面加载时已存在的残留)只做
|
|
107
460
|
// 基线、不播放;之后 at 严格增长的新 'done' 信号才叮一声。
|
|
108
461
|
let lastAt = null;
|
|
@@ -118,11 +471,57 @@ window.__ModuleLoader__.load({
|
|
|
118
471
|
}
|
|
119
472
|
if (!(at > lastAt)) return;
|
|
120
473
|
lastAt = at;
|
|
121
|
-
if (value.
|
|
122
|
-
playDing({ volume: value.
|
|
474
|
+
if (value.turnEndEnabled !== false) {
|
|
475
|
+
playDing({ volume: value.turnEndVolume, freq: value.turnEndFreq, decayMs: value.turnEndDecayMs });
|
|
476
|
+
const sessionId = typeof sig.sessionId === "string" ? sig.sessionId : undefined;
|
|
477
|
+
// 信号里只有 sessionId:异步查一次真实标题再落缓存与弹 toast。
|
|
478
|
+
void (async () => {
|
|
479
|
+
const title = await fetchSessionTitle(sessionId);
|
|
480
|
+
const msg = { at, sessionId, title };
|
|
481
|
+
recordTurnEnd(at, sessionId, title);
|
|
482
|
+
showTurnEndToast(msg);
|
|
483
|
+
})();
|
|
123
484
|
}
|
|
124
485
|
}
|
|
125
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
|
+
|
|
126
525
|
// ── 设置分区 UI --------------------------------------------------------------
|
|
127
526
|
const divider = "rgba(0,0,0,0.08)";
|
|
128
527
|
const hintColor = "rgba(0,0,0,0.45)";
|
|
@@ -139,6 +538,44 @@ window.__ModuleLoader__.load({
|
|
|
139
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 };
|
|
140
539
|
const inputRangeStyle = { flex: 1, minWidth: 140 };
|
|
141
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
|
+
|
|
142
579
|
function DingSection(props) {
|
|
143
580
|
// The renderer binds the injected hooks compartment ('ding' key) into a
|
|
144
581
|
// use<Name> hook; the store is the bare observable, so a selector reads
|
|
@@ -149,56 +586,57 @@ window.__ModuleLoader__.load({
|
|
|
149
586
|
const ph = hintStyle;
|
|
150
587
|
if (snap.status === "unavailable") {
|
|
151
588
|
return h("div", { style: wrapStyle },
|
|
152
|
-
h("h2", { style: titleStyle }, "
|
|
589
|
+
h("h2", { style: titleStyle }, "提示音配置"),
|
|
153
590
|
h("p", { style: ph }, "设置不可用(宿主端未注册 falling-ts-web-ding 命名空间)。"));
|
|
154
591
|
}
|
|
155
592
|
if (snap.status === "loading" || value === undefined) {
|
|
156
593
|
return h("div", { style: wrapStyle },
|
|
157
|
-
h("h2", { style: titleStyle }, "
|
|
594
|
+
h("h2", { style: titleStyle }, "提示音配置"),
|
|
158
595
|
h("p", { style: ph }, "加载中…"));
|
|
159
596
|
}
|
|
160
597
|
const disabled = !snap.writable;
|
|
161
598
|
const v = (value && typeof value === "object") ? value : {};
|
|
162
599
|
const pct = (n) => Math.round((Number(n) || 0) * 100) + "%";
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
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
|
+
};
|
|
169
629
|
return h("div", { style: wrapStyle },
|
|
170
|
-
h("h2", { style: titleStyle }, "
|
|
171
|
-
h("p", { style: introStyle }, "
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
disabled: disabled,
|
|
179
|
-
onChange: (ev) => update("enabled", ev.target.checked),
|
|
180
|
-
})),
|
|
181
|
-
),
|
|
182
|
-
sliderRow("音量",
|
|
183
|
-
{ min: 0, max: 1, step: 0.05, value: Number(v.volume) || 0.7, disabled: disabled, onChange: (ev) => update("volume", Number(ev.target.value)) },
|
|
184
|
-
pct(v.volume)),
|
|
185
|
-
sliderRow("音色频率(Hz)",
|
|
186
|
-
{ min: 120, max: 2000, step: 10, value: Number(v.freq) || 880, disabled: disabled, onChange: (ev) => update("freq", Number(ev.target.value)) },
|
|
187
|
-
Math.round(Number(v.freq) || 880) + " Hz"),
|
|
188
|
-
sliderRow("衰减时长(ms)",
|
|
189
|
-
{ min: 100, max: 2000, step: 50, value: Number(v.decayMs) || 900, disabled: disabled, onChange: (ev) => update("decayMs", Number(ev.target.value)) },
|
|
190
|
-
Math.round(Number(v.decayMs) || 900) + " ms"),
|
|
191
|
-
h("div", { key: "preview", style: lastRowStyle },
|
|
192
|
-
h("span", { style: labelStyle }, "试听"),
|
|
193
|
-
h("span", { style: controlStyle },
|
|
194
|
-
h("button", {
|
|
195
|
-
style: buttonStyle,
|
|
196
|
-
disabled: disabled,
|
|
197
|
-
onClick: () => play({ volume: v.volume, freq: v.freq, decayMs: v.decayMs }),
|
|
198
|
-
}, "播放一声")),
|
|
199
|
-
),
|
|
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 })),
|
|
200
638
|
h("p", { style: { gridColumn: "1 / 3", color: hintColor, fontSize: 12, lineHeight: 1.55 } },
|
|
201
|
-
"浏览器自动播放策略:首次与页面交互(点击/按键)
|
|
639
|
+
"浏览器自动播放策略:首次与页面交互(点击/按键)或点击任一试听后,提示音才会出声。"));
|
|
202
640
|
}
|
|
203
641
|
|
|
204
642
|
/**
|
|
@@ -217,12 +655,20 @@ window.__ModuleLoader__.load({
|
|
|
217
655
|
d.value = s.value;
|
|
218
656
|
d.writable = s.writable;
|
|
219
657
|
});
|
|
658
|
+
questionValueRef = s.value; // 供 question 观察器回调读取
|
|
659
|
+
installQuestionObserver(s.value); // 一次性安装 DOM 观察器(幂等)
|
|
220
660
|
if (s.status === "ready") maybePlayFromValue(s.value);
|
|
221
661
|
} catch { /* never let a cosmetic derive take down the panel */ }
|
|
222
662
|
};
|
|
223
663
|
const unsub = scope.subscribe(derive);
|
|
224
664
|
derive();
|
|
225
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");
|
|
226
672
|
const injected = () => ({
|
|
227
673
|
hooks: { ding: store },
|
|
228
674
|
update: (field, value) => scope.set(field, value),
|
|
@@ -235,7 +681,7 @@ window.__ModuleLoader__.load({
|
|
|
235
681
|
name: "settings.section",
|
|
236
682
|
id: "web-ding",
|
|
237
683
|
order: 80,
|
|
238
|
-
label: () => "
|
|
684
|
+
label: () => "提示音配置",
|
|
239
685
|
inject: injected,
|
|
240
686
|
}, DingSection));
|
|
241
687
|
}
|