@goodandready/dsh-voice 0.9.0 → 0.9.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -1
- package/README.ru.md +1 -1
- package/README.zh.md +6 -0
- package/lib/audio-guard.js +133 -0
- package/lib/client.js +46 -17
- package/lib/http-util.js +3 -2
- package/lib/index.js +15 -10
- package/lib/normalize.js +7 -7
- package/lib/sensevoice-installer.js +21 -10
- package/package.json +2 -1
package/README.md
CHANGED
|
@@ -211,7 +211,7 @@ 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 }`
|
|
@@ -219,6 +219,7 @@ Registers `transcribe_audio(file_path, language?)` in `ctx.tools`, allowing agen
|
|
|
219
219
|
* `GET /dsh-voice/status` — Returns daemon status, active chains, SenseVoice.
|
|
220
220
|
* `GET /dsh-voice/config` — Returns live plugin configuration snapshot.
|
|
221
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).
|
|
222
223
|
|
|
223
224
|
---
|
|
224
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
|
@@ -131,11 +131,17 @@ v0.8.18 质量审查后的修复批次(Gitea #79–#87,PR #88):
|
|
|
131
131
|
|
|
132
132
|
## 🤖 智能体工具与 HTTP 端点
|
|
133
133
|
|
|
134
|
+
### 智能体工具 (`transcribe_audio`)
|
|
135
|
+
在 `ctx.tools` 中注册 `transcribe_audio(file_path, language?)`,允许智能体直接读取和转写本地音频文件。包含严格的目录边界检查(`allowedAudioDirs`、`~/.dsh`、`tmpdir`、`cwd`)、符号链接越界防护以及音频特征码(magic bytes)校验。
|
|
136
|
+
|
|
137
|
+
### 内部 HTTP 端点
|
|
138
|
+
|
|
134
139
|
* `POST /dsh-voice/transcribe` — 音频转写:`{ dataBase64, mimeType, mode }` → `{ ok, text, provider, tookMs }`
|
|
135
140
|
* `POST /dsh-voice/polish` — 文本模型润色:`{ text }` → `{ ok, text }`
|
|
136
141
|
* `GET /dsh-voice/status` — 查询后端状态、服务商健康度与 SenseVoice
|
|
137
142
|
* `GET /dsh-voice/config` — 获取插件运行时配置快照
|
|
138
143
|
* `PUT /dsh-voice/config` — 跨网络安全持久化更新插件配置
|
|
144
|
+
* `GET/POST /dsh-voice/sensevoice-installer` — SenseVoice 一键模型安装状态与触发接口(受 `isTrustedCaller` 保护,绝对路径脱敏)
|
|
139
145
|
|
|
140
146
|
---
|
|
141
147
|
|
|
@@ -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
|
|
@@ -545,15 +547,15 @@ window.__ModuleLoader__.load({
|
|
|
545
547
|
const trimmed = String(text || '').trim().toLowerCase()
|
|
546
548
|
const { buttons, input } = promptInfo
|
|
547
549
|
if (buttons && buttons.length > 0) {
|
|
548
|
-
if (/^(1|первый|first)$/i.test(trimmed) && buttons[0]) { buttons[0].click(); return true }
|
|
549
|
-
if (/^(2|второй|second)$/i.test(trimmed) && buttons[1]) { buttons[1].click(); return true }
|
|
550
|
-
if (/^(3|третий|third)$/i.test(trimmed) && buttons[2]) { buttons[2].click(); return true }
|
|
551
|
-
if (/^(да|yes|ok|confirm
|
|
552
|
-
const yesBtn = buttons.find((b) => /да|yes|ok|confirm
|
|
550
|
+
if (/^(1|первый|first|第一)$/i.test(trimmed) && buttons[0]) { buttons[0].click(); return true }
|
|
551
|
+
if (/^(2|второй|second|第二)$/i.test(trimmed) && buttons[1]) { buttons[1].click(); return true }
|
|
552
|
+
if (/^(3|третий|third|第三)$/i.test(trimmed) && buttons[2]) { buttons[2].click(); return true }
|
|
553
|
+
if (/^(да|yes|ok|confirm|согласен|是|好|确认|同意)$/i.test(trimmed)) {
|
|
554
|
+
const yesBtn = buttons.find((b) => /да|yes|ok|confirm|是|好|确认|同意/i.test(b.textContent || ''))
|
|
553
555
|
if (yesBtn) { yesBtn.click(); return true }
|
|
554
556
|
}
|
|
555
|
-
if (/^(нет|no|отмена|cancel)$/i.test(trimmed)) {
|
|
556
|
-
const noBtn = buttons.find((b) => /нет|no|cancel
|
|
557
|
+
if (/^(нет|no|отмена|cancel|不|否|取消)$/i.test(trimmed)) {
|
|
558
|
+
const noBtn = buttons.find((b) => /нет|no|cancel|отмена|不|否|取消/i.test(b.textContent || ''))
|
|
557
559
|
if (noBtn) { noBtn.click(); return true }
|
|
558
560
|
}
|
|
559
561
|
}
|
|
@@ -695,8 +697,8 @@ window.__ModuleLoader__.load({
|
|
|
695
697
|
if (!res.ok || !parsed || !parsed.ok) {
|
|
696
698
|
throw new Error((parsed && parsed.error && parsed.error.message) || `HTTP ${res.status}`)
|
|
697
699
|
}
|
|
698
|
-
if (parsed.command) return { command: parsed.command, text: '' }
|
|
699
|
-
return { text: String(parsed.text || '').trim(), command: null }
|
|
700
|
+
if (parsed.command) return { command: parsed.command, text: '', action: null }
|
|
701
|
+
return { text: String(parsed.text || '').trim(), command: null, action: parsed.action || null }
|
|
700
702
|
}
|
|
701
703
|
|
|
702
704
|
function tidyPhrase(text) {
|
|
@@ -891,7 +893,10 @@ window.__ModuleLoader__.load({
|
|
|
891
893
|
chainsPromise = fetch('/dsh-voice/status', { cache: 'no-store' })
|
|
892
894
|
.then((res) => res.json())
|
|
893
895
|
.then((data) => (data && data.modes) || {})
|
|
894
|
-
.catch(() =>
|
|
896
|
+
.catch(() => {
|
|
897
|
+
chainsPromise = null
|
|
898
|
+
return {}
|
|
899
|
+
})
|
|
895
900
|
}
|
|
896
901
|
return chainsPromise.then((modes) => {
|
|
897
902
|
const row = modes[mode] || {}
|
|
@@ -2185,6 +2190,7 @@ window.__ModuleLoader__.load({
|
|
|
2185
2190
|
const [err, setErr] = React.useState('')
|
|
2186
2191
|
const [serverConfig, setServerConfig] = React.useState(null)
|
|
2187
2192
|
const [configLoading, setConfigLoading] = React.useState(true)
|
|
2193
|
+
const [jargonInput, setJargonInput] = React.useState(null)
|
|
2188
2194
|
|
|
2189
2195
|
React.useEffect(() => {
|
|
2190
2196
|
let alive = true
|
|
@@ -2503,6 +2509,21 @@ window.__ModuleLoader__.load({
|
|
|
2503
2509
|
const save = async () => {
|
|
2504
2510
|
setErr(''); setSaved(false)
|
|
2505
2511
|
if (!draft) return
|
|
2512
|
+
if (jargonInput !== null) {
|
|
2513
|
+
const lines = jargonInput.split('\n')
|
|
2514
|
+
const parsed = lines
|
|
2515
|
+
.map((line) => {
|
|
2516
|
+
const parts = line.split(/->|=|:/)
|
|
2517
|
+
if (parts.length >= 2) {
|
|
2518
|
+
const from = parts[0].trim()
|
|
2519
|
+
const to = parts.slice(1).join(':').trim()
|
|
2520
|
+
if (from && to) return { from, to }
|
|
2521
|
+
}
|
|
2522
|
+
return null
|
|
2523
|
+
})
|
|
2524
|
+
.filter(Boolean)
|
|
2525
|
+
draft.jargonDictionary = parsed
|
|
2526
|
+
}
|
|
2506
2527
|
|
|
2507
2528
|
let httpOk = false
|
|
2508
2529
|
let httpError = null
|
|
@@ -3025,17 +3046,25 @@ window.__ModuleLoader__.load({
|
|
|
3025
3046
|
React.createElement('textarea', {
|
|
3026
3047
|
rows: 3,
|
|
3027
3048
|
disabled: !writable,
|
|
3028
|
-
placeholder: '
|
|
3029
|
-
value:
|
|
3030
|
-
?
|
|
3031
|
-
:
|
|
3049
|
+
placeholder: t('jargonPlaceholder'),
|
|
3050
|
+
value: jargonInput !== null
|
|
3051
|
+
? jargonInput
|
|
3052
|
+
: (Array.isArray(draft && draft.jargonDictionary)
|
|
3053
|
+
? draft.jargonDictionary.map((item) => item && item.from ? `${item.from} -> ${item.to}` : '').filter(Boolean).join('\n')
|
|
3054
|
+
: ''),
|
|
3032
3055
|
onChange: (e) => {
|
|
3033
|
-
|
|
3056
|
+
setJargonInput(e.target.value)
|
|
3057
|
+
},
|
|
3058
|
+
onBlur: () => {
|
|
3059
|
+
if (jargonInput === null) return
|
|
3060
|
+
const lines = jargonInput.split('\n')
|
|
3034
3061
|
const parsed = lines
|
|
3035
3062
|
.map((line) => {
|
|
3036
3063
|
const parts = line.split(/->|=|:/)
|
|
3037
3064
|
if (parts.length >= 2) {
|
|
3038
|
-
|
|
3065
|
+
const from = parts[0].trim()
|
|
3066
|
+
const to = parts.slice(1).join(':').trim()
|
|
3067
|
+
if (from && to) return { from, to }
|
|
3039
3068
|
}
|
|
3040
3069
|
return null
|
|
3041
3070
|
})
|
|
@@ -3108,7 +3137,7 @@ window.__ModuleLoader__.load({
|
|
|
3108
3137
|
React.createElement('div', { className: 'cb-section-desc' }, t('versionHint')),
|
|
3109
3138
|
React.createElement('div', { className: 'cb-row', style: { alignItems: 'center', gap: '12px' } },
|
|
3110
3139
|
React.createElement('span', { className: 'cb-badge cb-badge-ok' },
|
|
3111
|
-
'v' + ((updaterStatus && updaterStatus.currentVersion) || '0.9.
|
|
3140
|
+
'v' + ((updaterStatus && updaterStatus.currentVersion) || '0.9.2')
|
|
3112
3141
|
),
|
|
3113
3142
|
updaterStatus && updaterStatus.updateAvailable
|
|
3114
3143
|
? React.createElement('span', { className: 'cb-badge cb-badge-warn' },
|
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
|
@@ -27,13 +27,11 @@ import { createPolishText } from './polish.js'
|
|
|
27
27
|
import { createUserMessage } from '@deepseek-ai/dsh-llm'
|
|
28
28
|
import { buildProviderOrder, sessionCommand } from './transcribe-core.js'
|
|
29
29
|
import { registerPluginUpdater } from './updater.js'
|
|
30
|
-
import { registerSensevoiceInstaller } from './sensevoice-installer.js'
|
|
30
|
+
import { registerSensevoiceInstaller, findTokensFile } from './sensevoice-installer.js'
|
|
31
31
|
import { createConfigReader } from './config-compat.js'
|
|
32
32
|
import { registerConfigRoutes } from './config-routes.js'
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
return !lang || lang === 'auto' || String(lang).includes(',')
|
|
36
|
-
}
|
|
33
|
+
import { getAllowedAudioRoots, validateAudioPath, validateAudioContent } from './audio-guard.js'
|
|
34
|
+
import { isAutoLang } from './provider-http.js'
|
|
37
35
|
|
|
38
36
|
export const name = '@goodandready/dsh-voice'
|
|
39
37
|
export const NS = 'dsh-voice'
|
|
@@ -136,6 +134,7 @@ const BaseConfig = z.object({
|
|
|
136
134
|
ffmpegBin: z.string().default('ffmpeg')
|
|
137
135
|
.description('ffmpeg used to convert browser webm/opus into the WAV that whisper.cpp requires.'),
|
|
138
136
|
timeoutMs: z.number().default(120000),
|
|
137
|
+
allowedAudioDirs: z.array(z.string()).default([]),
|
|
139
138
|
maxFileBytes: z.number().default(25 * 1024 * 1024),
|
|
140
139
|
normalizeTranscript: z.boolean().default(false)
|
|
141
140
|
.description('transcribe_audio: convert spoken numbers to digits and tidy punctuation.'),
|
|
@@ -322,9 +321,12 @@ export function apply(ctx, baseConfig) {
|
|
|
322
321
|
try {
|
|
323
322
|
let port = '6006'
|
|
324
323
|
try { port = new URL(cfg.sensevoiceUrl).port || '6006' } catch { /* invalid URL */ }
|
|
324
|
+
const tokensPath = findTokensFile(path.dirname(cfg.sensevoiceModel))
|
|
325
|
+
const tokensArg = tokensPath ? ` --tokens=${JSON.stringify(tokensPath)}` : ''
|
|
325
326
|
const spec = ctx.shell.resolve({
|
|
326
327
|
command: `${JSON.stringify(cfg.sensevoiceBin)}`
|
|
327
328
|
+ ` --sense-voice-model=${JSON.stringify(cfg.sensevoiceModel)}`
|
|
329
|
+
+ tokensArg
|
|
328
330
|
+ ` --port=${port} --num-threads=4`,
|
|
329
331
|
timeoutMs: 0,
|
|
330
332
|
stdoutMaxBytes: 4 * 1024 * 1024,
|
|
@@ -593,7 +595,7 @@ export function apply(ctx, baseConfig) {
|
|
|
593
595
|
},
|
|
594
596
|
}), 'dsh-voice: /transcribe route')
|
|
595
597
|
|
|
596
|
-
ctx.tools.register(
|
|
598
|
+
ctx.effect(() => ctx.tools.register(
|
|
597
599
|
defineTool({
|
|
598
600
|
name: 'transcribe_audio',
|
|
599
601
|
description:
|
|
@@ -624,14 +626,17 @@ export function apply(ctx, baseConfig) {
|
|
|
624
626
|
const cfg = live()
|
|
625
627
|
const filePath = String(args.file_path || '').trim()
|
|
626
628
|
if (!filePath) throw new Error('transcribe_audio: file_path is required')
|
|
627
|
-
const
|
|
629
|
+
const allowedRoots = getAllowedAudioRoots(cfg)
|
|
630
|
+
const { real } = await validateAudioPath(filePath, allowedRoots)
|
|
631
|
+
const info = await stat(real).catch(() => null)
|
|
628
632
|
if (!info) throw new Error(`transcribe_audio: file not found: ${filePath}`)
|
|
629
633
|
if (info.size > cfg.maxFileBytes) {
|
|
630
634
|
throw new Error(`transcribe_audio: file too large (${info.size} bytes, max ${cfg.maxFileBytes})`)
|
|
631
635
|
}
|
|
632
636
|
if (info.size < 44) throw new Error('transcribe_audio: file is empty or too small')
|
|
633
|
-
const
|
|
634
|
-
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'
|
|
635
640
|
const modeCfg = { ...cfg.message, language: String(args.language || cfg.message.language) }
|
|
636
641
|
let raw = await transcribe(modeCfg, bytes, mime, exec.signal)
|
|
637
642
|
if (raw && raw.text) raw = { ...raw, text: await polishText(raw.text, modeCfg, exec.signal) }
|
|
@@ -644,7 +649,7 @@ export function apply(ctx, baseConfig) {
|
|
|
644
649
|
return out
|
|
645
650
|
},
|
|
646
651
|
}),
|
|
647
|
-
)
|
|
652
|
+
), 'dsh-voice: transcribe_audio tool')
|
|
648
653
|
|
|
649
654
|
ctx.effect(() => () => {
|
|
650
655
|
if (child) { try { child.kill && child.kill() } catch { /* already dead */ } }
|
package/lib/normalize.js
CHANGED
|
@@ -128,28 +128,28 @@ export function extractVoiceActions(text) {
|
|
|
128
128
|
if (!raw) return { text: '', action: null }
|
|
129
129
|
|
|
130
130
|
const lower = raw.toLowerCase().replace(/[.!?…]+$/, '').trim()
|
|
131
|
-
if (/^(отправь|отправить|send|send it|send message)$/i.test(lower)) {
|
|
131
|
+
if (/^(отправь|отправить|send|send it|send message|发送|发出)$/i.test(lower)) {
|
|
132
132
|
return { text: '', action: 'send' }
|
|
133
133
|
}
|
|
134
|
-
if (/^(отмена|cancel|discard)$/i.test(lower)) {
|
|
134
|
+
if (/^(отмена|cancel|discard|取消|算了)$/i.test(lower)) {
|
|
135
135
|
return { text: '', action: 'cancel' }
|
|
136
136
|
}
|
|
137
|
-
if (/^(очисти|очистить|clear|clear composer)$/i.test(lower)) {
|
|
137
|
+
if (/^(очисти|очистить|clear|clear composer|清空|清除)$/i.test(lower)) {
|
|
138
138
|
return { text: '', action: 'clear' }
|
|
139
139
|
}
|
|
140
|
-
if (/^(новая строка|с новой строки|new line|newline)$/i.test(lower)) {
|
|
140
|
+
if (/^(новая строка|с новой строки|new line|newline|换行|新行)$/i.test(lower)) {
|
|
141
141
|
return { text: '', action: 'newline' }
|
|
142
142
|
}
|
|
143
143
|
|
|
144
|
-
const trailingSend = /[\s,]+(отправь|отправить|send|send it)[.!?…]*$/i
|
|
144
|
+
const trailingSend = /[\s,]+(отправь|отправить|send|send it|发送|发出)[.!?…]*$/i
|
|
145
145
|
if (trailingSend.test(raw)) {
|
|
146
146
|
return { text: raw.replace(trailingSend, '').trim(), action: 'send' }
|
|
147
147
|
}
|
|
148
|
-
const trailingCancel = /[\s,]+(отмена|cancel)[.!?…]*$/i
|
|
148
|
+
const trailingCancel = /[\s,]+(отмена|cancel|取消|算了)[.!?…]*$/i
|
|
149
149
|
if (trailingCancel.test(raw)) {
|
|
150
150
|
return { text: raw.replace(trailingCancel, '').trim(), action: 'cancel' }
|
|
151
151
|
}
|
|
152
|
-
const trailingNewline = /[\s,]+(новая строка|с новой строки|new line)[.!?…]*$/i
|
|
152
|
+
const trailingNewline = /[\s,]+(новая строка|с новой строки|new line|换行|新行)[.!?…]*$/i
|
|
153
153
|
if (trailingNewline.test(raw)) {
|
|
154
154
|
return { text: raw.replace(trailingNewline, '').trim() + '\n', action: 'newline' }
|
|
155
155
|
}
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// Downloads pre-quantized ONNX model and configures autostart on the host.
|
|
3
3
|
|
|
4
4
|
import { createWriteStream, existsSync, readdirSync } from 'node:fs'
|
|
5
|
-
import { mkdir, stat } from 'node:fs/promises'
|
|
5
|
+
import { mkdir, stat, unlink } from 'node:fs/promises'
|
|
6
6
|
import { homedir } from 'node:os'
|
|
7
7
|
import path from 'node:path'
|
|
8
8
|
import { spawn } from 'node:child_process'
|
|
@@ -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,
|
|
@@ -153,6 +161,7 @@ export async function startSensevoiceInstall(ctx, cfg, updateConfigFn, startSens
|
|
|
153
161
|
installerState.step = 'extracting'
|
|
154
162
|
installerState.progress = 95
|
|
155
163
|
await extractTarBz2(archivePath, modelDir)
|
|
164
|
+
await unlink(archivePath).catch(() => {})
|
|
156
165
|
|
|
157
166
|
const modelFile = findModelFile(modelDir)
|
|
158
167
|
if (!modelFile) throw new Error('model.int8.onnx not found after extraction')
|
|
@@ -175,6 +184,7 @@ export async function startSensevoiceInstall(ctx, cfg, updateConfigFn, startSens
|
|
|
175
184
|
|
|
176
185
|
return { ok: true, modelPath: modelFile }
|
|
177
186
|
} catch (err) {
|
|
187
|
+
await unlink(archivePath).catch(() => {})
|
|
178
188
|
installerState.installing = false
|
|
179
189
|
installerState.error = String(err?.message || err)
|
|
180
190
|
installerState.step = 'failed'
|
|
@@ -189,6 +199,11 @@ export function registerSensevoiceInstaller(ctx, { liveConfig, isAlive, updateCo
|
|
|
189
199
|
kind: 'exact',
|
|
190
200
|
path: '/dsh-voice/sensevoice-installer',
|
|
191
201
|
handler: async (req, res) => {
|
|
202
|
+
if (!isTrustedCaller(req)) {
|
|
203
|
+
writeJson(res, 403, { ok: false, error: 'Forbidden: untrusted caller' })
|
|
204
|
+
return
|
|
205
|
+
}
|
|
206
|
+
|
|
192
207
|
if (req.method === 'GET') {
|
|
193
208
|
const status = await getSensevoiceStatus(liveConfig(), isAlive)
|
|
194
209
|
writeJson(res, 200, status)
|
|
@@ -196,13 +211,9 @@ export function registerSensevoiceInstaller(ctx, { liveConfig, isAlive, updateCo
|
|
|
196
211
|
}
|
|
197
212
|
|
|
198
213
|
if (req.method === 'POST') {
|
|
199
|
-
|
|
200
|
-
writeJson(res, 403, { ok: false, error: 'Forbidden: untrusted caller' })
|
|
201
|
-
return
|
|
202
|
-
}
|
|
203
|
-
|
|
214
|
+
const safeState = { ...installerState, modelPath: redactModelPath(installerState.modelPath) }
|
|
204
215
|
if (installerState.installing) {
|
|
205
|
-
writeJson(res, 409, { ok: false, error: 'Installation already in progress', state:
|
|
216
|
+
writeJson(res, 409, { ok: false, error: 'Installation already in progress', state: safeState })
|
|
206
217
|
return
|
|
207
218
|
}
|
|
208
219
|
|
|
@@ -211,7 +222,7 @@ export function registerSensevoiceInstaller(ctx, { liveConfig, isAlive, updateCo
|
|
|
211
222
|
ctx.logger?.warn?.(`SenseVoice 1-click install error: ${String(err)}`)
|
|
212
223
|
})
|
|
213
224
|
|
|
214
|
-
writeJson(res, 202, { ok: true, message: 'SenseVoice installation started', state:
|
|
225
|
+
writeJson(res, 202, { ok: true, message: 'SenseVoice installation started', state: safeState })
|
|
215
226
|
return
|
|
216
227
|
}
|
|
217
228
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@goodandready/dsh-voice",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.2",
|
|
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",
|
|
@@ -43,6 +43,7 @@
|
|
|
43
43
|
"scripts": {
|
|
44
44
|
"test": "node --test test/*.test.mjs",
|
|
45
45
|
"build:client": "node scripts/build-client.mjs",
|
|
46
|
+
"build:check": "node scripts/build-client.mjs --check",
|
|
46
47
|
"pretest": "node scripts/build-client.mjs",
|
|
47
48
|
"lint": "node --check lib/*.js scripts/*.mjs",
|
|
48
49
|
"pack:check": "node scripts/pack-check.mjs"
|