@goodandready/dsh-tts 0.4.29 → 0.4.31
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/CHANGELOG.md +37 -0
- package/README.zh.md +6 -5
- package/lib/cache.js +55 -7
- package/lib/client.js +110 -34
- package/lib/engines/manager.js +36 -5
- package/lib/index.js +1 -0
- package/lib/keys.js +1 -0
- package/lib/routes.js +24 -9
- package/lib/stream-hub.js +1 -1
- package/lib/updater.js +4 -5
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,42 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.4.31
|
|
4
|
+
|
|
5
|
+
### Fixed
|
|
6
|
+
- **Settings & Revision Safety** (#151):
|
|
7
|
+
- Stripped `revision` from config payload before calling `settingsApi.replace(parsed, revision)`, eliminating `Config field "revision" is not volatile` validation errors in core SettingsForms.
|
|
8
|
+
- Reordered save sequence so provider API keys are only persisted to credential store after settings replacement succeeds, preventing mutation on 409 revision conflict.
|
|
9
|
+
- **Messenger & Duplex Route Guards** (#165):
|
|
10
|
+
- Added `vadBargeIn` property to `/dsh-tts/status` and connected client player to check `vadBargeIn` during active speech.
|
|
11
|
+
- Enforced 403 rejection on external messenger `/dsh-tts/speak` requests when `messengerTtsEnabled` is disabled.
|
|
12
|
+
- **Model Integrity & Immutable Sources** (#179):
|
|
13
|
+
- Updated Kokoro-82M ONNX model weights and voices URLs to official release assets from `thewh1teagle/kokoro-onnx` v0.19.
|
|
14
|
+
- Pinned F5-TTS model weights URL to immutable upstream commit hash `84e5a410d9cead4de2f847e7c9369a6440bdfaca`.
|
|
15
|
+
- Upgraded `validateFileIntegrity` to reject HTML error pages, repetitive dummy byte sequences, and malformed JSON files.
|
|
16
|
+
- **SSE Hub Backpressure & Resource Protection** (#186):
|
|
17
|
+
- Implemented backpressure buffer checks in `safeWrite`, immediately dropping backpressured subscribers when buffer cap is exceeded while preserving healthy subscribers.
|
|
18
|
+
- **Client Timeout Stream Wrapping** (#198):
|
|
19
|
+
- Wrapped response body methods (`json`, `text`, `blob`, `arrayBuffer`) in `clientFetch` so `AbortController` timeout remains active until the body settles.
|
|
20
|
+
- **Synthesis Cache Race Elimination** (#202):
|
|
21
|
+
- Added generation tracking and in-flight write synchronization to `lib/cache.js`, preventing `ENOTEMPTY` errors during cache clearing.
|
|
22
|
+
- **Documentation & Packaging Hygiene** (#181, #183, #184):
|
|
23
|
+
- Aligned `README.zh.md` architecture and tables to correctly classify Edge TTS as cloud-free online and Piper/eSpeak as local/offline.
|
|
24
|
+
- Removed `AGENTS.md` and `index.md` from `.gitignore` while ensuring strict exclusion from published npm packages.
|
|
25
|
+
- Added comprehensive test suites verifying synthesis deduplication, player SSE contracts, and cache clear atomicity.
|
|
26
|
+
|
|
27
|
+
## 0.4.30
|
|
28
|
+
|
|
29
|
+
### Fixed
|
|
30
|
+
- **Supply Chain Security & Updater** (#196, #199):
|
|
31
|
+
- Removed `--config.minimumReleaseAge=0` bypass in `lib/updater.js:installExact`, respecting pnpm supply-chain quarantine during updates.
|
|
32
|
+
- Consolidated updater endpoint to canonical `/api/dsh-tts/update`, removing legacy `/dsh-tts/updater` route alias.
|
|
33
|
+
- Implemented shared `updaterMutex` returning HTTP 409 `Install already in progress` across concurrent requests.
|
|
34
|
+
- **Theme Tokens & Design System** (#197):
|
|
35
|
+
- Replaced legacy CSS custom properties with canonical DSH core theme tokens (`--dsw-state-warn-primary`, `--dsw-brand-primary`, `--dsw-bg-layer-3`).
|
|
36
|
+
- **Client Networking & Timeout Safety** (#198):
|
|
37
|
+
- Wrapped all client `fetch` calls in `clientFetch(url, options)` utility with configurable 15-second `AbortController` timeout.
|
|
38
|
+
- Added error rejection handlers on fire-and-forget telemetry, settings, and player requests.
|
|
39
|
+
|
|
3
40
|
## 0.4.29
|
|
4
41
|
|
|
5
42
|
### Fixed
|
package/README.zh.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
<div align="center">
|
|
4
4
|
|
|
5
|
-
<h3>DeepSeek Harness
|
|
5
|
+
<h3>DeepSeek Harness 多引擎语音合成:云端与本地引擎、流式音频、IT 术语词典与即时通讯集成</h3>
|
|
6
6
|
|
|
7
7
|
<p align="center">
|
|
8
8
|
<a href="https://www.npmjs.com/package/@goodandready/dsh-tts"><img src="https://img.shields.io/npm/v/@goodandready/dsh-tts.svg?style=for-the-badge&color=6366f1&labelColor=1e1b4b" alt="npm version"></a>
|
|
@@ -39,7 +39,7 @@
|
|
|
39
39
|
|
|
40
40
|
**`dsh-tts`** 为 **DeepSeek Harness** Web 界面提供高保真智能体回复语音朗读服务。开启 **朗读智能体回复** 后,每条生成的助手回复或实时流式片段均由服务端合成并即时推流至浏览器播放。
|
|
41
41
|
|
|
42
|
-
API
|
|
42
|
+
API 密钥绝不暴露给前端:音频合成全程在服务端通过**多服务商独立备用链**执行,包括本地系统与在线引擎(Edge TTS 在线免 Key,Piper 与 eSpeak 本地离线)。Kokoro/F5 仅可下载权重,**推理未捆绑**。
|
|
43
43
|
|
|
44
44
|
```mermaid
|
|
45
45
|
graph LR
|
|
@@ -59,7 +59,7 @@ graph LR
|
|
|
59
59
|
|
|
60
60
|
subgraph Fallback [TTS 引擎备用链]
|
|
61
61
|
LRU -->|未命中| Chain{生效备用链}
|
|
62
|
-
Chain
|
|
62
|
+
Chain -->|在线 / 本地| P1[Edge TTS / Piper / eSpeak]
|
|
63
63
|
Chain -.->|云端| P2[OpenAI / ElevenLabs / Google / Azure / Groq]
|
|
64
64
|
Chain -.->|免费云端| P3[EdgeTTS / SiliconFlow]
|
|
65
65
|
Chain -.->|系统兜底| P4[本地 Piper / eSpeak NG]
|
|
@@ -85,8 +85,9 @@ graph LR
|
|
|
85
85
|
|
|
86
86
|
## 🚀 核心功能
|
|
87
87
|
|
|
88
|
-
### 1. 📴
|
|
89
|
-
* **
|
|
88
|
+
### 1. 📴 本地系统与在线引擎状态
|
|
89
|
+
* **Piper / eSpeak**:完全离线的本地系统合成,无需云 API 密钥。
|
|
90
|
+
* **Edge TTS**:免费的微软在线语音合成,无需 API 密钥(需要网络连接与 edge-tts 工具)。
|
|
90
91
|
* **Kokoro-82M / F5-TTS**:仅权重下载与状态展示。**推理未捆绑在本包中**,provider 会明确失败并继续 fallback。
|
|
91
92
|
* **ModelManager 管理界面**:在设置中手动安装模型,实时显示下载进度条、SHA-256 校验和一键删除。无任何静默或自动下载。
|
|
92
93
|
|
package/lib/cache.js
CHANGED
|
@@ -19,6 +19,15 @@ export function createSpeechCache({ root, maxBytes }) {
|
|
|
19
19
|
return ready
|
|
20
20
|
}
|
|
21
21
|
|
|
22
|
+
let generation = 0
|
|
23
|
+
const pendingWrites = new Set()
|
|
24
|
+
|
|
25
|
+
function trackWrite(p) {
|
|
26
|
+
pendingWrites.add(p)
|
|
27
|
+
p.finally(() => pendingWrites.delete(p)).catch(() => {})
|
|
28
|
+
return p
|
|
29
|
+
}
|
|
30
|
+
|
|
22
31
|
// L1 In-Memory RAM Cache (max 50 entries or 10MB total buffer size)
|
|
23
32
|
const l1Map = new Map()
|
|
24
33
|
const L1_MAX_ITEMS = 50
|
|
@@ -102,9 +111,21 @@ export function createSpeechCache({ root, maxBytes }) {
|
|
|
102
111
|
await ensureIndex()
|
|
103
112
|
const buf = Buffer.isBuffer(audio) ? audio : Buffer.from(audio)
|
|
104
113
|
putL1(key, mime, buf)
|
|
105
|
-
await fs.promises.writeFile(path.join(dir, key + AUDIO_EXT), buf)
|
|
106
114
|
const now = Date.now()
|
|
107
|
-
|
|
115
|
+
const currentGen = generation
|
|
116
|
+
const writePromise = (async () => {
|
|
117
|
+
if (currentGen !== generation) return
|
|
118
|
+
await fs.promises.writeFile(path.join(dir, key + AUDIO_EXT), buf)
|
|
119
|
+
await fs.promises.writeFile(path.join(dir, key + '.json'), JSON.stringify({ mime, at: now }))
|
|
120
|
+
if (currentGen !== generation) {
|
|
121
|
+
await fs.promises.rm(path.join(dir, key + AUDIO_EXT), { force: true }).catch(() => {})
|
|
122
|
+
await fs.promises.rm(path.join(dir, key + '.json'), { force: true }).catch(() => {})
|
|
123
|
+
}
|
|
124
|
+
})()
|
|
125
|
+
trackWrite(writePromise)
|
|
126
|
+
await writePromise
|
|
127
|
+
if (currentGen !== generation) return
|
|
128
|
+
|
|
108
129
|
const prev = diskEntries.get(key)
|
|
109
130
|
if (prev) diskTotalBytes -= prev.size
|
|
110
131
|
diskEntries.set(key, { size: buf.length, at: now })
|
|
@@ -124,7 +145,15 @@ export function createSpeechCache({ root, maxBytes }) {
|
|
|
124
145
|
l1Map.set(key, { ...hit, at: now })
|
|
125
146
|
const d = diskEntries.get(key)
|
|
126
147
|
if (d) d.at = now
|
|
127
|
-
|
|
148
|
+
const currentGen = generation
|
|
149
|
+
const writePromise = (async () => {
|
|
150
|
+
if (currentGen !== generation) return
|
|
151
|
+
await fs.promises.writeFile(path.join(dir, key + '.json'), JSON.stringify({ mime: hit.mime, at: now }))
|
|
152
|
+
if (currentGen !== generation) {
|
|
153
|
+
await fs.promises.rm(path.join(dir, key + '.json'), { force: true }).catch(() => {})
|
|
154
|
+
}
|
|
155
|
+
})().catch(() => {})
|
|
156
|
+
trackWrite(writePromise)
|
|
128
157
|
return { mime: hit.mime || 'audio/mpeg', audio: hit.audio }
|
|
129
158
|
}
|
|
130
159
|
|
|
@@ -137,7 +166,15 @@ export function createSpeechCache({ root, maxBytes }) {
|
|
|
137
166
|
const now = Date.now()
|
|
138
167
|
const d = diskEntries.get(key)
|
|
139
168
|
if (d) d.at = now
|
|
140
|
-
|
|
169
|
+
const currentGen = generation
|
|
170
|
+
const writePromise = (async () => {
|
|
171
|
+
if (currentGen !== generation) return
|
|
172
|
+
await fs.promises.writeFile(path.join(dir, key + '.json'), JSON.stringify({ mime: meta.mime, at: now }))
|
|
173
|
+
if (currentGen !== generation) {
|
|
174
|
+
await fs.promises.rm(path.join(dir, key + '.json'), { force: true }).catch(() => {})
|
|
175
|
+
}
|
|
176
|
+
})().catch(() => {})
|
|
177
|
+
trackWrite(writePromise)
|
|
141
178
|
return { mime: meta.mime || 'audio/mpeg', audio }
|
|
142
179
|
} catch (miss) {
|
|
143
180
|
return null
|
|
@@ -145,12 +182,23 @@ export function createSpeechCache({ root, maxBytes }) {
|
|
|
145
182
|
},
|
|
146
183
|
|
|
147
184
|
async clear() {
|
|
185
|
+
generation++
|
|
148
186
|
l1Map.clear()
|
|
187
|
+
if (pendingWrites.size > 0) {
|
|
188
|
+
await Promise.allSettled(Array.from(pendingWrites))
|
|
189
|
+
}
|
|
149
190
|
await ensureIndex()
|
|
150
191
|
const count = diskEntries.size
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
await fs.promises.
|
|
192
|
+
let files = []
|
|
193
|
+
try {
|
|
194
|
+
files = await fs.promises.readdir(dir)
|
|
195
|
+
} catch {
|
|
196
|
+
files = []
|
|
197
|
+
}
|
|
198
|
+
for (const name of files) {
|
|
199
|
+
if (name.endsWith(AUDIO_EXT) || name.endsWith('.json')) {
|
|
200
|
+
await fs.promises.rm(path.join(dir, name), { force: true }).catch(() => {})
|
|
201
|
+
}
|
|
154
202
|
}
|
|
155
203
|
diskEntries.clear()
|
|
156
204
|
diskTotalBytes = 0
|
package/lib/client.js
CHANGED
|
@@ -51,7 +51,69 @@ window.__ModuleLoader__.load({
|
|
|
51
51
|
}
|
|
52
52
|
|
|
53
53
|
|
|
54
|
-
|
|
54
|
+
function clientFetch(url, options) {
|
|
55
|
+
const opts = options || {}
|
|
56
|
+
const timeoutMs = Number(opts.timeoutMs) > 0 ? Number(opts.timeoutMs) : 15000
|
|
57
|
+
const controller = new AbortController()
|
|
58
|
+
let timer = null
|
|
59
|
+
let parentCleanup = null
|
|
60
|
+
|
|
61
|
+
const cleanup = () => {
|
|
62
|
+
if (timer) {
|
|
63
|
+
clearTimeout(timer)
|
|
64
|
+
timer = null
|
|
65
|
+
}
|
|
66
|
+
if (parentCleanup) {
|
|
67
|
+
parentCleanup()
|
|
68
|
+
parentCleanup = null
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
timer = setTimeout(() => {
|
|
73
|
+
try { controller.abort() } catch { /* already aborted */ }
|
|
74
|
+
}, timeoutMs)
|
|
75
|
+
if (timer && typeof timer.unref === 'function') timer.unref()
|
|
76
|
+
|
|
77
|
+
if (opts.signal) {
|
|
78
|
+
if (opts.signal.aborted) {
|
|
79
|
+
controller.abort()
|
|
80
|
+
} else {
|
|
81
|
+
const onAbort = () => {
|
|
82
|
+
try { controller.abort() } catch { /* already aborted */ }
|
|
83
|
+
}
|
|
84
|
+
opts.signal.addEventListener('abort', onAbort, { once: true })
|
|
85
|
+
parentCleanup = () => {
|
|
86
|
+
try { opts.signal.removeEventListener('abort', onAbort) } catch { /* listener already removed */ }
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const fetchOpts = Object.assign({}, opts, { signal: controller.signal })
|
|
92
|
+
delete fetchOpts.timeoutMs
|
|
93
|
+
|
|
94
|
+
return fetch(url, fetchOpts).then((res) => {
|
|
95
|
+
if (!res.body || res.bodyUsed) {
|
|
96
|
+
cleanup()
|
|
97
|
+
return res
|
|
98
|
+
}
|
|
99
|
+
const wrap = (orig) => {
|
|
100
|
+
if (typeof orig !== 'function') return orig
|
|
101
|
+
return function (...args) {
|
|
102
|
+
return orig.apply(res, args).finally(cleanup)
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
res.json = wrap(res.json)
|
|
106
|
+
res.text = wrap(res.text)
|
|
107
|
+
res.blob = wrap(res.blob)
|
|
108
|
+
res.arrayBuffer = wrap(res.arrayBuffer)
|
|
109
|
+
return res
|
|
110
|
+
}, (err) => {
|
|
111
|
+
cleanup()
|
|
112
|
+
throw err
|
|
113
|
+
})
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function getModelHint(provider, t) {
|
|
55
117
|
if (typeof t === 'function') {
|
|
56
118
|
const loc = t('modelHint_' + provider)
|
|
57
119
|
if (loc && !loc.startsWith('modelHint_')) return loc
|
|
@@ -122,12 +184,12 @@ window.__ModuleLoader__.load({
|
|
|
122
184
|
'.dts-body{border-top:1px solid var(--dsw-alias-border-l2);margin:0 16px;padding:12px 0}' +
|
|
123
185
|
'.dts-field{display:flex;flex-direction:column;gap:6px;padding:6px 0;font-size:13px;color:var(--dsw-alias-label-primary)}' +
|
|
124
186
|
'.dts-input{height:34px;border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-label-primary);border-radius:8px;padding:0 10px;font-size:13px;box-sizing:border-box}' +
|
|
125
|
-
'.dts-input:focus{outline:none;border-color:var(--dsw-alias-
|
|
187
|
+
'.dts-input:focus{outline:none;border-color:var(--dsw-alias-brand-primary, #6366f1)}' +
|
|
126
188
|
'.dts-foot{border-top:1px solid var(--dsw-alias-border-l2);display:flex;justify-content:flex-end;align-items:center;gap:10px;padding:14px 0 4px}' +
|
|
127
189
|
'.dts-save{appearance:none;font:inherit;cursor:pointer;border:1px solid transparent;border-radius:8px;padding:6px 16px;font-size:13px;font-weight:500;background:var(--dsw-alias-label-primary);color:var(--dsw-alias-bg-layer-3);transition:all .15s ease}' +
|
|
128
190
|
'.dts-save:hover:not(:disabled){opacity:0.88}' +
|
|
129
191
|
'.dts-btn{appearance:none;font:inherit;cursor:pointer;border:1px solid var(--dsw-alias-border-l2);border-radius:8px;padding:6px 12px;font-size:13px;background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-label-primary);font-weight:500;display:inline-flex;align-items:center;justify-content:center;gap:6px;transition:all .15s ease}' +
|
|
130
|
-
'.dts-btn:hover:not(:disabled){background:var(--dsw-alias-bg-layer-
|
|
192
|
+
'.dts-btn:hover:not(:disabled){background:var(--dsw-alias-bg-layer-3, var(--dsw-alias-bg-layer-2));border-color:var(--dsw-alias-label-dimmed, var(--dsw-alias-border-l2))}' +
|
|
131
193
|
'.dts-btn-danger{color:var(--dsw-alias-state-error-primary);border-color:color-mix(in srgb, var(--dsw-alias-state-error-primary) 30%, transparent)}' +
|
|
132
194
|
'.dts-btn-danger:hover:not(:disabled){background:color-mix(in srgb, var(--dsw-alias-state-error-primary) 12%, transparent);border-color:color-mix(in srgb, var(--dsw-alias-state-error-primary) 50%, transparent)}' +
|
|
133
195
|
'.dts-entry{display:flex;flex-direction:column;gap:8px;padding:12px;border:1px solid var(--dsw-alias-border-l2);border-radius:8px;background:var(--dsw-alias-bg-layer-2)}' +
|
|
@@ -135,18 +197,18 @@ window.__ModuleLoader__.load({
|
|
|
135
197
|
'.dts-row .dts-model{flex:1;min-width:120px}' +
|
|
136
198
|
'.dts-row .dts-key{flex:1;min-width:180px}' +
|
|
137
199
|
'.dts-mini{border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-label-primary);border-radius:6px;width:28px;height:28px;cursor:pointer;flex:none;display:inline-flex;align-items:center;justify-content:center;font-size:13px;transition:all .15s ease}' +
|
|
138
|
-
'.dts-mini:hover:not(:disabled){background:var(--dsw-alias-bg-layer-
|
|
200
|
+
'.dts-mini:hover:not(:disabled){background:var(--dsw-alias-bg-layer-3, var(--dsw-alias-bg-layer-2))}' +
|
|
139
201
|
'.dts-ok{font-size:12px;color:var(--dsw-alias-state-success-primary);font-weight:500}' +
|
|
140
202
|
'.dts-bad{font-size:12px;color:var(--dsw-alias-state-error-primary);font-weight:500}' +
|
|
141
203
|
'.dts-badge{font-size:11px;padding:3px 8px;border-radius:999px;border:1px solid var(--dsw-alias-border-l2);color:var(--dsw-alias-label-secondary);white-space:nowrap;display:inline-flex;align-items:center;gap:4px;font-weight:500}' +
|
|
142
204
|
'.dts-badge-on{color:var(--dsw-alias-state-success-primary);border-color:var(--dsw-alias-state-success-primary);background:color-mix(in srgb, var(--dsw-alias-state-success-primary) 8%, transparent)}' +
|
|
143
|
-
'.dts-badge-warn{color:var(--dsw-alias-state-
|
|
205
|
+
'.dts-badge-warn{color:var(--dsw-alias-state-warn-primary, #f59e0b);border-color:var(--dsw-alias-state-warn-primary, #f59e0b);background:color-mix(in srgb, var(--dsw-alias-state-warn-primary, #f59e0b) 8%, transparent)}' +
|
|
144
206
|
'.dts-badge-bad{color:var(--dsw-alias-state-error-primary);border-color:var(--dsw-alias-state-error-primary);background:color-mix(in srgb, var(--dsw-alias-state-error-primary) 8%, transparent)}' +
|
|
145
207
|
'.dts-link{background:none;border:none;color:var(--dsw-alias-label-secondary);cursor:pointer;font-size:12px;padding:0;text-decoration:underline}' +
|
|
146
208
|
'.dts-link:hover:not(:disabled){color:var(--dsw-alias-label-primary)}' +
|
|
147
209
|
'.dts-alert{padding:10px 14px;border-radius:8px;font-size:13px}' +
|
|
148
210
|
'.dts-alert-err{background:color-mix(in srgb, var(--dsw-alias-state-error-primary) 10%, transparent);color:var(--dsw-alias-state-error-primary);border:1px solid color-mix(in srgb, var(--dsw-alias-state-error-primary) 20%, transparent)}' +
|
|
149
|
-
'.dts-alert-warn{background:color-mix(in srgb, var(--dsw-alias-state-
|
|
211
|
+
'.dts-alert-warn{background:color-mix(in srgb, var(--dsw-alias-state-warn-primary, #f59e0b) 10%, transparent);color:var(--dsw-alias-state-warn-primary, #f59e0b);border:1px solid color-mix(in srgb, var(--dsw-alias-state-warn-primary, #f59e0b) 20%, transparent)}' +
|
|
150
212
|
'.dts-alert-ok{background:color-mix(in srgb, var(--dsw-alias-state-success-primary) 10%, transparent);color:var(--dsw-alias-state-success-primary);border:1px solid color-mix(in srgb, var(--dsw-alias-state-success-primary) 20%, transparent)}' +
|
|
151
213
|
'.dts-grow{flex:1;min-width:100px}'
|
|
152
214
|
|
|
@@ -457,7 +519,7 @@ window.__ModuleLoader__.load({
|
|
|
457
519
|
const onSpeaking = (event) => {
|
|
458
520
|
const phase = event && event.detail && event.detail.phase
|
|
459
521
|
if (phase !== 'start') return
|
|
460
|
-
if (!player.bargeIn) return
|
|
522
|
+
if (!player.bargeIn || player.vadBargeIn === false) return
|
|
461
523
|
if (!player.audio && !player.queue.length && !player.busy) return
|
|
462
524
|
stopPlayback()
|
|
463
525
|
}
|
|
@@ -543,7 +605,7 @@ window.__ModuleLoader__.load({
|
|
|
543
605
|
|
|
544
606
|
async function pollPending() {
|
|
545
607
|
try {
|
|
546
|
-
const st = await
|
|
608
|
+
const st = await clientFetch('/dsh-tts/status', { cache: 'no-store', timeoutMs: 8000 })
|
|
547
609
|
if (!st.ok) return
|
|
548
610
|
const meta = await st.json()
|
|
549
611
|
player.enabled = !!(meta && meta.speakReplies)
|
|
@@ -551,8 +613,9 @@ window.__ModuleLoader__.load({
|
|
|
551
613
|
if (meta && meta.chime) player.chime = meta.chime
|
|
552
614
|
if (meta && meta.roles) player.roles = meta.roles
|
|
553
615
|
if (meta && typeof meta.bargeIn === 'boolean') player.bargeIn = meta.bargeIn
|
|
616
|
+
if (meta && typeof meta.vadBargeIn === 'boolean') player.vadBargeIn = meta.vadBargeIn
|
|
554
617
|
if (!player.enabled) return
|
|
555
|
-
const res = await
|
|
618
|
+
const res = await clientFetch('/dsh-tts/pending?after=' + encodeURIComponent(player.after || ''), { cache: 'no-store', timeoutMs: 8000 })
|
|
556
619
|
if (!res.ok) return
|
|
557
620
|
const data = await res.json()
|
|
558
621
|
const items = data && Array.isArray(data.items) ? data.items : []
|
|
@@ -608,10 +671,11 @@ window.__ModuleLoader__.load({
|
|
|
608
671
|
function playStandalone(text) {
|
|
609
672
|
if (!text) return
|
|
610
673
|
stopPlayback()
|
|
611
|
-
|
|
674
|
+
clientFetch('/dsh-tts/speak', {
|
|
612
675
|
method: 'POST',
|
|
613
676
|
headers: { 'Content-Type': 'application/json' },
|
|
614
677
|
body: JSON.stringify({ text }),
|
|
678
|
+
timeoutMs: 15000,
|
|
615
679
|
})
|
|
616
680
|
.then((r) => r.json())
|
|
617
681
|
.then((d) => {
|
|
@@ -623,15 +687,18 @@ window.__ModuleLoader__.load({
|
|
|
623
687
|
audioBase64: d.audioBase64,
|
|
624
688
|
})
|
|
625
689
|
})
|
|
626
|
-
.catch(() => {
|
|
690
|
+
.catch((err) => {
|
|
691
|
+
console.warn('[dsh-tts] playStandalone fetch failed:', err)
|
|
692
|
+
})
|
|
627
693
|
}
|
|
628
694
|
|
|
629
695
|
function previewSpeech(provider, model, voice, text) {
|
|
630
696
|
stopPlayback()
|
|
631
|
-
return
|
|
697
|
+
return clientFetch('/dsh-tts/preview', {
|
|
632
698
|
method: 'POST',
|
|
633
699
|
headers: { 'Content-Type': 'application/json' },
|
|
634
700
|
body: JSON.stringify({ provider, model, voice, text }),
|
|
701
|
+
timeoutMs: 15000,
|
|
635
702
|
})
|
|
636
703
|
.then((r) => r.json())
|
|
637
704
|
.then((d) => {
|
|
@@ -657,10 +724,11 @@ window.__ModuleLoader__.load({
|
|
|
657
724
|
if (list && list.items && list.items.length) phrase = list.items[0]
|
|
658
725
|
}
|
|
659
726
|
if (!phrase) return
|
|
660
|
-
|
|
727
|
+
clientFetch('/dsh-tts/speak', {
|
|
661
728
|
method: 'POST',
|
|
662
729
|
headers: { 'Content-Type': 'application/json' },
|
|
663
730
|
body: JSON.stringify({ text: phrase }),
|
|
731
|
+
timeoutMs: 15000,
|
|
664
732
|
})
|
|
665
733
|
.then((r) => r.json())
|
|
666
734
|
.then((d) => {
|
|
@@ -1219,8 +1287,8 @@ window.__ModuleLoader__.load({
|
|
|
1219
1287
|
const [models, setModels] = React.useState({})
|
|
1220
1288
|
|
|
1221
1289
|
const loadStatus = React.useCallback(() => {
|
|
1222
|
-
if (typeof fetch === 'undefined') return
|
|
1223
|
-
|
|
1290
|
+
if (typeof clientFetch === 'undefined' && typeof fetch === 'undefined') return
|
|
1291
|
+
clientFetch('/dsh-tts/models/status', { timeoutMs: 8000 })
|
|
1224
1292
|
.then((r) => r.json())
|
|
1225
1293
|
.then((d) => { if (d && d.ok && d.models) setModels(d.models) })
|
|
1226
1294
|
.catch(() => {}) // status poll best-effort
|
|
@@ -1236,11 +1304,14 @@ window.__ModuleLoader__.load({
|
|
|
1236
1304
|
if (typeof window !== 'undefined' && window.confirm) {
|
|
1237
1305
|
if (!window.confirm(t('confirmDeleteModel'))) return
|
|
1238
1306
|
}
|
|
1239
|
-
|
|
1307
|
+
clientFetch('/dsh-tts/models/delete', {
|
|
1240
1308
|
method: 'DELETE',
|
|
1241
1309
|
headers: { 'content-type': 'application/json' },
|
|
1242
1310
|
body: JSON.stringify({ engine }),
|
|
1243
|
-
|
|
1311
|
+
timeoutMs: 10000,
|
|
1312
|
+
}).then(() => loadStatus()).catch((err) => {
|
|
1313
|
+
console.warn('[dsh-tts] model delete failed:', err)
|
|
1314
|
+
})
|
|
1244
1315
|
}
|
|
1245
1316
|
|
|
1246
1317
|
const kokoro = models.kokoro || { installed: false, downloading: false, progress: 0 }
|
|
@@ -1480,7 +1551,7 @@ window.__ModuleLoader__.load({
|
|
|
1480
1551
|
style: {
|
|
1481
1552
|
padding: '10px 14px',
|
|
1482
1553
|
borderRadius: '8px',
|
|
1483
|
-
border: '1px solid var(--dsw-alias-state-
|
|
1554
|
+
border: '1px solid var(--dsw-alias-state-warn-primary, #f59e0b)',
|
|
1484
1555
|
background: 'var(--dsw-alias-bg-layer-2)',
|
|
1485
1556
|
display: 'flex',
|
|
1486
1557
|
flexDirection: 'column',
|
|
@@ -1488,7 +1559,7 @@ window.__ModuleLoader__.load({
|
|
|
1488
1559
|
fontSize: '13px',
|
|
1489
1560
|
},
|
|
1490
1561
|
},
|
|
1491
|
-
React.createElement('span', { style: { fontWeight: 600, color: 'var(--dsw-alias-state-
|
|
1562
|
+
React.createElement('span', { style: { fontWeight: 600, color: 'var(--dsw-alias-state-warn-primary, #f59e0b)' } },
|
|
1492
1563
|
'⚠️ ' + t('voiceNotInstalledTitle')
|
|
1493
1564
|
),
|
|
1494
1565
|
React.createElement('span', { className: 'dts-sub' },
|
|
@@ -1522,7 +1593,7 @@ window.__ModuleLoader__.load({
|
|
|
1522
1593
|
style: {
|
|
1523
1594
|
padding: '10px 14px',
|
|
1524
1595
|
borderRadius: '8px',
|
|
1525
|
-
border: '1px solid var(--dsw-alias-state-
|
|
1596
|
+
border: '1px solid var(--dsw-alias-state-warn-primary, #f59e0b)',
|
|
1526
1597
|
background: 'var(--dsw-alias-bg-layer-2)',
|
|
1527
1598
|
display: 'flex',
|
|
1528
1599
|
flexDirection: 'column',
|
|
@@ -1530,7 +1601,7 @@ window.__ModuleLoader__.load({
|
|
|
1530
1601
|
fontSize: '13px',
|
|
1531
1602
|
},
|
|
1532
1603
|
},
|
|
1533
|
-
React.createElement('span', { style: { fontWeight: 600, color: 'var(--dsw-alias-state-
|
|
1604
|
+
React.createElement('span', { style: { fontWeight: 600, color: 'var(--dsw-alias-state-warn-primary, #f59e0b)' } },
|
|
1534
1605
|
'⚠️ ' + t('messengerNotInstalledTitle')
|
|
1535
1606
|
),
|
|
1536
1607
|
React.createElement('span', { className: 'dts-sub' },
|
|
@@ -1565,8 +1636,9 @@ window.__ModuleLoader__.load({
|
|
|
1565
1636
|
setLoading(true)
|
|
1566
1637
|
setMsg(null)
|
|
1567
1638
|
try {
|
|
1568
|
-
const res = await
|
|
1639
|
+
const res = await clientFetch('/api/dsh-tts/update', {
|
|
1569
1640
|
headers: { 'x-dsh-plugin-update': '1' },
|
|
1641
|
+
timeoutMs: 10000,
|
|
1570
1642
|
})
|
|
1571
1643
|
if (!res.ok) throw new Error('HTTP ' + res.status)
|
|
1572
1644
|
const data = await res.json()
|
|
@@ -1582,12 +1654,13 @@ window.__ModuleLoader__.load({
|
|
|
1582
1654
|
setUpdating(true)
|
|
1583
1655
|
setMsg(null)
|
|
1584
1656
|
try {
|
|
1585
|
-
const res = await
|
|
1657
|
+
const res = await clientFetch('/api/dsh-tts/update', {
|
|
1586
1658
|
method: 'POST',
|
|
1587
1659
|
headers: {
|
|
1588
1660
|
'x-dsh-plugin-update': '1',
|
|
1589
1661
|
'content-type': 'application/json',
|
|
1590
1662
|
},
|
|
1663
|
+
timeoutMs: 60000,
|
|
1591
1664
|
})
|
|
1592
1665
|
const data = await res.json()
|
|
1593
1666
|
if (!res.ok) throw new Error((data && (data.error || data.message)) || 'HTTP ' + res.status)
|
|
@@ -1631,7 +1704,7 @@ window.__ModuleLoader__.load({
|
|
|
1631
1704
|
{
|
|
1632
1705
|
type: 'button',
|
|
1633
1706
|
className: 'dts-btn',
|
|
1634
|
-
style: { borderColor: 'var(--dsw-alias-state-
|
|
1707
|
+
style: { borderColor: 'var(--dsw-alias-state-warn-primary, #f59e0b)', color: 'var(--dsw-alias-state-warn-primary, #f59e0b)' },
|
|
1635
1708
|
onClick: onUpdateNow,
|
|
1636
1709
|
disabled: updating || loading,
|
|
1637
1710
|
},
|
|
@@ -1661,11 +1734,11 @@ window.__ModuleLoader__.load({
|
|
|
1661
1734
|
const t = props.t || ((key) => key)
|
|
1662
1735
|
const [data, setData] = React.useState(null)
|
|
1663
1736
|
const loadStats = () => {
|
|
1664
|
-
|
|
1737
|
+
clientFetch('/dsh-tts/stats', { cache: 'no-store', timeoutMs: 8000 }).then((r) => r.json()).then(setData).catch((err) => console.warn('[dsh-tts] loadStats failed:', err))
|
|
1665
1738
|
}
|
|
1666
1739
|
React.useEffect(() => { loadStats() }, [])
|
|
1667
1740
|
const reset = () => {
|
|
1668
|
-
|
|
1741
|
+
clientFetch('/dsh-tts/stats', { method: 'DELETE', timeoutMs: 8000 }).then(loadStats).catch((err) => console.warn('[dsh-tts] resetStats failed:', err))
|
|
1669
1742
|
}
|
|
1670
1743
|
return React.createElement('div', { className: 'dts-block' },
|
|
1671
1744
|
React.createElement('span', { className: 'dts-sub' }, data
|
|
@@ -1751,18 +1824,18 @@ window.__ModuleLoader__.load({
|
|
|
1751
1824
|
|
|
1752
1825
|
React.useEffect(() => {
|
|
1753
1826
|
let alive = true
|
|
1754
|
-
|
|
1827
|
+
clientFetch('/dsh-tts/config', { cache: 'no-store', timeoutMs: 8000 }).then((res) => res.json()).then((data) => {
|
|
1755
1828
|
if (!alive) return
|
|
1756
1829
|
applyPayload(data)
|
|
1757
1830
|
}).catch((e) => { if (alive) setErr(String(e && e.message ? e.message : e)) })
|
|
1758
1831
|
|
|
1759
|
-
|
|
1832
|
+
clientFetch('/dsh-tts/integrations', { cache: 'no-store', timeoutMs: 8000 }).then((res) => res.json()).then((data) => {
|
|
1760
1833
|
if (!alive) return
|
|
1761
1834
|
if (data && data.ok) setIntegrations(data.integrations || data)
|
|
1762
1835
|
}).catch(() => {})
|
|
1763
1836
|
|
|
1764
1837
|
const loadTelemetry = () => {
|
|
1765
|
-
|
|
1838
|
+
clientFetch('/dsh-tts/stats', { cache: 'no-store', timeoutMs: 8000 }).then((res) => res.json()).then((st) => {
|
|
1766
1839
|
if (!alive || !st) return
|
|
1767
1840
|
setTelemetry((prev) => Object.assign({}, prev, {
|
|
1768
1841
|
cacheHits: st.cacheHits || 0,
|
|
@@ -1773,7 +1846,7 @@ window.__ModuleLoader__.load({
|
|
|
1773
1846
|
}).catch(() => {
|
|
1774
1847
|
if (alive) setTelemetry((prev) => Object.assign({}, prev, { online: false }))
|
|
1775
1848
|
})
|
|
1776
|
-
|
|
1849
|
+
clientFetch('/dsh-tts/status', { cache: 'no-store', timeoutMs: 8000 }).then((res) => res.json()).then((st) => {
|
|
1777
1850
|
if (!alive || !st) return
|
|
1778
1851
|
setTelemetry((prev) => Object.assign({}, prev, {
|
|
1779
1852
|
sseClients: st.sseClients || 0,
|
|
@@ -1804,10 +1877,11 @@ window.__ModuleLoader__.load({
|
|
|
1804
1877
|
const value = String(raw != null ? raw : ((keyDrafts && keyDrafts[provider]) || '')).trim()
|
|
1805
1878
|
if (!value) return
|
|
1806
1879
|
setErr('')
|
|
1807
|
-
const res = await
|
|
1880
|
+
const res = await clientFetch('/dsh-tts/credential', {
|
|
1808
1881
|
method: 'PUT',
|
|
1809
1882
|
headers: { 'Content-Type': 'application/json' },
|
|
1810
1883
|
body: JSON.stringify({ provider: provider, value: value }),
|
|
1884
|
+
timeoutMs: 10000,
|
|
1811
1885
|
})
|
|
1812
1886
|
const data = await res.json().catch(() => ({ /* parse error */ }))
|
|
1813
1887
|
if (!res.ok) throw new Error((data && data.error && data.error.message) || ('HTTP ' + res.status))
|
|
@@ -1818,10 +1892,11 @@ window.__ModuleLoader__.load({
|
|
|
1818
1892
|
const clearKey = async (provider) => {
|
|
1819
1893
|
setErr('')
|
|
1820
1894
|
try {
|
|
1821
|
-
const res = await
|
|
1895
|
+
const res = await clientFetch('/dsh-tts/credential', {
|
|
1822
1896
|
method: 'DELETE',
|
|
1823
1897
|
headers: { 'Content-Type': 'application/json' },
|
|
1824
1898
|
body: JSON.stringify({ provider: provider }),
|
|
1899
|
+
timeoutMs: 10000,
|
|
1825
1900
|
})
|
|
1826
1901
|
const data = await res.json().catch(() => ({ /* parse error */ }))
|
|
1827
1902
|
if (!res.ok) throw new Error((data && data.error && data.error.message) || ('HTTP ' + res.status))
|
|
@@ -1843,10 +1918,11 @@ window.__ModuleLoader__.load({
|
|
|
1843
1918
|
if (revision !== null && revision !== undefined) {
|
|
1844
1919
|
bodyPayload.revision = revision
|
|
1845
1920
|
}
|
|
1846
|
-
const res = await
|
|
1921
|
+
const res = await clientFetch('/dsh-tts/config', {
|
|
1847
1922
|
method: 'PUT',
|
|
1848
1923
|
headers: { 'Content-Type': 'application/json' },
|
|
1849
1924
|
body: JSON.stringify(bodyPayload),
|
|
1925
|
+
timeoutMs: 10000,
|
|
1850
1926
|
})
|
|
1851
1927
|
const data = await res.json().catch(() => ({ /* parse error */ }))
|
|
1852
1928
|
if (!res.ok) throw new Error((data && data.error && data.error.message) || ('HTTP ' + res.status))
|
|
@@ -1862,7 +1938,7 @@ window.__ModuleLoader__.load({
|
|
|
1862
1938
|
|
|
1863
1939
|
const clearCacheNow = async () => {
|
|
1864
1940
|
setErr('')
|
|
1865
|
-
const res = await
|
|
1941
|
+
const res = await clientFetch('/dsh-tts/cache', { method: 'DELETE', timeoutMs: 10000 })
|
|
1866
1942
|
if (!res.ok) throw new Error('HTTP ' + res.status)
|
|
1867
1943
|
setSaved(true); setTimeout(() => setSaved(false), 2000)
|
|
1868
1944
|
}
|
package/lib/engines/manager.js
CHANGED
|
@@ -11,12 +11,12 @@ export const MODEL_SPECS = {
|
|
|
11
11
|
files: [
|
|
12
12
|
{
|
|
13
13
|
name: 'kokoro-v0_19.onnx',
|
|
14
|
-
url: 'https://
|
|
14
|
+
url: 'https://github.com/thewh1teagle/kokoro-onnx/releases/download/model-files/kokoro-v0_19.onnx',
|
|
15
15
|
minBytes: 1024,
|
|
16
16
|
},
|
|
17
17
|
{
|
|
18
18
|
name: 'voices.json',
|
|
19
|
-
url: 'https://
|
|
19
|
+
url: 'https://github.com/thewh1teagle/kokoro-onnx/releases/download/model-files/voices.json',
|
|
20
20
|
minBytes: 1024,
|
|
21
21
|
},
|
|
22
22
|
],
|
|
@@ -29,7 +29,7 @@ export const MODEL_SPECS = {
|
|
|
29
29
|
files: [
|
|
30
30
|
{
|
|
31
31
|
name: 'model_1200000.safetensors',
|
|
32
|
-
url: 'https://huggingface.co/SWivid/F5-TTS/resolve/
|
|
32
|
+
url: 'https://huggingface.co/SWivid/F5-TTS/resolve/84e5a410d9cead4de2f847e7c9369a6440bdfaca/F5TTS_Base/model_1200000.safetensors',
|
|
33
33
|
minBytes: 1024,
|
|
34
34
|
},
|
|
35
35
|
],
|
|
@@ -50,13 +50,44 @@ export function createModelManager({ root = process.env.DSH_HOME || path.join(os
|
|
|
50
50
|
const st = await fs.promises.stat(filePath)
|
|
51
51
|
if (!st.isFile() || st.size < minBytes) return false
|
|
52
52
|
const fd = await fs.promises.open(filePath, 'r')
|
|
53
|
-
const
|
|
54
|
-
|
|
53
|
+
const sampleLen = Math.min(st.size, 2048)
|
|
54
|
+
const buf = Buffer.alloc(sampleLen)
|
|
55
|
+
await fd.read(buf, 0, sampleLen, 0)
|
|
55
56
|
await fd.close()
|
|
57
|
+
|
|
56
58
|
const str = buf.toString('utf8').trim().toLowerCase()
|
|
57
59
|
if (str.startsWith('<!doctype') || str.startsWith('<html') || str.startsWith('<head') || str.includes('<html')) {
|
|
58
60
|
return false
|
|
59
61
|
}
|
|
62
|
+
if (str.startsWith('404') || str.includes('entry not found') || str.includes('invalid rev id')) {
|
|
63
|
+
return false
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Reject repetitive dummy bytes (e.g. 2048 bytes of 0x2a)
|
|
67
|
+
if (buf.length >= 2048 && (buf[0] === 0x2a || buf[0] === 0x00)) {
|
|
68
|
+
let allSame = true
|
|
69
|
+
for (let i = 1; i < buf.length; i++) {
|
|
70
|
+
if (buf[i] !== buf[0]) {
|
|
71
|
+
allSame = false
|
|
72
|
+
break
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
if (allSame) return false
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Validate JSON files
|
|
79
|
+
if (filePath.endsWith('.json')) {
|
|
80
|
+
const rawStr = buf.toString('utf8').trim()
|
|
81
|
+
if (rawStr.startsWith('{') || rawStr.startsWith('[') || rawStr.includes('{')) {
|
|
82
|
+
try {
|
|
83
|
+
const fullContent = await fs.promises.readFile(filePath, 'utf8')
|
|
84
|
+
JSON.parse(fullContent)
|
|
85
|
+
} catch {
|
|
86
|
+
return false
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
60
91
|
return true
|
|
61
92
|
} catch (_err) {
|
|
62
93
|
return false
|
package/lib/index.js
CHANGED
|
@@ -248,6 +248,7 @@ export function apply(ctx, config) {
|
|
|
248
248
|
get: () => live(),
|
|
249
249
|
replace: async (next, expectedRevision) => {
|
|
250
250
|
const plain = plainConfig(next)
|
|
251
|
+
delete plain.revision
|
|
251
252
|
const svc = ctx?.get?.('settings') ?? ctx?.settings ?? ctx?.get?.('settingsForms') ?? ctx?.settingsForms
|
|
252
253
|
if (!svc) {
|
|
253
254
|
throw new Error('Settings service is unavailable')
|
package/lib/keys.js
CHANGED
|
@@ -59,6 +59,7 @@ export function stripSecretsFromConfig(payload) {
|
|
|
59
59
|
const config = { ...payload }
|
|
60
60
|
delete config.keys
|
|
61
61
|
delete config.credentials
|
|
62
|
+
delete config.revision
|
|
62
63
|
for (const key of Object.keys(config)) {
|
|
63
64
|
if (/KeyEnv$/.test(key)) continue
|
|
64
65
|
if (/(^|.*)(apiKey|api_key|secret|token|password)$/i.test(key) || /Key$/.test(key)) {
|
package/lib/routes.js
CHANGED
|
@@ -49,6 +49,7 @@ export function registerHttpRoutes(ctx, api) {
|
|
|
49
49
|
rate: cfg.rate,
|
|
50
50
|
chime: cfg.chime,
|
|
51
51
|
bargeIn: cfg.bargeIn && cfg.vadBargeIn !== false,
|
|
52
|
+
vadBargeIn: cfg.vadBargeIn !== false,
|
|
52
53
|
voiceDuplexEnabled: cfg.voiceDuplexEnabled !== false,
|
|
53
54
|
messengerTtsEnabled: cfg.messengerTtsEnabled !== false,
|
|
54
55
|
language: cfg.language,
|
|
@@ -368,23 +369,35 @@ export function registerHttpRoutes(ctx, api) {
|
|
|
368
369
|
}
|
|
369
370
|
const revision = payload?.revision ?? (typeof payload?.config === 'object' ? payload.config?.revision : undefined)
|
|
370
371
|
if (payload && typeof payload.config === 'object') {
|
|
371
|
-
payload = { ...payload.config, keys: payload.keys
|
|
372
|
+
payload = { ...payload.config, keys: payload.keys }
|
|
373
|
+
}
|
|
374
|
+
if (payload && typeof payload === 'object') {
|
|
375
|
+
delete payload.revision
|
|
376
|
+
if (payload.config && typeof payload.config === 'object') {
|
|
377
|
+
delete payload.config.revision
|
|
378
|
+
}
|
|
372
379
|
}
|
|
373
380
|
const keys = pendingKeyWrites(payload && payload.keys)
|
|
374
381
|
const stripped = stripSecretsFromConfig(payload)
|
|
382
|
+
delete stripped.revision
|
|
375
383
|
let parsed
|
|
376
384
|
try { parsed = validateConfig(stripped) } catch (e) {
|
|
377
385
|
writeJson(res, 400, { ok: false, error: { code: 'schema', message: String(e && e.message || e) } })
|
|
378
386
|
return
|
|
379
387
|
}
|
|
388
|
+
if (parsed && typeof parsed === 'object') {
|
|
389
|
+
delete parsed.revision
|
|
390
|
+
}
|
|
380
391
|
try {
|
|
392
|
+
await settingsApi.replace(parsed, revision)
|
|
381
393
|
for (const item of keys) {
|
|
382
394
|
await storeProviderKey(item.provider, item.value)
|
|
383
395
|
}
|
|
384
|
-
await settingsApi.replace(parsed, revision)
|
|
385
396
|
writeJson(res, 200, await configResponse())
|
|
386
397
|
} catch (e) {
|
|
387
|
-
const isConflict = e?.code === 'conflict' || e?.name === 'SettingsConflictError' ||
|
|
398
|
+
const isConflict = e?.code === 'conflict' || e?.name === 'SettingsConflictError' || (
|
|
399
|
+
/conflict/i.test(e?.message || '') || (/revision/i.test(e?.message || '') && !/not volatile/i.test(e?.message || ''))
|
|
400
|
+
)
|
|
388
401
|
const statusCode = isConflict ? 409 : 500
|
|
389
402
|
const code = isConflict ? 'conflict' : 'save'
|
|
390
403
|
writeJson(res, statusCode, { ok: false, error: { code, message: String(e && e.message || e) } })
|
|
@@ -467,6 +480,12 @@ export function registerHttpRoutes(ctx, api) {
|
|
|
467
480
|
const rawText = String(payload.text || '')
|
|
468
481
|
if (!rawText.trim()) { writeJson(res, 400, { ok: false, error: { code: 'no-text', message: 'no text' } }); return }
|
|
469
482
|
const cfg = live()
|
|
483
|
+
const isVoice = payload.channel === 'voice' || payload.source === 'voice' || req.headers['x-dsh-channel'] === 'voice'
|
|
484
|
+
if (isVoice && !cfg.voiceDuplexEnabled) {
|
|
485
|
+
writeJson(res, 403, { ok: false, error: { code: 'disabled', message: 'voice duplex tts is disabled' } })
|
|
486
|
+
return
|
|
487
|
+
}
|
|
488
|
+
const isWeb = payload.channel === 'web' || payload.source === 'web' || req.headers['x-dsh-channel'] === 'web'
|
|
470
489
|
const isMessenger = payload.channel === 'messenger' || payload.source === 'messenger' || req.headers['x-dsh-channel'] === 'messenger'
|
|
471
490
|
if (isMessenger && !cfg.messengerTtsEnabled) {
|
|
472
491
|
writeJson(res, 403, { ok: false, error: { code: 'disabled', message: 'messenger tts is disabled' } })
|
|
@@ -495,15 +514,11 @@ export function registerHttpRoutes(ctx, api) {
|
|
|
495
514
|
},
|
|
496
515
|
}), 'dsh-tts: /speak route')
|
|
497
516
|
|
|
498
|
-
|
|
499
|
-
endpoint: '/dsh-tts/updater',
|
|
500
|
-
packageName: '@goodandready/dsh-tts',
|
|
501
|
-
manifestUrl: new URL('../package.json', import.meta.url),
|
|
502
|
-
}), 'dsh-tts: updater route /dsh-tts/updater')
|
|
503
|
-
|
|
517
|
+
const updaterMutex = { installing: false }
|
|
504
518
|
ctx.effect(() => registerPluginUpdater(ctx, {
|
|
505
519
|
endpoint: '/api/dsh-tts/update',
|
|
506
520
|
packageName: '@goodandready/dsh-tts',
|
|
507
521
|
manifestUrl: new URL('../package.json', import.meta.url),
|
|
522
|
+
mutex: updaterMutex,
|
|
508
523
|
}), 'dsh-tts: updater route /api/dsh-tts/update')
|
|
509
524
|
}
|
package/lib/stream-hub.js
CHANGED
|
@@ -25,7 +25,7 @@ function safeWrite(res, payload) {
|
|
|
25
25
|
? res.writableLength
|
|
26
26
|
: (res.__dtsBufferedBytes || 0)
|
|
27
27
|
|
|
28
|
-
if (res.__dtsBackpressured && currentBuffered + payloadBytes > MAX_SUBSCRIBER_BUFFER) {
|
|
28
|
+
if (res.__dtsBackpressured && (currentBuffered + payloadBytes > MAX_SUBSCRIBER_BUFFER)) {
|
|
29
29
|
subscribers.delete(res)
|
|
30
30
|
detach(res)
|
|
31
31
|
try { res.end() } catch (_err) { /* already ended */ }
|
package/lib/updater.js
CHANGED
|
@@ -225,7 +225,6 @@ async function installExact(target, packageSpec, options) {
|
|
|
225
225
|
'--profile',
|
|
226
226
|
target.profileName,
|
|
227
227
|
'add',
|
|
228
|
-
'--config.minimumReleaseAge=0',
|
|
229
228
|
packageSpec,
|
|
230
229
|
`--registry=${options.registry ?? 'https://registry.npmjs.org/'}`,
|
|
231
230
|
],
|
|
@@ -268,7 +267,7 @@ function json(response, statusCode, value) {
|
|
|
268
267
|
}
|
|
269
268
|
|
|
270
269
|
export function registerPluginUpdater(ctx, options) {
|
|
271
|
-
|
|
270
|
+
const mutex = options?.mutex || { installing: false }
|
|
272
271
|
if (typeof ctx?.webServer?.register !== 'function') {
|
|
273
272
|
ctx?.logger?.warn?.('[dsh-tts] webServer service unavailable, updater skipped')
|
|
274
273
|
return () => {}
|
|
@@ -298,11 +297,11 @@ export function registerPluginUpdater(ctx, options) {
|
|
|
298
297
|
json(response, 403, { error: 'Rejected non-local or cross-origin update request.' })
|
|
299
298
|
return
|
|
300
299
|
}
|
|
301
|
-
if (installing) {
|
|
300
|
+
if (mutex.installing) {
|
|
302
301
|
json(response, 409, { error: 'This plugin is already updating.' })
|
|
303
302
|
return
|
|
304
303
|
}
|
|
305
|
-
installing = true
|
|
304
|
+
mutex.installing = true
|
|
306
305
|
try {
|
|
307
306
|
const before = await status(options, target)
|
|
308
307
|
if (before.latestVersion === undefined) {
|
|
@@ -322,7 +321,7 @@ export function registerPluginUpdater(ctx, options) {
|
|
|
322
321
|
} catch (err) {
|
|
323
322
|
json(response, 503, { error: `Plugin update failed: ${String(err?.message || err)}` })
|
|
324
323
|
} finally {
|
|
325
|
-
installing = false
|
|
324
|
+
mutex.installing = false
|
|
326
325
|
}
|
|
327
326
|
} catch (error) {
|
|
328
327
|
ctx?.logger?.warn?.(`[dsh-tts] plugin updater failed: ${String(error)}`)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@goodandready/dsh-tts",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.31",
|
|
4
4
|
"description": "Text-to-speech for DeepSeek Harness: speak agent replies in the Web UI with a provider fallback chain (OpenAI, ElevenLabs, Google, Azure, Groq, Deepgram, OpenRouter, Edge, Piper, eSpeak).",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|