@falling-ts/dsh-web-ding 0.1.0
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/LICENSE +21 -0
- package/README.cn.md +61 -0
- package/README.md +66 -0
- package/cordis.patch.yml +12 -0
- package/index.js +140 -0
- package/package.json +49 -0
- package/src/core/settings.js +141 -0
- package/src/core/signal.js +66 -0
- package/src/hooks/idle.js +70 -0
- package/web/client.js +247 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 falling-ts
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.cn.md
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# dsh-web-ding
|
|
2
|
+
|
|
3
|
+
一个 DSH Cordis 插件:当 **agent 回合结束**(所有轮次安静、转入空闲)时,在
|
|
4
|
+
**浏览器里**播放一声"叮"。声音**完全由前端 JavaScript(Web Audio API)合成**——
|
|
5
|
+
Node/后端从不发声,也不走 Windows/系统通知。
|
|
6
|
+
|
|
7
|
+
架构参照 [dsh-force-compact](https://github.com/falling-ts/dsh-force-compact):
|
|
8
|
+
纯监听器的 Host 半部 + 浏览器 client 半部,之间用官方
|
|
9
|
+
`settings/document-updated` 镜像通道相连。
|
|
10
|
+
|
|
11
|
+
## 工作方式
|
|
12
|
+
|
|
13
|
+
| 层 | 行为 |
|
|
14
|
+
|----|------|
|
|
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 合成一声短"叮"(三个正弦振荡器 + 指数衰减包络),经标签页播放。 |
|
|
17
|
+
|
|
18
|
+
## 安装
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
dsh plugin --profile web add github:falling-ts/dsh-web-ding
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
(需要 Web 应用带上 client bundle——`package.json` 的 `dsh.client` 声明会自动
|
|
25
|
+
完成。)
|
|
26
|
+
|
|
27
|
+
## 配置(`falling-ts-web-ding` 命名空间,$DSH_HOME/settings.yaml)
|
|
28
|
+
|
|
29
|
+
| 字段 | 类型 | 默认 | 含义 |
|
|
30
|
+
|------|------|------|------|
|
|
31
|
+
| `enabled` | boolean | `true` | 总开关(关闭时 Host 跳过发布)。 |
|
|
32
|
+
| `volume` | number 0..1 | `0.7` | Web Audio 播放音量。 |
|
|
33
|
+
| `freq` | number 80..4000 | `880` | "叮"的基频(Hz)。 |
|
|
34
|
+
| `decayMs` | number 100..4000 | `900` | 音色衰减时长(ms)。 |
|
|
35
|
+
|
|
36
|
+
也可以在 **设置 → 回合结束提示音** 面板里调整,面板带"试听"按钮。
|
|
37
|
+
|
|
38
|
+
任何会话的 agent 回合结束(转入空闲)都会提示;同一会话内重复 idle tick 与新建后从未运行过的会话保持静默。
|
|
39
|
+
|
|
40
|
+
## 浏览器自动播放策略
|
|
41
|
+
|
|
42
|
+
浏览器要求一次用户手势后才允许出声。客户端在首次指针/按键交互(以及点击
|
|
43
|
+
"试听"按钮)时预热 `AudioContext`,所以:与页面交互一次(或点一下试听),之后
|
|
44
|
+
每次回合结束就能听到叮。后台标签页里的 AudioContext 可能被浏览器自身挂起——
|
|
45
|
+
保持标签页可见才能听到声音。
|
|
46
|
+
|
|
47
|
+
## 开发
|
|
48
|
+
|
|
49
|
+
```bash
|
|
50
|
+
# 作为开发覆盖层挂载(plain JS,无构建步骤)
|
|
51
|
+
dsh web --patch $(pwd)/cordis.patch.yml # 你的 CLI 支持该选项时
|
|
52
|
+
# 或从本地路径安装
|
|
53
|
+
dsh plugin --profile web add /path/to/dsh-web-ding
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
插件自身规则见 `AGENTS.md`(纯 Host 监听器、后端不发声、不弹系统通知、只用
|
|
57
|
+
官方 settings 镜像通道通向浏览器)。
|
|
58
|
+
|
|
59
|
+
## License
|
|
60
|
+
|
|
61
|
+
MIT
|
package/README.md
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# dsh-web-ding
|
|
2
|
+
|
|
3
|
+
A DSH Cordis plugin that plays a **"ding"** in the **browser** the moment an
|
|
4
|
+
agent finishes (runs to idle). The sound is synthesized **entirely by front-end
|
|
5
|
+
JavaScript (Web Audio API)** — the Node/Host side never plays audio and no
|
|
6
|
+
Windows/system notification is used.
|
|
7
|
+
|
|
8
|
+
Built by following the architecture of
|
|
9
|
+
[dsh-force-compact](https://github.com/falling-ts/dsh-force-compact):
|
|
10
|
+
a pure-listener Host half + a browser client half connected by the official
|
|
11
|
+
`settings/document-updated` mirror channel.
|
|
12
|
+
|
|
13
|
+
## What it does
|
|
14
|
+
|
|
15
|
+
| Layer | What happens |
|
|
16
|
+
|-------|--------------|
|
|
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. |
|
|
19
|
+
|
|
20
|
+
## Install
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
dsh plugin --profile web add github:falling-ts/dsh-web-ding
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
Requires the web app to ship the client bundle (the `dsh.client` declaration in
|
|
27
|
+
`package.json` does that automatically).
|
|
28
|
+
|
|
29
|
+
## Configuration (`falling-ts-web-ding` namespace, $DSH_HOME/settings.yaml)
|
|
30
|
+
|
|
31
|
+
| Field | Type | Default | Meaning |
|
|
32
|
+
|-------|------|---------|---------|
|
|
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). |
|
|
37
|
+
|
|
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.
|
|
39
|
+
|
|
40
|
+
You can also adjust these from the **设置 → 回合结束提示音** panel, which
|
|
41
|
+
includes a "试听" (preview) button.
|
|
42
|
+
|
|
43
|
+
## Browser autoplay policy
|
|
44
|
+
|
|
45
|
+
Browsers block audio until a user gesture. The client warms the
|
|
46
|
+
`AudioContext` on the first pointer/key interaction (and on the preview
|
|
47
|
+
button), so: interact with the page once (or press 试听) and you will hear the
|
|
48
|
+
ding at every agent turn end. An audio context in a background tab may be
|
|
49
|
+
suspended by the browser itself — keep the tab visible to hear the tone.
|
|
50
|
+
|
|
51
|
+
## Development
|
|
52
|
+
|
|
53
|
+
```bash
|
|
54
|
+
# mount as a dev overlay (plain JS, no build step)
|
|
55
|
+
dsh web --patch $(pwd)/cordis.patch.yml # if supported by your CLI
|
|
56
|
+
# or install from a local path
|
|
57
|
+
dsh plugin --profile web add /path/to/dsh-web-ding
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
See `AGENTS.md` for the plugin's own rules (pure Host listener, no backend
|
|
61
|
+
audio, no OS notification, only sanctioned settings-mirror channel to the
|
|
62
|
+
browser).
|
|
63
|
+
|
|
64
|
+
## License
|
|
65
|
+
|
|
66
|
+
MIT
|
package/cordis.patch.yml
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# Opt-in layer: mount the dsh-web-ding function plugin over the
|
|
2
|
+
# current composition without changing shipped defaults.
|
|
3
|
+
#
|
|
4
|
+
# dsh plugin --profile web add github:falling-ts/dsh-web-ding # install
|
|
5
|
+
#
|
|
6
|
+
# The Host half is a pure listener on agent/status; it pushes a "agent
|
|
7
|
+
# finished" signal into the falling-ts-web-ding settings namespace, which the
|
|
8
|
+
# browser client half mirrors and answers with a Web-Audio "ding".
|
|
9
|
+
|
|
10
|
+
- insert:
|
|
11
|
+
- id: web-ding
|
|
12
|
+
name: '@falling-ts/dsh-web-ding'
|
package/index.js
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-web-ding — a DSH Cordis function plugin.
|
|
3
|
+
*
|
|
4
|
+
* Announces the moment an agent finishes: the Host half listens for the
|
|
5
|
+
* agent/status 'idle' TRANSITION (all turns done, including sub-agents, before
|
|
6
|
+
* the next human turn) and publishes a tiny 'done' signal into the
|
|
7
|
+
* `falling-ts-web-ding` settings namespace. The browser client half
|
|
8
|
+
* (web/client.js) mirrors that namespace live and answers the signal by
|
|
9
|
+
* synthesizing a short "ding" with the Web Audio API — ENTIRELY front-end JS.
|
|
10
|
+
*
|
|
11
|
+
* The Node/Host side deliberately never plays audio and never raises a
|
|
12
|
+
* Windows/system notification: the sound lives in the browser tab.
|
|
13
|
+
*
|
|
14
|
+
* Layout:
|
|
15
|
+
* - index.js — this file; the Cordis plugin entry (listener registrations).
|
|
16
|
+
* - core/settings.js — the `falling-ts-web-ding` settings namespace (parameters + schema).
|
|
17
|
+
* - core/signal.js — the host→browser signal publisher (settings field write).
|
|
18
|
+
* - hooks/idle.js — the `agent/status` idle-transition observer.
|
|
19
|
+
* - web/client.js — the browser half: mirrors the namespace, plays the ding, registers a settings.section.
|
|
20
|
+
*
|
|
21
|
+
* @module @falling-ts/dsh-web-ding
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import { registerNamespace } from './src/core/settings.js'
|
|
25
|
+
import { handleAgentStatus } from './src/hooks/idle.js'
|
|
26
|
+
|
|
27
|
+
/** @type {string} the function plugin's display name. */
|
|
28
|
+
export const name = 'web-ding'
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Register the `agent/status` listener and the `falling-ts-web-ding` settings
|
|
32
|
+
* namespace (the "回合结束提示音" surface).
|
|
33
|
+
*
|
|
34
|
+
* No `inject` is declared: the `settings` service arrives with the preset
|
|
35
|
+
* plane AFTER this plugin's boot-time effect runs, and a boot-time `inject`
|
|
36
|
+
* would fail the boot assertion (the same late-mount ordering documented by
|
|
37
|
+
* dsh-force-compact). The namespace registration is therefore lazy +
|
|
38
|
+
* idempotent: attempted at boot and again atop every `agent/status` emission,
|
|
39
|
+
* with a bounded self-cancelling retry while the service is still absent. The
|
|
40
|
+
* retry is installation bookkeeping (it settles and cancels itself on
|
|
41
|
+
* success) — not a persistent timer or long-lived state.
|
|
42
|
+
*
|
|
43
|
+
* @param {import('@deepseek-ai/cordis').Context} ctx
|
|
44
|
+
*/
|
|
45
|
+
const __applyInner = (ctx) => {
|
|
46
|
+
ctx.logger.info('[web-ding] apply START; settings=' + (ctx.get('settings') !== undefined ? 'present' : 'ABSENT'))
|
|
47
|
+
|
|
48
|
+
// ── Lazy namespace install (settings service may arrive after boot) ──────
|
|
49
|
+
const settingsState = { settled: false, installed: false }
|
|
50
|
+
const RETRY_DELAY_MS = 1000
|
|
51
|
+
const RETRY_MAX_ATTEMPTS = 30
|
|
52
|
+
const retryTimer = { value: undefined }
|
|
53
|
+
const tryRegisterOnce = async () => {
|
|
54
|
+
if (settingsState.settled) return
|
|
55
|
+
const settings = ctx.get('settings')
|
|
56
|
+
if (settings === undefined || typeof settings.register !== 'function') return // keep retrying
|
|
57
|
+
try {
|
|
58
|
+
const ok = await registerNamespace(ctx)
|
|
59
|
+
settingsState.settled = true
|
|
60
|
+
if (ok) {
|
|
61
|
+
settingsState.installed = true
|
|
62
|
+
ctx.logger.info('[web-ding] registered settings namespace "falling-ts-web-ding"')
|
|
63
|
+
} else {
|
|
64
|
+
ctx.logger.warn('[web-ding] settings present but schema build failed — namespace NOT registered')
|
|
65
|
+
}
|
|
66
|
+
} catch (error) {
|
|
67
|
+
const message = error instanceof Error ? (error.stack || error.message) : String(error)
|
|
68
|
+
ctx.logger.warn(`[web-ding] settings namespace registration threw — ${message}`)
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
const maybeRetryRegister = () => {
|
|
72
|
+
if (settingsState.settled || retryTimer.value !== undefined) return
|
|
73
|
+
let attempts = 0
|
|
74
|
+
const attempt = () => {
|
|
75
|
+
retryTimer.value = undefined
|
|
76
|
+
if (settingsState.settled) return
|
|
77
|
+
attempts += 1
|
|
78
|
+
void (async () => {
|
|
79
|
+
await tryRegisterOnce()
|
|
80
|
+
if (settingsState.settled) {
|
|
81
|
+
if (retryTimer.value !== undefined) clearTimeout(retryTimer.value)
|
|
82
|
+
retryTimer.value = undefined
|
|
83
|
+
return
|
|
84
|
+
}
|
|
85
|
+
if (attempts >= RETRY_MAX_ATTEMPTS) {
|
|
86
|
+
settingsState.settled = true // give up; agent/status listeners stay as safety net
|
|
87
|
+
return
|
|
88
|
+
}
|
|
89
|
+
retryTimer.value = setTimeout(attempt, RETRY_DELAY_MS)
|
|
90
|
+
})().catch(() => {})
|
|
91
|
+
}
|
|
92
|
+
attempt()
|
|
93
|
+
}
|
|
94
|
+
const maybeRegisterSettingsNamespace = () => {
|
|
95
|
+
if (settingsState.settled) return
|
|
96
|
+
void (async () => {
|
|
97
|
+
await tryRegisterOnce()
|
|
98
|
+
if (!settingsState.settled) maybeRetryRegister()
|
|
99
|
+
})().catch(() => {})
|
|
100
|
+
}
|
|
101
|
+
// Cancel any pending retry on teardown.
|
|
102
|
+
ctx.effect(() => () => {
|
|
103
|
+
if (retryTimer.value !== undefined) clearTimeout(retryTimer.value)
|
|
104
|
+
}, 'web-ding: settings install retry cleanup')
|
|
105
|
+
|
|
106
|
+
// Fire the eager attempt once NOW so a cold start with no agent traffic
|
|
107
|
+
// still lands the namespace (the client panel depends on it).
|
|
108
|
+
maybeRegisterSettingsNamespace()
|
|
109
|
+
|
|
110
|
+
// ── Turn-end ding: agent/status idle transition → publish 'done' signal ──
|
|
111
|
+
// agent/status is a SYNC event; the heavy work (settings write) is handed off
|
|
112
|
+
// to an async IIFE with its own catch so nothing escapes the dispatch.
|
|
113
|
+
ctx.on('agent/status', (payload) => {
|
|
114
|
+
maybeRegisterSettingsNamespace() // re-armed; cheap no-op once settled
|
|
115
|
+
void (async () => {
|
|
116
|
+
await handleAgentStatus(ctx, payload)
|
|
117
|
+
})().catch((error) => {
|
|
118
|
+
const message = error instanceof Error ? error.message : String(error)
|
|
119
|
+
ctx.logger.warn(`[web-ding] agent/status handler degraded (swallowed) — ${message}`)
|
|
120
|
+
})
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
ctx.logger.info('[web-ding] apply END (listeners + namespace attempts done)')
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Plugin entry.
|
|
128
|
+
* @param {import('@deepseek-ai/cordis').Context} ctx
|
|
129
|
+
*/
|
|
130
|
+
export const apply = (ctx) => {
|
|
131
|
+
try {
|
|
132
|
+
return __applyInner(ctx)
|
|
133
|
+
} catch (error) {
|
|
134
|
+
const message = error instanceof Error ? (error.stack || error.message) : String(error)
|
|
135
|
+
try {
|
|
136
|
+
ctx.logger.error(`[web-ding] apply FAILED — ${message}`)
|
|
137
|
+
} catch { /* never */ }
|
|
138
|
+
throw error
|
|
139
|
+
}
|
|
140
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@falling-ts/dsh-web-ding",
|
|
3
|
+
"version": "0.1.0",
|
|
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
|
+
"type": "module",
|
|
6
|
+
"main": "./index.js",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": "./index.js",
|
|
9
|
+
"./client": "./web/client.js",
|
|
10
|
+
"./package.json": "./package.json"
|
|
11
|
+
},
|
|
12
|
+
"files": [
|
|
13
|
+
"index.js",
|
|
14
|
+
"src/",
|
|
15
|
+
"web/",
|
|
16
|
+
"cordis.patch.yml"
|
|
17
|
+
],
|
|
18
|
+
"license": "MIT",
|
|
19
|
+
"publishConfig": {
|
|
20
|
+
"access": "public"
|
|
21
|
+
},
|
|
22
|
+
"dsh": {
|
|
23
|
+
"bundle": {
|
|
24
|
+
"patch": "./cordis.patch.yml"
|
|
25
|
+
},
|
|
26
|
+
"client": {
|
|
27
|
+
"platform": "web",
|
|
28
|
+
"inject": [
|
|
29
|
+
"@deepseek-ai/dsh-client-ui-settings"
|
|
30
|
+
]
|
|
31
|
+
}
|
|
32
|
+
},
|
|
33
|
+
"repository": {
|
|
34
|
+
"type": "git",
|
|
35
|
+
"url": "git+https://github.com/falling-ts/dsh-web-ding.git"
|
|
36
|
+
},
|
|
37
|
+
"keywords": [
|
|
38
|
+
"dsh",
|
|
39
|
+
"cordis",
|
|
40
|
+
"agent",
|
|
41
|
+
"turn-end",
|
|
42
|
+
"idle",
|
|
43
|
+
"notification",
|
|
44
|
+
"ding",
|
|
45
|
+
"web-audio",
|
|
46
|
+
"sound",
|
|
47
|
+
"client"
|
|
48
|
+
]
|
|
49
|
+
}
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-web-ding settings — the "回合结束提示音" (turn-end ding) surface.
|
|
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):
|
|
7
|
+
*
|
|
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.
|
|
18
|
+
*
|
|
19
|
+
* The `signal` field is the PLUGIN-PRIVATE host→browser messenger (the same
|
|
20
|
+
* pattern dsh-force-compact uses for its `liveUi` field): the Host half is
|
|
21
|
+
* its only writer, the browser client never writes it, and it deliberately
|
|
22
|
+
* persists to settings.yaml like every other field (harmless cosmetic residue
|
|
23
|
+
* — the client's own last-at latch ignores anything it saw before load).
|
|
24
|
+
*
|
|
25
|
+
* @module @falling-ts/dsh-web-ding/settings
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
/** The settings namespace key (settings.get / settings.register address). */
|
|
29
|
+
export const NS = 'falling-ts-web-ding'
|
|
30
|
+
|
|
31
|
+
/** The settings field carrying the host→browser turn-end signal. */
|
|
32
|
+
export const SIGNAL_FIELD = 'signal'
|
|
33
|
+
|
|
34
|
+
/** Defaults — also the base passed to settings.register. */
|
|
35
|
+
export const DEFAULTS = Object.freeze({
|
|
36
|
+
enabled: true,
|
|
37
|
+
volume: 0.7,
|
|
38
|
+
freq: 880,
|
|
39
|
+
decayMs: 900,
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Read ONE raw field of the namespace without a full parse. Never throws.
|
|
44
|
+
* @param {import('@deepseek-ai/cordis').Context} ctx
|
|
45
|
+
* @param {string} field
|
|
46
|
+
* @returns {Promise<unknown>} the raw stored value, or `undefined` when the
|
|
47
|
+
* settings service is not mounted or the field is unset.
|
|
48
|
+
*/
|
|
49
|
+
export async function readRawSetting(ctx, field) {
|
|
50
|
+
try {
|
|
51
|
+
const settings = ctx.get('settings')
|
|
52
|
+
if (settings === undefined || typeof settings.get !== 'function') return undefined
|
|
53
|
+
const value = settings.get(NS)
|
|
54
|
+
if (value === undefined || value === null) return undefined
|
|
55
|
+
if (typeof value !== 'object') return undefined
|
|
56
|
+
return value[field]
|
|
57
|
+
} catch {
|
|
58
|
+
return undefined
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Resolve the schemastery `z` constructor, tolerating BOTH layouts:
|
|
64
|
+
* - a monorepo/dev layout where `@deepseek-ai/schemastery` resolves as a
|
|
65
|
+
* bare specifier;
|
|
66
|
+
* - this plugin as a STANDALONE repo whose node_modules lacks schemastery
|
|
67
|
+
* (it lives in the sibling `deepseek-harness/vendor/` copy). Then walk up
|
|
68
|
+
* from this file looking for the vendored build and import it via a
|
|
69
|
+
* file:// URL (required on Windows).
|
|
70
|
+
* @returns {Promise<object|undefined>} resolved `z`, or undefined.
|
|
71
|
+
*/
|
|
72
|
+
async function resolveZ() {
|
|
73
|
+
try {
|
|
74
|
+
const mod = await import('@deepseek-ai/schemastery')
|
|
75
|
+
const z = mod.default ?? mod
|
|
76
|
+
if (typeof z.object === 'function') return z
|
|
77
|
+
} catch { /* fall through to candidate 2 */ }
|
|
78
|
+
try {
|
|
79
|
+
const { fileURLToPath, pathToFileURL } = await import('node:url')
|
|
80
|
+
const { dirname, join } = await import('node:path')
|
|
81
|
+
const { existsSync } = await import('node:fs')
|
|
82
|
+
let dir = dirname(fileURLToPath(import.meta.url))
|
|
83
|
+
for (let hop = 0; hop < 8; hop += 1) {
|
|
84
|
+
const cand = join(dir, 'deepseek-harness/vendor/schemastery/lib/index.mjs')
|
|
85
|
+
if (existsSync(cand)) {
|
|
86
|
+
const mod = await import(pathToFileURL(cand).href)
|
|
87
|
+
const z = mod.default ?? mod
|
|
88
|
+
if (typeof z.object === 'function') return z
|
|
89
|
+
}
|
|
90
|
+
const parent = dirname(dir)
|
|
91
|
+
if (parent === dir) break
|
|
92
|
+
dir = parent
|
|
93
|
+
}
|
|
94
|
+
} catch { /* candidate 2 unavailable */ }
|
|
95
|
+
return undefined
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Build the `falling-ts-web-ding` schema through @deepseek-ai/schemastery.
|
|
100
|
+
* @returns {Promise<((section: unknown) => unknown) & { toJSON: () => unknown } | null>}
|
|
101
|
+
*/
|
|
102
|
+
export async function buildSchema() {
|
|
103
|
+
try {
|
|
104
|
+
const z = await resolveZ()
|
|
105
|
+
if (z === undefined) return null
|
|
106
|
+
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),
|
|
111
|
+
// TRANSIENT host→browser messenger (src/core/signal.js): host-written
|
|
112
|
+
// { phase:'done', at, sessionId }. z.any() because the vendored
|
|
113
|
+
// schemastery exposes only object/any/string/number/boolean/array.
|
|
114
|
+
signal: z.any(),
|
|
115
|
+
})
|
|
116
|
+
} catch {
|
|
117
|
+
return null
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Register the namespace when a `settings` service is mounted. Idempotent.
|
|
123
|
+
* Falls back to a callable placeholder schema so the panel still loads when
|
|
124
|
+
* schemastery is unresolvable.
|
|
125
|
+
* @param {import('@deepseek-ai/cordis').Context} ctx
|
|
126
|
+
* @returns {Promise<boolean>}
|
|
127
|
+
*/
|
|
128
|
+
export async function registerNamespace(ctx) {
|
|
129
|
+
const settings = ctx.get('settings')
|
|
130
|
+
if (settings === undefined || typeof settings.register !== 'function') return false
|
|
131
|
+
const schema = await buildSchema()
|
|
132
|
+
const thirdArg = { base: { ...DEFAULTS } }
|
|
133
|
+
const placeholderSchema = (section) => section
|
|
134
|
+
placeholderSchema.toJSON = () => ({})
|
|
135
|
+
try {
|
|
136
|
+
settings.register(NS, schema !== null ? schema : placeholderSchema, thirdArg)
|
|
137
|
+
return true
|
|
138
|
+
} catch {
|
|
139
|
+
return false
|
|
140
|
+
}
|
|
141
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Turn-end signal messenger — the plugin-private bridge between the HOST half
|
|
3
|
+
* ("this agent just finished") and the CLIENT half ("play the ding").
|
|
4
|
+
*
|
|
5
|
+
* Why settings at all
|
|
6
|
+
* ------------------
|
|
7
|
+
* The browser client half mirrors the `falling-ts-web-ding` namespace through
|
|
8
|
+
* `settingsScope.bind` → `createSnapshotStore`, so ANY field the Host writes
|
|
9
|
+
* here is reflected in the browser live (the SettingsScope revision-fencing
|
|
10
|
+
* contract, the same `settings/document-updated` broadcast dsh-force-compact
|
|
11
|
+
* rides for its liveUi badge). That is the ONLY sanctioned host→browser
|
|
12
|
+
* live-data channel an independent plugin bundle can use.
|
|
13
|
+
*
|
|
14
|
+
* The `signal` payload is deliberately minimal:
|
|
15
|
+
* { phase: 'done', at: <monotonic epoch ms>, sessionId?: string }
|
|
16
|
+
* `at` doubles as the sequence number — the client ignores any signal older
|
|
17
|
+
* than the one it last played, so restarts and stale residue never re-ding.
|
|
18
|
+
*
|
|
19
|
+
* Guarantees:
|
|
20
|
+
* • NEVER throws — a settings-service absence or a rejected write is caught
|
|
21
|
+
* and logged at most once per lifetime (cosmetic only; the agent/status
|
|
22
|
+
* dispatch proceeds untouched).
|
|
23
|
+
* • Fire-and-forget from the caller's perspective.
|
|
24
|
+
*
|
|
25
|
+
* @module @falling-ts/dsh-web-ding/signal
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
import { NS, SIGNAL_FIELD } from './settings.js'
|
|
29
|
+
|
|
30
|
+
/** Process-local monotonic high-water mark so two same-millisecond idles can never collide. */
|
|
31
|
+
let lastAt = 0
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Publish one "agent finished" signal onto the `signal` field of the
|
|
35
|
+
* `falling-ts-web-ding` namespace. THE host→browser delivery point.
|
|
36
|
+
* @param {import('@deepseek-ai/cordis').Context} ctx
|
|
37
|
+
* @param {string|undefined} sessionId the agent session id that just went idle
|
|
38
|
+
* @returns {Promise<void>}
|
|
39
|
+
*/
|
|
40
|
+
let warnedOnce = false
|
|
41
|
+
export async function publishDingSignal(ctx, sessionId) {
|
|
42
|
+
try {
|
|
43
|
+
const settings = ctx.get('settings')
|
|
44
|
+
if (settings === undefined || typeof settings.update !== 'function') return
|
|
45
|
+
let at = Date.now()
|
|
46
|
+
if (at <= lastAt) at = lastAt + 1
|
|
47
|
+
lastAt = at
|
|
48
|
+
const signal = {
|
|
49
|
+
phase: 'done',
|
|
50
|
+
at,
|
|
51
|
+
...(typeof sessionId === 'string' && sessionId !== '' ? { sessionId } : {}),
|
|
52
|
+
}
|
|
53
|
+
await settings.update(NS, { [SIGNAL_FIELD]: signal })
|
|
54
|
+
if (!warnedOnce) {
|
|
55
|
+
warnedOnce = true
|
|
56
|
+
try {
|
|
57
|
+
ctx.logger.debug(`[web-ding] signal published via ${NS}.${SIGNAL_FIELD} (at=${at})`)
|
|
58
|
+
} catch { /* logging must never propagate */ }
|
|
59
|
+
}
|
|
60
|
+
} catch (error) {
|
|
61
|
+
const message = error instanceof Error ? error.message : String(error)
|
|
62
|
+
try {
|
|
63
|
+
ctx.logger.warn(`[web-ding] signal publish failed (ignored, cosmetic only) — ${message}`)
|
|
64
|
+
} catch { /* never */ }
|
|
65
|
+
}
|
|
66
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The turn-end ding trigger — observes `agent/status` and publishes the
|
|
3
|
+
* "agent finished" signal exactly once per idle TRANSITION.
|
|
4
|
+
*
|
|
5
|
+
* Why the transition bookkeeping:
|
|
6
|
+
* - The agent starts idle for a fresh session (before any turn) — nobody
|
|
7
|
+
* asked for a ding then, and a "new session created" ding would be noise.
|
|
8
|
+
* - The agent/status idle tick can repeat while a session stays idle (the
|
|
9
|
+
* same recurrence dsh-force-compact documents for its idle compaction), so
|
|
10
|
+
* the raw `status === 'idle'` guard alone would re-ding every tick.
|
|
11
|
+
*
|
|
12
|
+
* Two process-local latches (pure listener state, no timer, no persistence):
|
|
13
|
+
* prevStatus: sessionId → last observed status (Map)
|
|
14
|
+
* everBusy: sessionIds that were observed in a non-idle status (Set)
|
|
15
|
+
* A signal is published only when BOTH hold: the previous status was NOT
|
|
16
|
+
* 'idle' (this is a genuine running→idle transition) AND the session was
|
|
17
|
+
* observed busy at least once (so the very first idle after session creation
|
|
18
|
+
* stays silent).
|
|
19
|
+
*
|
|
20
|
+
* @module @falling-ts/dsh-web-ding/turn-end
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { readRawSetting } from '../core/settings.js'
|
|
24
|
+
import { publishDingSignal } from '../core/signal.js'
|
|
25
|
+
|
|
26
|
+
/** @type {Map<string,string>} sessionId → last observed agent/status. */
|
|
27
|
+
const prevStatus = new Map()
|
|
28
|
+
/** @type {Set<string>} sessionIds that have been observed busy (non-idle). */
|
|
29
|
+
const everBusy = new Set()
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Handle one `agent/status` emission. Never throws out of the listener (any
|
|
33
|
+
* anomaly logs and settles).
|
|
34
|
+
* @param {import('@deepseek-ai/cordis').Context} ctx
|
|
35
|
+
* @param {{agent: import('@deepseek-ai/dsh-agent').Agent, status: string}} payload
|
|
36
|
+
* @returns {Promise<void>}
|
|
37
|
+
*/
|
|
38
|
+
export async function handleAgentStatus(ctx, payload) {
|
|
39
|
+
try {
|
|
40
|
+
if (payload === null || typeof payload !== 'object') return
|
|
41
|
+
const agent = payload.agent
|
|
42
|
+
const status = payload.status
|
|
43
|
+
if (typeof status !== 'string') return
|
|
44
|
+
const session = (agent && typeof agent === 'object') ? agent.session : undefined
|
|
45
|
+
const sid = (session && typeof session.id === 'string') ? session.id : '?'
|
|
46
|
+
|
|
47
|
+
if (status !== 'idle') {
|
|
48
|
+
if (status === 'running') everBusy.add(sid)
|
|
49
|
+
prevStatus.set(sid, status)
|
|
50
|
+
return
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const prev = prevStatus.get(sid)
|
|
54
|
+
prevStatus.set(sid, 'idle')
|
|
55
|
+
if (prev === 'idle') return // repeated idle tick — already handled
|
|
56
|
+
if (!everBusy.has(sid)) return // fresh session that never ran — no ding
|
|
57
|
+
|
|
58
|
+
const enabled = await readRawSetting(ctx, 'enabled')
|
|
59
|
+
if (enabled === false) {
|
|
60
|
+
ctx.logger.debug(`[web-ding] ${sid}: idle transition ignored — enabled=false`)
|
|
61
|
+
return
|
|
62
|
+
}
|
|
63
|
+
await publishDingSignal(ctx, sid)
|
|
64
|
+
} catch (error) {
|
|
65
|
+
const message = error instanceof Error ? (error.stack || error.message) : String(error)
|
|
66
|
+
try {
|
|
67
|
+
ctx.logger.warn(`[web-ding] handleAgentStatus degraded (swallowed) — ${message}`)
|
|
68
|
+
} catch { /* never */ }
|
|
69
|
+
}
|
|
70
|
+
}
|
package/web/client.js
ADDED
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-web-ding 浏览器半部:回合结束提示音(设置分区 + Web Audio 播放器)。
|
|
3
|
+
*
|
|
4
|
+
* 这是一个闭包工厂 artifact:window.__ModuleLoader__.load({ id, factory }),
|
|
5
|
+
* factory(require) 通过注入的 require 解析外部模块(react、client-runtime),
|
|
6
|
+
* 并返回插件面 { name, inject, apply }。宿主半部(根 index.js)与本文件是
|
|
7
|
+
* 同一 package 的两个面:宿主半部由 main 入口加载,本文件由 exports["./client"]
|
|
8
|
+
* 导出,经 dsh.client 声明被 client module 系统自动组成并服务。
|
|
9
|
+
*
|
|
10
|
+
* 职责:
|
|
11
|
+
* 1. settingsScope 镜像 falling-ts-web-ding 命名空间,订阅其快照翻转——这
|
|
12
|
+
* 就是宿主的"事件时钟":宿主在 agent/status idle 转变时写入 signal 字段,
|
|
13
|
+
* 经 settings/document-updated 广播到达这里。
|
|
14
|
+
* 2. 检测新的 'done' 信号(at 严格大于本页面最后播放的 at 才响应,首帧只做
|
|
15
|
+
* 基线不播放,重启残留/重复快照都不会重复响)后,用 Web Audio API 合成
|
|
16
|
+
* 一声"叮"。声音 100% 由浏览器 JS 生成——宿主 Node 端从不发声,也不发
|
|
17
|
+
* Windows/系统通知。
|
|
18
|
+
* 3. 注册 settings.section "回合结束提示音" 分区:开关、音量、音色频率、
|
|
19
|
+
* 时长与"试听"按钮(点击试听同时完成音频解锁)。
|
|
20
|
+
*
|
|
21
|
+
* 浏览器自动播放策略:AudioContext 需要一次用户手势才能出声。首次
|
|
22
|
+
* pointerdown/keydown 做一次性预热(创建并 resume),"试听"按钮点击本身
|
|
23
|
+
* 也是一次手势,所以点过试听或与本页面交互过后即可正常听到回合结束的叮。
|
|
24
|
+
*
|
|
25
|
+
* @module @falling-ts/dsh-web-ding/client
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
window.__ModuleLoader__.load({
|
|
29
|
+
id: "@falling-ts/dsh-web-ding",
|
|
30
|
+
factory: (require) => {
|
|
31
|
+
var module = { exports: {} };
|
|
32
|
+
var exports = module.exports;
|
|
33
|
+
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
|
34
|
+
const React = require("react");
|
|
35
|
+
const h = React.createElement;
|
|
36
|
+
// 基线外部(web 平台预载):把 settingsScope 镜像成 uSES 安全的 SnapshotStore。
|
|
37
|
+
const { createSnapshotStore } = require("@deepseek-ai/dsh-client-runtime/client");
|
|
38
|
+
|
|
39
|
+
/** 宿主侧设置命名空间(settings.get 读取的键)。 */
|
|
40
|
+
const NS_SETTINGS = "falling-ts-web-ding";
|
|
41
|
+
|
|
42
|
+
/** 必需服务(slots 提供分区注册;settingsScope 由 ui-settings 提供)。 */
|
|
43
|
+
const inject = ["slots", "settingsScope"];
|
|
44
|
+
|
|
45
|
+
// ── Web Audio "叮" 播放器 --------------------------------------------------
|
|
46
|
+
// 完全前端合成:无音频资产、无系统通知。三个正弦振荡器叠加:
|
|
47
|
+
// 基频 + 高八度泛音(轻)+ 2.5 倍铃感泛音(更轻),各自带指数衰减包络。
|
|
48
|
+
let audio = null;
|
|
49
|
+
function ensureAudio() {
|
|
50
|
+
if (audio) {
|
|
51
|
+
if (audio.ctx.state === "closed") audio = null;
|
|
52
|
+
else return audio;
|
|
53
|
+
}
|
|
54
|
+
const Ctx = window.AudioContext || window.webkitAudioContext;
|
|
55
|
+
if (!Ctx) return null;
|
|
56
|
+
const ctx = new Ctx();
|
|
57
|
+
audio = { ctx };
|
|
58
|
+
return audio;
|
|
59
|
+
}
|
|
60
|
+
// 用户手势一次性预热:创建 AudioContext 并 resume,解除自动播放静音。
|
|
61
|
+
function warmup() {
|
|
62
|
+
const a = ensureAudio();
|
|
63
|
+
if (a && a.ctx.state === "suspended") {
|
|
64
|
+
void a.ctx.resume().catch(() => {});
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
if (typeof window !== "undefined") {
|
|
68
|
+
window.addEventListener("pointerdown", warmup, { once: true });
|
|
69
|
+
window.addEventListener("keydown", warmup, { once: true });
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* 播放一声"叮"。
|
|
73
|
+
* @param {{volume?: number, freq?: number, decayMs?: number}} opts
|
|
74
|
+
* @returns {boolean} 是否成功调度(不支持 Web Audio 时返回 false)
|
|
75
|
+
*/
|
|
76
|
+
function playDing(opts) {
|
|
77
|
+
const o = opts || {};
|
|
78
|
+
const a = ensureAudio();
|
|
79
|
+
if (!a) return false;
|
|
80
|
+
const ctx = a.ctx;
|
|
81
|
+
if (ctx.state === "suspended") void ctx.resume().catch(() => {});
|
|
82
|
+
const volume = Math.min(1, Math.max(0, Number(o.volume) || 0.7));
|
|
83
|
+
const freq = Math.min(4000, Math.max(80, Number(o.freq) || 880));
|
|
84
|
+
const decay = Math.min(4000, Math.max(100, Number(o.decayMs) || 900)) / 1000;
|
|
85
|
+
const t0 = ctx.currentTime + 0.02;
|
|
86
|
+
const schedule = (f, peak, start, dur) => {
|
|
87
|
+
const osc = ctx.createOscillator();
|
|
88
|
+
const g = ctx.createGain();
|
|
89
|
+
osc.type = "sine";
|
|
90
|
+
osc.frequency.setValueAtTime(f, start);
|
|
91
|
+
g.gain.setValueAtTime(0.0001, start);
|
|
92
|
+
g.gain.exponentialRampToValueAtTime(Math.max(0.0001, peak), start + 0.012);
|
|
93
|
+
g.gain.exponentialRampToValueAtTime(0.0001, start + dur);
|
|
94
|
+
osc.connect(g);
|
|
95
|
+
g.connect(ctx.destination);
|
|
96
|
+
osc.start(start);
|
|
97
|
+
osc.stop(start + dur + 0.05);
|
|
98
|
+
};
|
|
99
|
+
schedule(freq, volume * 0.55, t0, decay); // 基频主体
|
|
100
|
+
schedule(freq * 2.0, volume * 0.2, t0, decay * 0.75); // 高八度泛音
|
|
101
|
+
schedule(freq * 2.5, volume * 0.07, t0 + 0.004, decay * 0.6); // 铃感泛音
|
|
102
|
+
return true;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// ── 命名空间快照 → 信号检测 → 播放 ------------------------------------------
|
|
106
|
+
// lastAt:本页面最后响应过的 signal.at。首帧(页面加载时已存在的残留)只做
|
|
107
|
+
// 基线、不播放;之后 at 严格增长的新 'done' 信号才叮一声。
|
|
108
|
+
let lastAt = null;
|
|
109
|
+
function maybePlayFromValue(value) {
|
|
110
|
+
if (!value || typeof value !== "object") return;
|
|
111
|
+
const sig = value.signal;
|
|
112
|
+
if (!sig || typeof sig !== "object") return;
|
|
113
|
+
if (sig.phase !== "done") return;
|
|
114
|
+
const at = typeof sig.at === "number" ? sig.at : 0;
|
|
115
|
+
if (lastAt === null) {
|
|
116
|
+
lastAt = at; // 首帧基线:页面打开前已发生的信号不播
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
if (!(at > lastAt)) return;
|
|
120
|
+
lastAt = at;
|
|
121
|
+
if (value.enabled !== false) {
|
|
122
|
+
playDing({ volume: value.volume, freq: value.freq, decayMs: value.decayMs });
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// ── 设置分区 UI --------------------------------------------------------------
|
|
127
|
+
const divider = "rgba(0,0,0,0.08)";
|
|
128
|
+
const hintColor = "rgba(0,0,0,0.45)";
|
|
129
|
+
const gridCols = "200px minmax(0,1fr)";
|
|
130
|
+
const wrapStyle = { padding: "4px 0" };
|
|
131
|
+
const titleStyle = { margin: "2px 0 2px", fontSize: 15, lineHeight: 1.4 };
|
|
132
|
+
const introStyle = { margin: "0 0 6px", color: hintColor, lineHeight: 1.65, fontSize: 13, maxWidth: 680 };
|
|
133
|
+
const rowStyle = { display: "grid", gridTemplateColumns: gridCols, columnGap: 16, rowGap: 5, padding: "13px 0", borderBottom: "1px solid " + divider, alignItems: "center" };
|
|
134
|
+
const lastRowStyle = { ...rowStyle, borderBottom: "none" };
|
|
135
|
+
const labelStyle = { fontSize: 13.5, fontWeight: 500, lineHeight: 1.35 };
|
|
136
|
+
const controlStyle = { display: "flex", alignItems: "center", gap: 10, flexWrap: "wrap" };
|
|
137
|
+
const hintStyle = { gridColumn: "1 / 3", color: hintColor, fontSize: 12, lineHeight: 1.55 };
|
|
138
|
+
const valueStyle = { fontVariantNumeric: "tabular-nums", fontSize: 13, color: hintColor, minWidth: 52, textAlign: "right" };
|
|
139
|
+
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
|
+
const inputRangeStyle = { flex: 1, minWidth: 140 };
|
|
141
|
+
|
|
142
|
+
function DingSection(props) {
|
|
143
|
+
// The renderer binds the injected hooks compartment ('ding' key) into a
|
|
144
|
+
// use<Name> hook; the store is the bare observable, so a selector reads
|
|
145
|
+
// its latest snapshot (the sanctioned client pattern, see force-compact).
|
|
146
|
+
const { update, play } = props;
|
|
147
|
+
const snap = props.useDing((s) => s);
|
|
148
|
+
const value = snap.value;
|
|
149
|
+
const ph = hintStyle;
|
|
150
|
+
if (snap.status === "unavailable") {
|
|
151
|
+
return h("div", { style: wrapStyle },
|
|
152
|
+
h("h2", { style: titleStyle }, "回合结束提示音"),
|
|
153
|
+
h("p", { style: ph }, "设置不可用(宿主端未注册 falling-ts-web-ding 命名空间)。"));
|
|
154
|
+
}
|
|
155
|
+
if (snap.status === "loading" || value === undefined) {
|
|
156
|
+
return h("div", { style: wrapStyle },
|
|
157
|
+
h("h2", { style: titleStyle }, "回合结束提示音"),
|
|
158
|
+
h("p", { style: ph }, "加载中…"));
|
|
159
|
+
}
|
|
160
|
+
const disabled = !snap.writable;
|
|
161
|
+
const v = (value && typeof value === "object") ? value : {};
|
|
162
|
+
const pct = (n) => Math.round((Number(n) || 0) * 100) + "%";
|
|
163
|
+
const sliderRow = (labelText, props2, display) => h("div", { key: labelText, style: rowStyle },
|
|
164
|
+
h("span", { style: labelStyle }, labelText),
|
|
165
|
+
h("span", { style: controlStyle },
|
|
166
|
+
h("input", { type: "range", disabled: disabled, style: inputRangeStyle, ...props2 }),
|
|
167
|
+
h("span", { style: valueStyle }, display)),
|
|
168
|
+
);
|
|
169
|
+
return h("div", { style: wrapStyle },
|
|
170
|
+
h("h2", { style: titleStyle }, "回合结束提示音"),
|
|
171
|
+
h("p", { style: introStyle }, "agent 回合结束时(转入空闲)由浏览器 JS 播放一声合成“叮”——纯前端 Web Audio,宿主不发声、不弹 Windows/系统通知。"),
|
|
172
|
+
h("div", { key: "enabled", style: rowStyle },
|
|
173
|
+
h("span", { style: labelStyle }, "启用"),
|
|
174
|
+
h("span", { style: controlStyle },
|
|
175
|
+
h("input", {
|
|
176
|
+
type: "checkbox",
|
|
177
|
+
checked: v.enabled !== false,
|
|
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
|
+
),
|
|
200
|
+
h("p", { style: { gridColumn: "1 / 3", color: hintColor, fontSize: 12, lineHeight: 1.55 } },
|
|
201
|
+
"浏览器自动播放策略:首次与页面交互(点击/按键)或点击试听后,回合结束提示音才会出声。设置写入 $DSH_HOME/settings.yaml 的 falling-ts-web-ding 段。"));
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* 注册分区、绑定命名空间、把信号接进播放器。
|
|
206
|
+
* @param {import('@deepseek-ai/cordis').Context} ctx - client 根上下文。
|
|
207
|
+
*/
|
|
208
|
+
function apply(ctx) {
|
|
209
|
+
const scope = ctx.settingsScope.bind({ namespace: NS_SETTINGS });
|
|
210
|
+
const store = createSnapshotStore({ status: "loading", value: undefined, writable: false });
|
|
211
|
+
const derive = () => {
|
|
212
|
+
try {
|
|
213
|
+
const s = scope.getSnapshot();
|
|
214
|
+
if (s === undefined || s === null || typeof s !== "object") return;
|
|
215
|
+
store.update((d) => {
|
|
216
|
+
d.status = s.status;
|
|
217
|
+
d.value = s.value;
|
|
218
|
+
d.writable = s.writable;
|
|
219
|
+
});
|
|
220
|
+
if (s.status === "ready") maybePlayFromValue(s.value);
|
|
221
|
+
} catch { /* never let a cosmetic derive take down the panel */ }
|
|
222
|
+
};
|
|
223
|
+
const unsub = scope.subscribe(derive);
|
|
224
|
+
derive();
|
|
225
|
+
ctx.effect(() => unsub, "web-ding: scope subscription");
|
|
226
|
+
const injected = () => ({
|
|
227
|
+
hooks: { ding: store },
|
|
228
|
+
update: (field, value) => scope.set(field, value),
|
|
229
|
+
play: (opts) => {
|
|
230
|
+
warmup();
|
|
231
|
+
return playDing(opts || {});
|
|
232
|
+
},
|
|
233
|
+
});
|
|
234
|
+
ctx.slots.inject("settings.section", () => ctx.slots.register({
|
|
235
|
+
name: "settings.section",
|
|
236
|
+
id: "web-ding",
|
|
237
|
+
order: 80,
|
|
238
|
+
label: () => "回合结束提示音",
|
|
239
|
+
inject: injected,
|
|
240
|
+
}, DingSection));
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
exports.apply = apply;
|
|
244
|
+
exports.inject = inject;
|
|
245
|
+
return module.exports;
|
|
246
|
+
}
|
|
247
|
+
});
|