@goodandready/dsh-voice 0.8.38 → 0.9.1
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.md +4 -1
- package/README.ru.md +1 -1
- package/README.zh.md +16 -0
- package/lib/audio-guard.js +133 -0
- package/lib/client.js +77 -14
- package/lib/config-routes.js +135 -0
- package/lib/http-util.js +3 -2
- package/lib/index.js +21 -4
- package/lib/sensevoice-installer.js +18 -9
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -211,12 +211,15 @@ Open **Settings → Plugins → Plugin settings → Voice** in the Web UI:
|
|
|
211
211
|
## 🤖 Agent Tool & HTTP API
|
|
212
212
|
|
|
213
213
|
### Agent Tool (`transcribe_audio`)
|
|
214
|
-
Registers `transcribe_audio(file_path, language?)` in `ctx.tools`, allowing agents to analyze audio files, interview recordings, and voice notes directly from disk.
|
|
214
|
+
Registers `transcribe_audio(file_path, language?)` in `ctx.tools`, allowing agents to analyze audio files, interview recordings, and voice notes directly from disk. Path containment is strictly enforced against allowed directories (`allowedAudioDirs`, `~/.dsh`, `tmpdir`, `cwd`) with symlink traversal escape prevention and magic-byte audio format validation.
|
|
215
215
|
|
|
216
216
|
### Internal HTTP Endpoints
|
|
217
217
|
* `POST /dsh-voice/transcribe` — `{ dataBase64, mimeType, mode }` → `{ ok, text, provider, tookMs }`
|
|
218
218
|
* `POST /dsh-voice/polish` — `{ text }` → `{ ok, text }`
|
|
219
219
|
* `GET /dsh-voice/status` — Returns daemon status, active chains, SenseVoice.
|
|
220
|
+
* `GET /dsh-voice/config` — Returns live plugin configuration snapshot.
|
|
221
|
+
* `PUT /dsh-voice/config` — Updates and persists plugin configuration across network.
|
|
222
|
+
* `GET/POST /dsh-voice/sensevoice-installer` — SenseVoice 1-click model installation status and trigger (protected by `isTrustedCaller`, model paths redacted).
|
|
220
223
|
|
|
221
224
|
---
|
|
222
225
|
|
package/README.ru.md
CHANGED
|
@@ -211,7 +211,7 @@ dsh plugin --profile web add @goodandready/dsh-voice
|
|
|
211
211
|
## 🤖 Инструмент агента и HTTP API
|
|
212
212
|
|
|
213
213
|
### Инструмент агента (`transcribe_audio`)
|
|
214
|
-
Регистрирует `transcribe_audio(file_path, language?)` в `ctx.tools`, позволяя агентам распознавать аудиофайлы, интервью и записи с диска.
|
|
214
|
+
Регистрирует `transcribe_audio(file_path, language?)` в `ctx.tools` (с проверкой границ разрешённых каталогов `allowedAudioDirs` / `~/.dsh` / `tmpdir` / `cwd`, защитой от симлинков и валидацией сигнатур аудиофайлов), позволяя агентам распознавать аудиофайлы, интервью и записи с диска.
|
|
215
215
|
|
|
216
216
|
### Внутренние HTTP эндпоинты
|
|
217
217
|
* `POST /dsh-voice/transcribe` — `{ dataBase64, mimeType, mode }` → `{ ok, text, provider, tookMs }`
|
package/README.zh.md
CHANGED
|
@@ -129,6 +129,22 @@ v0.8.18 质量审查后的修复批次(Gitea #79–#87,PR #88):
|
|
|
129
129
|
|
|
130
130
|
---
|
|
131
131
|
|
|
132
|
+
## 🤖 智能体工具与 HTTP 端点
|
|
133
|
+
|
|
134
|
+
### 智能体工具 (`transcribe_audio`)
|
|
135
|
+
在 `ctx.tools` 中注册 `transcribe_audio(file_path, language?)`,允许智能体直接读取和转写本地音频文件。包含严格的目录边界检查(`allowedAudioDirs`、`~/.dsh`、`tmpdir`、`cwd`)、符号链接越界防护以及音频特征码(magic bytes)校验。
|
|
136
|
+
|
|
137
|
+
### 内部 HTTP 端点
|
|
138
|
+
|
|
139
|
+
* `POST /dsh-voice/transcribe` — 音频转写:`{ dataBase64, mimeType, mode }` → `{ ok, text, provider, tookMs }`
|
|
140
|
+
* `POST /dsh-voice/polish` — 文本模型润色:`{ text }` → `{ ok, text }`
|
|
141
|
+
* `GET /dsh-voice/status` — 查询后端状态、服务商健康度与 SenseVoice
|
|
142
|
+
* `GET /dsh-voice/config` — 获取插件运行时配置快照
|
|
143
|
+
* `PUT /dsh-voice/config` — 跨网络安全持久化更新插件配置
|
|
144
|
+
* `GET/POST /dsh-voice/sensevoice-installer` — SenseVoice 一键模型安装状态与触发接口(受 `isTrustedCaller` 保护,绝对路径脱敏)
|
|
145
|
+
|
|
146
|
+
---
|
|
147
|
+
|
|
132
148
|
## 📦 安装指南
|
|
133
149
|
|
|
134
150
|
```bash
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import path from 'node:path'
|
|
2
|
+
import os from 'node:os'
|
|
3
|
+
import { realpath } from 'node:fs/promises'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Check whether a target path is strictly located within any of the allowed root directories.
|
|
7
|
+
* Boundary-aware prefix match: /tmp does not allow /tmp-other.
|
|
8
|
+
* @param {string} targetPath
|
|
9
|
+
* @param {string[]} allowedRoots
|
|
10
|
+
* @returns {boolean}
|
|
11
|
+
*/
|
|
12
|
+
export function isPathUnderRoots(targetPath, allowedRoots) {
|
|
13
|
+
if (!targetPath || !Array.isArray(allowedRoots) || allowedRoots.length === 0) return false
|
|
14
|
+
const normalized = path.resolve(targetPath)
|
|
15
|
+
|
|
16
|
+
return allowedRoots.some((root) => {
|
|
17
|
+
if (!root) return false
|
|
18
|
+
const normRoot = path.resolve(root)
|
|
19
|
+
if (normalized === normRoot) return true
|
|
20
|
+
const prefix = normRoot.endsWith(path.sep) ? normRoot : normRoot + path.sep
|
|
21
|
+
return normalized.startsWith(prefix)
|
|
22
|
+
})
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Return allowed root directories for the transcribe_audio tool.
|
|
27
|
+
* Combines configured audio directories with standard safe locations (.dsh, tmpdir, cwd).
|
|
28
|
+
* @param {object} [cfg]
|
|
29
|
+
* @returns {string[]}
|
|
30
|
+
*/
|
|
31
|
+
export function getAllowedAudioRoots(cfg) {
|
|
32
|
+
const custom = Array.isArray(cfg?.allowedAudioDirs) ? cfg.allowedAudioDirs : []
|
|
33
|
+
const defaults = [
|
|
34
|
+
os.tmpdir(),
|
|
35
|
+
path.join(os.homedir(), '.dsh'),
|
|
36
|
+
process.cwd(),
|
|
37
|
+
]
|
|
38
|
+
return [...custom, ...defaults].map((d) => path.resolve(String(d)))
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Validate audio path before access:
|
|
43
|
+
* 1. Checks lexical path boundary before calling stat or read.
|
|
44
|
+
* 2. Checks realpath to prevent symlink traversal escapes.
|
|
45
|
+
* @param {string} filePath
|
|
46
|
+
* @param {string[]} allowedRoots
|
|
47
|
+
* @returns {Promise<{ resolved: string, real: string }>}
|
|
48
|
+
*/
|
|
49
|
+
export async function validateAudioPath(filePath, allowedRoots) {
|
|
50
|
+
const raw = String(filePath || '').trim()
|
|
51
|
+
if (!raw) {
|
|
52
|
+
throw new Error('transcribe_audio: file_path is required')
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const resolved = path.resolve(raw)
|
|
56
|
+
if (!isPathUnderRoots(resolved, allowedRoots)) {
|
|
57
|
+
throw new Error(`transcribe_audio: path is outside allowed audio directories: ${raw}`)
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
let real = ''
|
|
61
|
+
try {
|
|
62
|
+
real = await realpath(resolved)
|
|
63
|
+
} catch (err) {
|
|
64
|
+
throw new Error(`transcribe_audio: file not found: ${raw}`)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
if (!isPathUnderRoots(real, allowedRoots)) {
|
|
68
|
+
throw new Error(`transcribe_audio: resolved symlink is outside allowed audio directories: ${raw}`)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
return { resolved, real }
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Sniff audio format from magic bytes.
|
|
76
|
+
* Supports WAV, MP3, OGG, FLAC, WebM, and M4A/MP4.
|
|
77
|
+
* @param {Buffer|Uint8Array} buffer
|
|
78
|
+
* @returns {string|null} Detected MIME type or null
|
|
79
|
+
*/
|
|
80
|
+
export function sniffAudioFormat(buffer) {
|
|
81
|
+
if (!buffer || buffer.length < 4) return null
|
|
82
|
+
|
|
83
|
+
// WAV: 'RIFF' .... 'WAVE'
|
|
84
|
+
if (buffer.length >= 12) {
|
|
85
|
+
const riff = String.fromCharCode(buffer[0], buffer[1], buffer[2], buffer[3])
|
|
86
|
+
const wave = String.fromCharCode(buffer[8], buffer[9], buffer[10], buffer[11])
|
|
87
|
+
if (riff === 'RIFF' && wave === 'WAVE') return 'audio/wav'
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// OGG: 'OggS'
|
|
91
|
+
if (String.fromCharCode(buffer[0], buffer[1], buffer[2], buffer[3]) === 'OggS') {
|
|
92
|
+
return 'audio/ogg'
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// FLAC: 'fLaC'
|
|
96
|
+
if (String.fromCharCode(buffer[0], buffer[1], buffer[2], buffer[3]) === 'fLaC') {
|
|
97
|
+
return 'audio/flac'
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// WebM / Matroska: 0x1A 0x45 0xDF 0xA3 (EBML)
|
|
101
|
+
if (buffer[0] === 0x1a && buffer[1] === 0x45 && buffer[2] === 0xdf && buffer[3] === 0xa3) {
|
|
102
|
+
return 'audio/webm'
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// MP3: ID3 header or frame sync 0xFF 0xFB/F3/F2
|
|
106
|
+
if (buffer.length >= 3 && String.fromCharCode(buffer[0], buffer[1], buffer[2]) === 'ID3') {
|
|
107
|
+
return 'audio/mpeg'
|
|
108
|
+
}
|
|
109
|
+
if (buffer[0] === 0xff && (buffer[1] & 0xe0) === 0xe0) {
|
|
110
|
+
return 'audio/mpeg'
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// M4A / MP4: '....ftyp'
|
|
114
|
+
if (buffer.length >= 8) {
|
|
115
|
+
const ftyp = String.fromCharCode(buffer[4], buffer[5], buffer[6], buffer[7])
|
|
116
|
+
if (ftyp === 'ftyp') return 'audio/mp4'
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
return null
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Enforce audio MIME/content validation independently of file extension.
|
|
124
|
+
* @param {Buffer|Uint8Array} buffer
|
|
125
|
+
* @returns {string} Detected audio MIME type
|
|
126
|
+
*/
|
|
127
|
+
export function validateAudioContent(buffer) {
|
|
128
|
+
const mime = sniffAudioFormat(buffer)
|
|
129
|
+
if (!mime) {
|
|
130
|
+
throw new Error('transcribe_audio: unsupported or invalid audio file format (magic bytes check failed)')
|
|
131
|
+
}
|
|
132
|
+
return mime
|
|
133
|
+
}
|
package/lib/client.js
CHANGED
|
@@ -225,6 +225,7 @@ window.__ModuleLoader__.load({
|
|
|
225
225
|
'jargonAdd': 'Add replacement',
|
|
226
226
|
'jargonFrom': 'Spoken slang',
|
|
227
227
|
'jargonTo': 'Correct spelling',
|
|
228
|
+
'jargonPlaceholder': 'k8s -> kubernetes\npr -> pull request',
|
|
228
229
|
}
|
|
229
230
|
|
|
230
231
|
const zh = {
|
|
@@ -418,6 +419,7 @@ window.__ModuleLoader__.load({
|
|
|
418
419
|
'jargonAdd': '添加替换项',
|
|
419
420
|
'jargonFrom': '口语词汇',
|
|
420
421
|
'jargonTo': '标准拼写',
|
|
422
|
+
'jargonPlaceholder': 'k8s -> kubernetes\npr -> pull request',
|
|
421
423
|
}
|
|
422
424
|
|
|
423
425
|
// Strings are also needed outside components — in recording handlers and
|
|
@@ -1222,6 +1224,15 @@ window.__ModuleLoader__.load({
|
|
|
1222
1224
|
if (event.repeat) return
|
|
1223
1225
|
// A modifier hotkey does not fight the text field: modifiers do not
|
|
1224
1226
|
// type. A plain letter key must not be hijacked while typing.
|
|
1227
|
+
const target = event.target
|
|
1228
|
+
const isEditable = target && (
|
|
1229
|
+
target.tagName === 'INPUT' ||
|
|
1230
|
+
target.tagName === 'TEXTAREA' ||
|
|
1231
|
+
target.isContentEditable ||
|
|
1232
|
+
(typeof target.getAttribute === 'function' && target.getAttribute('contenteditable') === 'true')
|
|
1233
|
+
)
|
|
1234
|
+
if (isEditable && !['Control', 'Alt', 'Shift'].includes(keyName)) return
|
|
1235
|
+
|
|
1225
1236
|
if (hotkeyMatches(event, keyName)) beginHold(mode)
|
|
1226
1237
|
}
|
|
1227
1238
|
const up = (event) => {
|
|
@@ -2174,6 +2185,25 @@ window.__ModuleLoader__.load({
|
|
|
2174
2185
|
const [draft, setDraft] = React.useState(null)
|
|
2175
2186
|
const [saved, setSaved] = React.useState(false)
|
|
2176
2187
|
const [err, setErr] = React.useState('')
|
|
2188
|
+
const [serverConfig, setServerConfig] = React.useState(null)
|
|
2189
|
+
const [configLoading, setConfigLoading] = React.useState(true)
|
|
2190
|
+
|
|
2191
|
+
React.useEffect(() => {
|
|
2192
|
+
let alive = true
|
|
2193
|
+
fetch('/dsh-voice/config', { cache: 'no-store' })
|
|
2194
|
+
.then((r) => r.json())
|
|
2195
|
+
.then((data) => {
|
|
2196
|
+
if (alive && data && data.ok && data.config) {
|
|
2197
|
+
setServerConfig(data.config)
|
|
2198
|
+
setDraft((curr) => curr || deepClone(data.config))
|
|
2199
|
+
}
|
|
2200
|
+
})
|
|
2201
|
+
.catch(() => {})
|
|
2202
|
+
.finally(() => {
|
|
2203
|
+
if (alive) setConfigLoading(false)
|
|
2204
|
+
})
|
|
2205
|
+
return () => { alive = false }
|
|
2206
|
+
}, [])
|
|
2177
2207
|
|
|
2178
2208
|
// Hotkey picker: not a name field — press the key you want.
|
|
2179
2209
|
const [catching, setCatching] = React.useState(false)
|
|
@@ -2207,9 +2237,9 @@ window.__ModuleLoader__.load({
|
|
|
2207
2237
|
.catch(() => {})
|
|
2208
2238
|
}, [])
|
|
2209
2239
|
|
|
2210
|
-
const ready = !!snap && snap.status === 'ready'
|
|
2211
|
-
const value = ready && snap.value ? snap.value : {}
|
|
2212
|
-
const writable = ready
|
|
2240
|
+
const ready = !!serverConfig || (!!snap && snap.status === 'ready')
|
|
2241
|
+
const value = (snap && snap.status === 'ready' && snap.value) ? snap.value : (serverConfig || {})
|
|
2242
|
+
const writable = ready
|
|
2213
2243
|
|
|
2214
2244
|
React.useEffect(() => {
|
|
2215
2245
|
if (ready) return undefined
|
|
@@ -2455,7 +2485,7 @@ window.__ModuleLoader__.load({
|
|
|
2455
2485
|
}, [checkUpdater])
|
|
2456
2486
|
|
|
2457
2487
|
if (!ready) {
|
|
2458
|
-
const waiting = !snap || snap.status === 'loading'
|
|
2488
|
+
const waiting = configLoading || (!snap || snap.status === 'loading')
|
|
2459
2489
|
return React.createElement('div', { className: 'cb-page' },
|
|
2460
2490
|
React.createElement('div', { className: 'dvs-wait' }, waiting
|
|
2461
2491
|
? t('loadingSettings')
|
|
@@ -2476,13 +2506,40 @@ window.__ModuleLoader__.load({
|
|
|
2476
2506
|
setErr(''); setSaved(false)
|
|
2477
2507
|
if (!draft) return
|
|
2478
2508
|
|
|
2479
|
-
|
|
2480
|
-
|
|
2481
|
-
|
|
2482
|
-
|
|
2483
|
-
|
|
2484
|
-
|
|
2509
|
+
let httpOk = false
|
|
2510
|
+
let httpError = null
|
|
2511
|
+
try {
|
|
2512
|
+
const res = await fetch('/dsh-voice/config', {
|
|
2513
|
+
method: 'PUT',
|
|
2514
|
+
headers: { 'Content-Type': 'application/json' },
|
|
2515
|
+
body: JSON.stringify({ config: draft }),
|
|
2516
|
+
})
|
|
2517
|
+
const data = await res.json()
|
|
2518
|
+
if (res.ok && data && data.ok) {
|
|
2519
|
+
httpOk = true
|
|
2520
|
+
if (data.config) {
|
|
2521
|
+
setServerConfig(data.config)
|
|
2522
|
+
}
|
|
2523
|
+
} else {
|
|
2524
|
+
httpError = data?.error?.message || data?.error || res.statusText || 'Save failed'
|
|
2485
2525
|
}
|
|
2526
|
+
} catch (e) {
|
|
2527
|
+
httpError = e?.message || String(e)
|
|
2528
|
+
}
|
|
2529
|
+
|
|
2530
|
+
if (scope && typeof scope.set === 'function') {
|
|
2531
|
+
for (const k of Object.keys(draft)) {
|
|
2532
|
+
try {
|
|
2533
|
+
await scope.set(k, draft[k])
|
|
2534
|
+
} catch (e) {
|
|
2535
|
+
/* best effort on loopback */
|
|
2536
|
+
}
|
|
2537
|
+
}
|
|
2538
|
+
}
|
|
2539
|
+
|
|
2540
|
+
if (!httpOk) {
|
|
2541
|
+
setErr(t('saveFailed') + ': ' + httpError)
|
|
2542
|
+
return
|
|
2486
2543
|
}
|
|
2487
2544
|
|
|
2488
2545
|
const d = draft || {}
|
|
@@ -2501,11 +2558,17 @@ window.__ModuleLoader__.load({
|
|
|
2501
2558
|
noiseGateDb: Number(d.noiseGateDb !== undefined ? d.noiseGateDb : -45),
|
|
2502
2559
|
contextGlossary: d.contextGlossary !== false,
|
|
2503
2560
|
visualizerStyle: d.visualizerStyle || 'liquid-wave',
|
|
2561
|
+
gatedTurnTaking: d.gatedTurnTaking !== false,
|
|
2562
|
+
autoSendVisualRing: d.autoSendVisualRing !== false,
|
|
2563
|
+
voiceActions: d.voiceActions !== false,
|
|
2564
|
+
techJargonCorrection: d.techJargonCorrection !== false,
|
|
2565
|
+
liveInterimPreview: d.liveInterimPreview !== false,
|
|
2566
|
+
structuredPromptVoice: d.structuredPromptVoice !== false,
|
|
2504
2567
|
})
|
|
2505
2568
|
try { window.dispatchEvent(new CustomEvent('dsh-voice:settings-saved')) } catch (noEvents) { /* nobody */ }
|
|
2506
2569
|
|
|
2507
|
-
|
|
2508
|
-
|
|
2570
|
+
setSaved(true)
|
|
2571
|
+
setTimeout(() => setSaved(false), 2000)
|
|
2509
2572
|
fetchStatus()
|
|
2510
2573
|
}
|
|
2511
2574
|
|
|
@@ -2964,7 +3027,7 @@ window.__ModuleLoader__.load({
|
|
|
2964
3027
|
React.createElement('textarea', {
|
|
2965
3028
|
rows: 3,
|
|
2966
3029
|
disabled: !writable,
|
|
2967
|
-
placeholder: '
|
|
3030
|
+
placeholder: t('jargonPlaceholder'),
|
|
2968
3031
|
value: Array.isArray(draft && draft.jargonDictionary)
|
|
2969
3032
|
? draft.jargonDictionary.map((item) => item && item.from ? `${item.from} -> ${item.to}` : '').filter(Boolean).join('\n')
|
|
2970
3033
|
: '',
|
|
@@ -3047,7 +3110,7 @@ window.__ModuleLoader__.load({
|
|
|
3047
3110
|
React.createElement('div', { className: 'cb-section-desc' }, t('versionHint')),
|
|
3048
3111
|
React.createElement('div', { className: 'cb-row', style: { alignItems: 'center', gap: '12px' } },
|
|
3049
3112
|
React.createElement('span', { className: 'cb-badge cb-badge-ok' },
|
|
3050
|
-
'v' + ((updaterStatus && updaterStatus.currentVersion) || '0.
|
|
3113
|
+
'v' + ((updaterStatus && updaterStatus.currentVersion) || '0.9.1')
|
|
3051
3114
|
),
|
|
3052
3115
|
updaterStatus && updaterStatus.updateAvailable
|
|
3053
3116
|
? React.createElement('span', { className: 'cb-badge cb-badge-warn' },
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
// lib/config-routes.js — Host route for plugin configuration persistence over network.
|
|
2
|
+
// Solves DSH network limitation where client-side persistence is blocked in non-loopback environments (Gitea #149).
|
|
3
|
+
|
|
4
|
+
import { writeJson, readBody, isTrustedCaller } from './http-util.js'
|
|
5
|
+
|
|
6
|
+
export function registerConfigRoutes(ctx, {
|
|
7
|
+
NS,
|
|
8
|
+
live,
|
|
9
|
+
getSettingsService,
|
|
10
|
+
getScope,
|
|
11
|
+
validateConfig,
|
|
12
|
+
triggerAutostart,
|
|
13
|
+
}) {
|
|
14
|
+
return ctx.webServer.register({
|
|
15
|
+
kind: 'exact',
|
|
16
|
+
path: '/dsh-voice/config',
|
|
17
|
+
handler: async (req, res) => {
|
|
18
|
+
// 1. Method check: only GET, PUT, POST allowed
|
|
19
|
+
if (req.method !== 'GET' && req.method !== 'PUT' && req.method !== 'POST') {
|
|
20
|
+
writeJson(res, 405, { ok: false, error: { code: 'method', message: 'GET, PUT, or POST only' } })
|
|
21
|
+
return
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// 2. Security check: loopback or same-origin
|
|
25
|
+
if (!isTrustedCaller(req)) {
|
|
26
|
+
writeJson(res, 403, { ok: false, error: { code: 'forbidden', message: 'forbidden source origin' } })
|
|
27
|
+
return
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// 3. GET: return live configuration snapshot
|
|
31
|
+
if (req.method === 'GET') {
|
|
32
|
+
writeJson(res, 200, { ok: true, config: live() })
|
|
33
|
+
return
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// 4. PUT / POST: check settings service availability
|
|
37
|
+
const settingsService = getSettingsService?.()
|
|
38
|
+
const scope = getScope?.()
|
|
39
|
+
if (!settingsService && !scope) {
|
|
40
|
+
writeJson(res, 503, { ok: false, error: { code: 'unavailable', message: 'settings service not ready' } })
|
|
41
|
+
return
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// 5. Read request body
|
|
45
|
+
let raw
|
|
46
|
+
try {
|
|
47
|
+
raw = await readBody(req, 1024 * 1024)
|
|
48
|
+
} catch (e) {
|
|
49
|
+
writeJson(res, 400, { ok: false, error: { code: 'body', message: e.message } })
|
|
50
|
+
return
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// 6. Parse JSON payload
|
|
54
|
+
let payload
|
|
55
|
+
try {
|
|
56
|
+
payload = JSON.parse(raw.toString('utf8') || '{}')
|
|
57
|
+
} catch {
|
|
58
|
+
writeJson(res, 400, { ok: false, error: { code: 'json', message: 'invalid json' } })
|
|
59
|
+
return
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const rawConfig = payload && typeof payload.config === 'object' && payload.config !== null
|
|
63
|
+
? payload.config
|
|
64
|
+
: payload
|
|
65
|
+
|
|
66
|
+
if (!rawConfig || typeof rawConfig !== 'object' || Array.isArray(rawConfig)) {
|
|
67
|
+
writeJson(res, 400, { ok: false, error: { code: 'body', message: 'body must be a JSON object' } })
|
|
68
|
+
return
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// 7. Validate and merge configuration
|
|
72
|
+
let merged
|
|
73
|
+
try {
|
|
74
|
+
const current = live() || {}
|
|
75
|
+
const candidate = {
|
|
76
|
+
...current,
|
|
77
|
+
...rawConfig,
|
|
78
|
+
dictation: { ...(current.dictation || {}), ...(rawConfig.dictation || {}) },
|
|
79
|
+
message: { ...(current.message || {}), ...(rawConfig.message || {}) },
|
|
80
|
+
}
|
|
81
|
+
merged = validateConfig ? validateConfig(candidate) : candidate
|
|
82
|
+
} catch (err) {
|
|
83
|
+
writeJson(res, 400, { ok: false, error: { code: 'validation', message: String(err?.message || err) } })
|
|
84
|
+
return
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// 8. Persist to host settings
|
|
88
|
+
try {
|
|
89
|
+
let saved = false
|
|
90
|
+
if (settingsService) {
|
|
91
|
+
let rev
|
|
92
|
+
try {
|
|
93
|
+
rev = settingsService.describe?.()?.find?.((r) => r.ns === NS)?.revision
|
|
94
|
+
} catch { /* ignore revision lookup errors */ }
|
|
95
|
+
|
|
96
|
+
if (typeof settingsService.replace === 'function') {
|
|
97
|
+
await settingsService.replace(NS, merged, rev)
|
|
98
|
+
saved = true
|
|
99
|
+
} else if (typeof settingsService.update === 'function') {
|
|
100
|
+
await settingsService.update(NS, merged, rev)
|
|
101
|
+
saved = true
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
if (scope) {
|
|
106
|
+
if (typeof scope.replace === 'function') {
|
|
107
|
+
await scope.replace(merged)
|
|
108
|
+
saved = true
|
|
109
|
+
} else if (typeof scope.patch === 'function') {
|
|
110
|
+
await scope.patch(merged)
|
|
111
|
+
saved = true
|
|
112
|
+
} else if (typeof scope.set === 'function') {
|
|
113
|
+
for (const [k, v] of Object.entries(merged)) {
|
|
114
|
+
await scope.set(k, v).catch(() => {})
|
|
115
|
+
}
|
|
116
|
+
saved = true
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
if (!saved) {
|
|
121
|
+
writeJson(res, 503, { ok: false, error: { code: 'unavailable', message: 'failed to persist settings' } })
|
|
122
|
+
return
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
if (typeof triggerAutostart === 'function') {
|
|
126
|
+
triggerAutostart()
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
writeJson(res, 200, { ok: true, config: live() })
|
|
130
|
+
} catch (err) {
|
|
131
|
+
writeJson(res, 500, { ok: false, error: { code: 'save', message: String(err?.message || err) } })
|
|
132
|
+
}
|
|
133
|
+
},
|
|
134
|
+
})
|
|
135
|
+
}
|
package/lib/http-util.js
CHANGED
|
@@ -50,8 +50,7 @@ export function isTrustedCaller(req) {
|
|
|
50
50
|
if (isLoopback(remote)) return true
|
|
51
51
|
|
|
52
52
|
const secSite = String(req.headers?.['sec-fetch-site'] || '').toLowerCase()
|
|
53
|
-
if (secSite === '
|
|
54
|
-
if (secSite === 'cross-site') return false
|
|
53
|
+
if (secSite === 'cross-site' || secSite === 'same-site') return false
|
|
55
54
|
|
|
56
55
|
const host = String(req.headers?.host || '').toLowerCase()
|
|
57
56
|
const origin = String(req.headers?.origin || '').trim().toLowerCase()
|
|
@@ -63,6 +62,8 @@ export function isTrustedCaller(req) {
|
|
|
63
62
|
return false
|
|
64
63
|
}
|
|
65
64
|
|
|
65
|
+
if (secSite === 'same-origin') return true
|
|
66
|
+
|
|
66
67
|
const referer = String(req.headers?.referer || '').trim().toLowerCase()
|
|
67
68
|
if (referer) {
|
|
68
69
|
try {
|
package/lib/index.js
CHANGED
|
@@ -29,6 +29,8 @@ import { buildProviderOrder, sessionCommand } from './transcribe-core.js'
|
|
|
29
29
|
import { registerPluginUpdater } from './updater.js'
|
|
30
30
|
import { registerSensevoiceInstaller } from './sensevoice-installer.js'
|
|
31
31
|
import { createConfigReader } from './config-compat.js'
|
|
32
|
+
import { registerConfigRoutes } from './config-routes.js'
|
|
33
|
+
import { getAllowedAudioRoots, validateAudioPath, validateAudioContent } from './audio-guard.js'
|
|
32
34
|
|
|
33
35
|
function isAutoLang(lang) {
|
|
34
36
|
return !lang || lang === 'auto' || String(lang).includes(',')
|
|
@@ -135,6 +137,7 @@ const BaseConfig = z.object({
|
|
|
135
137
|
ffmpegBin: z.string().default('ffmpeg')
|
|
136
138
|
.description('ffmpeg used to convert browser webm/opus into the WAV that whisper.cpp requires.'),
|
|
137
139
|
timeoutMs: z.number().default(120000),
|
|
140
|
+
allowedAudioDirs: z.array(z.string()).default([]),
|
|
138
141
|
maxFileBytes: z.number().default(25 * 1024 * 1024),
|
|
139
142
|
normalizeTranscript: z.boolean().default(false)
|
|
140
143
|
.description('transcribe_audio: convert spoken numbers to digits and tidy punctuation.'),
|
|
@@ -214,6 +217,7 @@ export function apply(ctx, baseConfig) {
|
|
|
214
217
|
// while caching unwrapped values across requests.
|
|
215
218
|
const readEntryConfig = createConfigReader(baseConfig, ctx)
|
|
216
219
|
let scope = null
|
|
220
|
+
let settingsService = null
|
|
217
221
|
let getConfig = readEntryConfig
|
|
218
222
|
const live = () => BaseConfig(structuredClone(getConfig() ?? {})) ?? readEntryConfig()
|
|
219
223
|
|
|
@@ -369,6 +373,7 @@ export function apply(ctx, baseConfig) {
|
|
|
369
373
|
}
|
|
370
374
|
|
|
371
375
|
ctx.inject(['settings'], (sctx) => {
|
|
376
|
+
settingsService = sctx.settings
|
|
372
377
|
try {
|
|
373
378
|
scope = sctx.settings.register(NS, Config, { base: baseConfig })
|
|
374
379
|
if (scope && typeof scope.get === 'function') {
|
|
@@ -380,7 +385,7 @@ export function apply(ctx, baseConfig) {
|
|
|
380
385
|
})
|
|
381
386
|
}
|
|
382
387
|
} catch { /* modern DSH handles settings via SettingsForms */ }
|
|
383
|
-
sctx.effect(() => () => { getConfig = readEntryConfig; scope = null })
|
|
388
|
+
sctx.effect(() => () => { getConfig = readEntryConfig; scope = null; settingsService = null })
|
|
384
389
|
triggerAutostart()
|
|
385
390
|
})
|
|
386
391
|
|
|
@@ -621,14 +626,17 @@ export function apply(ctx, baseConfig) {
|
|
|
621
626
|
const cfg = live()
|
|
622
627
|
const filePath = String(args.file_path || '').trim()
|
|
623
628
|
if (!filePath) throw new Error('transcribe_audio: file_path is required')
|
|
624
|
-
const
|
|
629
|
+
const allowedRoots = getAllowedAudioRoots(cfg)
|
|
630
|
+
const { real } = await validateAudioPath(filePath, allowedRoots)
|
|
631
|
+
const info = await stat(real).catch(() => null)
|
|
625
632
|
if (!info) throw new Error(`transcribe_audio: file not found: ${filePath}`)
|
|
626
633
|
if (info.size > cfg.maxFileBytes) {
|
|
627
634
|
throw new Error(`transcribe_audio: file too large (${info.size} bytes, max ${cfg.maxFileBytes})`)
|
|
628
635
|
}
|
|
629
636
|
if (info.size < 44) throw new Error('transcribe_audio: file is empty or too small')
|
|
630
|
-
const
|
|
631
|
-
const
|
|
637
|
+
const bytes = await readFile(real)
|
|
638
|
+
const sniffedMime = validateAudioContent(bytes)
|
|
639
|
+
const mime = sniffedMime || MIME_BY_EXT[path.extname(real).toLowerCase()] || 'audio/wav'
|
|
632
640
|
const modeCfg = { ...cfg.message, language: String(args.language || cfg.message.language) }
|
|
633
641
|
let raw = await transcribe(modeCfg, bytes, mime, exec.signal)
|
|
634
642
|
if (raw && raw.text) raw = { ...raw, text: await polishText(raw.text, modeCfg, exec.signal) }
|
|
@@ -664,5 +672,14 @@ export function apply(ctx, baseConfig) {
|
|
|
664
672
|
},
|
|
665
673
|
startSensevoice,
|
|
666
674
|
}), 'dsh-voice: sensevoice installer route')
|
|
675
|
+
|
|
676
|
+
ctx.effect(() => registerConfigRoutes(ctx, {
|
|
677
|
+
NS,
|
|
678
|
+
live,
|
|
679
|
+
getSettingsService: () => settingsService,
|
|
680
|
+
getScope: () => scope,
|
|
681
|
+
validateConfig: (val) => BaseConfig(structuredClone(val)),
|
|
682
|
+
triggerAutostart,
|
|
683
|
+
}), 'dsh-voice: config route')
|
|
667
684
|
}
|
|
668
685
|
|
|
@@ -41,6 +41,13 @@ export function findModelFile(dir) {
|
|
|
41
41
|
return null
|
|
42
42
|
}
|
|
43
43
|
|
|
44
|
+
export function redactModelPath(filePath) {
|
|
45
|
+
if (!filePath) return ""
|
|
46
|
+
const str = String(filePath).replace(/\\/g, "/")
|
|
47
|
+
const idx = str.indexOf(".dsh/models/")
|
|
48
|
+
if (idx !== -1) return "~/" + str.slice(idx)
|
|
49
|
+
return path.basename(str)
|
|
50
|
+
}
|
|
44
51
|
export function findTokensFile(dir) {
|
|
45
52
|
if (!dir || !existsSync(dir)) return null
|
|
46
53
|
try {
|
|
@@ -68,8 +75,9 @@ export async function getSensevoiceStatus(cfg, isAliveFn) {
|
|
|
68
75
|
return {
|
|
69
76
|
installed,
|
|
70
77
|
running,
|
|
71
|
-
modelPath: modelFile
|
|
72
|
-
tokensPath: tokensFile
|
|
78
|
+
modelPath: redactModelPath(modelFile),
|
|
79
|
+
tokensPath: redactModelPath(tokensFile),
|
|
80
|
+
path: installed ? '~/.dsh/models/sensevoice' : '',
|
|
73
81
|
installing: installerState.installing,
|
|
74
82
|
progress: installerState.progress,
|
|
75
83
|
step: installerState.step,
|
|
@@ -189,6 +197,11 @@ export function registerSensevoiceInstaller(ctx, { liveConfig, isAlive, updateCo
|
|
|
189
197
|
kind: 'exact',
|
|
190
198
|
path: '/dsh-voice/sensevoice-installer',
|
|
191
199
|
handler: async (req, res) => {
|
|
200
|
+
if (!isTrustedCaller(req)) {
|
|
201
|
+
writeJson(res, 403, { ok: false, error: 'Forbidden: untrusted caller' })
|
|
202
|
+
return
|
|
203
|
+
}
|
|
204
|
+
|
|
192
205
|
if (req.method === 'GET') {
|
|
193
206
|
const status = await getSensevoiceStatus(liveConfig(), isAlive)
|
|
194
207
|
writeJson(res, 200, status)
|
|
@@ -196,13 +209,9 @@ export function registerSensevoiceInstaller(ctx, { liveConfig, isAlive, updateCo
|
|
|
196
209
|
}
|
|
197
210
|
|
|
198
211
|
if (req.method === 'POST') {
|
|
199
|
-
|
|
200
|
-
writeJson(res, 403, { ok: false, error: 'Forbidden: untrusted caller' })
|
|
201
|
-
return
|
|
202
|
-
}
|
|
203
|
-
|
|
212
|
+
const safeState = { ...installerState, modelPath: redactModelPath(installerState.modelPath) }
|
|
204
213
|
if (installerState.installing) {
|
|
205
|
-
writeJson(res, 409, { ok: false, error: 'Installation already in progress', state:
|
|
214
|
+
writeJson(res, 409, { ok: false, error: 'Installation already in progress', state: safeState })
|
|
206
215
|
return
|
|
207
216
|
}
|
|
208
217
|
|
|
@@ -211,7 +220,7 @@ export function registerSensevoiceInstaller(ctx, { liveConfig, isAlive, updateCo
|
|
|
211
220
|
ctx.logger?.warn?.(`SenseVoice 1-click install error: ${String(err)}`)
|
|
212
221
|
})
|
|
213
222
|
|
|
214
|
-
writeJson(res, 202, { ok: true, message: 'SenseVoice installation started', state:
|
|
223
|
+
writeJson(res, 202, { ok: true, message: 'SenseVoice installation started', state: safeState })
|
|
215
224
|
return
|
|
216
225
|
}
|
|
217
226
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@goodandready/dsh-voice",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.9.1",
|
|
4
4
|
"description": "Voice input for DeepSeek Harness: dictation chunked by pauses and voice messages, each with its own provider fallback chain (Deepgram, Groq, HuggingFace, local whisper.cpp, plus any OpenAI-compatible API of your own).",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|