@goodandready/dsh-voice 0.8.18 → 0.8.19
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 +20 -0
- package/README.ru.md +20 -0
- package/README.zh.md +16 -0
- package/lib/chain.js +4 -4
- package/lib/client-src/00-open.js +24 -0
- package/lib/client-src/10-locale.js +148 -0
- package/lib/client-src/20-css.js +39 -0
- package/lib/client-src/30-core.js +284 -0
- package/lib/client-src/40-recording.js +384 -0
- package/lib/client-src/41-buttons.js +30 -0
- package/lib/client-src/50-visualizers.js +121 -0
- package/lib/client-src/60-composer.js +285 -0
- package/lib/client-src/70-settings.js +640 -0
- package/lib/client-src/90-close.js +23 -0
- package/lib/client.js +218 -334
- package/lib/index.js +41 -42
- package/lib/normalize.js +2 -2
- package/lib/providers.js +39 -43
- package/lib/stats.js +2 -2
- package/lib/wav.js +8 -8
- package/package.json +4 -2
|
@@ -0,0 +1,640 @@
|
|
|
1
|
+
// ------------------------------------------------------- settings page
|
|
2
|
+
const BUILTIN = [
|
|
3
|
+
'browser', 'deepgram', 'groq', 'hf', 'local-whisper', 'sensevoice',
|
|
4
|
+
// Presets: address and model are filled on the host; only a key is needed.
|
|
5
|
+
'openai', 'siliconflow', 'deepinfra', 'fireworks', 'mistral', 'openrouter',
|
|
6
|
+
]
|
|
7
|
+
const TEMPLATES = ['openai-transcriptions', 'openai-chat-audio']
|
|
8
|
+
// Resolve at render time: t() must not be captured before locale bind.
|
|
9
|
+
const MODEL_HINT_KEYS = {
|
|
10
|
+
browser: 'browserHint',
|
|
11
|
+
openai: 'openaiHint',
|
|
12
|
+
siliconflow: 'siliconflowHint',
|
|
13
|
+
deepinfra: 'deepinfraHint',
|
|
14
|
+
fireworks: 'fireworksHint',
|
|
15
|
+
mistral: 'mistralHint',
|
|
16
|
+
openrouter: 'openrouterHint',
|
|
17
|
+
'local-whisper': 'localHint',
|
|
18
|
+
sensevoice: 'sensevoiceHint',
|
|
19
|
+
}
|
|
20
|
+
const MODEL_HINT_STATIC = {
|
|
21
|
+
deepgram: 'nova-2',
|
|
22
|
+
groq: 'whisper-large-v3-turbo',
|
|
23
|
+
hf: 'openai/whisper-large-v3',
|
|
24
|
+
}
|
|
25
|
+
function modelHint(provider) {
|
|
26
|
+
const key = MODEL_HINT_KEYS[provider]
|
|
27
|
+
if (key) return t(key)
|
|
28
|
+
return MODEL_HINT_STATIC[provider] || ''
|
|
29
|
+
}
|
|
30
|
+
const LANGS = ['auto', 'ru', 'en', 'uk', 'de']
|
|
31
|
+
|
|
32
|
+
const SET_CSS =
|
|
33
|
+
'.dvs-wrap{display:flex;flex-direction:column;gap:22px;padding:4px 0;max-width:720px}' +
|
|
34
|
+
'.dvs-block{display:flex;flex-direction:column;gap:10px}' +
|
|
35
|
+
'.dvs-h{font-size:14px;font-weight:600;color:var(--dsw-alias-label-primary)}' +
|
|
36
|
+
'.dvs-sub{font-size:12px;color:var(--dsw-alias-label-secondary)}' +
|
|
37
|
+
'.dvs-row{display:flex;gap:8px;align-items:center}' +
|
|
38
|
+
'.dvs-row select,.dvs-row input{background:var(--dsw-alias-bg-layer-1);border:1px solid var(--dsw-alias-border-l1);color:var(--dsw-alias-label-primary);border-radius:6px;padding:6px 8px;font-size:13px}' +
|
|
39
|
+
'.dvs-row .dvs-model{flex:1}' +
|
|
40
|
+
'.dvs-field{display:flex;flex-direction:column;gap:6px;padding:12px 0;font-size:12px;color:var(--dsw-alias-label-secondary)}' +
|
|
41
|
+
'.dvs-field input,.dvs-field select{height:34px;border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-3);color:var(--dsw-alias-label-primary);border-radius:8px;padding:0 12px;font-size:13px}' +
|
|
42
|
+
'.dvs-mini{border:1px solid var(--dsw-alias-border-l2);background:transparent;color:var(--dsw-alias-label-primary);border-radius:6px;width:28px;height:28px;cursor:pointer;flex:none}' +
|
|
43
|
+
'.dvs-save{appearance:none;font:inherit;cursor:pointer;border:1px solid transparent;border-radius:8px;padding:5px 14px;font-size:13px;background:var(--dsw-alias-label-primary);color:var(--dsw-alias-bg-layer-3)}' +
|
|
44
|
+
'.dvs-card{display:flex;flex-direction:column;gap:6px;border:1px solid var(--dsw-alias-border-l2);border-radius:8px;padding:10px}' +
|
|
45
|
+
'.dvs-card input,.dvs-card select{background:var(--dsw-alias-bg-layer-1);border:1px solid var(--dsw-alias-border-l1);color:var(--dsw-alias-label-primary);border-radius:6px;padding:6px 8px;font-size:13px}' +
|
|
46
|
+
'.dvs-wait{font-size:13px;color:var(--dsw-alias-label-secondary);line-height:1.5;max-width:520px}' +
|
|
47
|
+
'.dvs-ok{font-size:12px;color:var(--dsw-alias-state-success-primary)}' +
|
|
48
|
+
'.dvs-bad{font-size:12px;color:var(--dsw-alias-state-error-primary)}' +
|
|
49
|
+
'.dvo-pcard{list-style:none;border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-3);border-radius:12px}' +
|
|
50
|
+
'.dvo-phead{appearance:none;width:100%;font:inherit;color:inherit;text-align:left;cursor:pointer;background:0 0;border:0;border-radius:12px;display:flex;align-items:center;gap:12px;padding:14px 16px}' +
|
|
51
|
+
'.dvo-pheadtext{display:flex;flex-direction:column;gap:2px;flex:1;min-width:0}' +
|
|
52
|
+
'.dvo-ptitle{color:var(--dsw-alias-label-primary);font-size:15px;font-weight:600;line-height:1.4}' +
|
|
53
|
+
'.dvo-pdesc{color:var(--dsw-alias-label-secondary);font-size:13px}' +
|
|
54
|
+
'.dvo-pchev{flex:none;display:flex;color:var(--dsw-alias-label-secondary);transition:transform .15s ease}' +
|
|
55
|
+
'.dvo-pcardOpen .dvo-pchev{transform:rotate(180deg)}' +
|
|
56
|
+
'.dvo-pbody{border-top:1px solid var(--dsw-alias-border-l2);margin:0 16px;padding-bottom:8px}'
|
|
57
|
+
const setCssId = 'dsh-voice/settings.module.css'
|
|
58
|
+
if (typeof document !== 'undefined' && !document.querySelector('style[data-dsh-plugin="dsh-voice"][data-plugin-css="' + setCssId + '"]')) {
|
|
59
|
+
const tag = document.createElement('style')
|
|
60
|
+
tag.textContent = SET_CSS
|
|
61
|
+
tag.setAttribute('data-dsh-plugin', 'dsh-voice')
|
|
62
|
+
tag.dataset.pluginCss = setCssId
|
|
63
|
+
document.head.appendChild(tag)
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// One chain editor: provider+model rows with reordering.
|
|
67
|
+
function ChainEditor(props) {
|
|
68
|
+
const rows = Array.isArray(props.value) ? props.value : []
|
|
69
|
+
const change = (i, patch) => {
|
|
70
|
+
const next = rows.map((r, k) => (k === i ? Object.assign({}, r, patch) : r))
|
|
71
|
+
props.onChange(next)
|
|
72
|
+
}
|
|
73
|
+
const move = (i, delta) => {
|
|
74
|
+
const j = i + delta
|
|
75
|
+
if (j < 0 || j >= rows.length) return
|
|
76
|
+
const next = rows.slice()
|
|
77
|
+
const tmp = next[i]; next[i] = next[j]; next[j] = tmp
|
|
78
|
+
props.onChange(next)
|
|
79
|
+
}
|
|
80
|
+
const remove = (i) => props.onChange(rows.filter((_, k) => k !== i))
|
|
81
|
+
const add = () => props.onChange(rows.concat([{ provider: 'local-whisper', model: '' }]))
|
|
82
|
+
const options = Array.isArray(props.options) && props.options.length ? props.options : BUILTIN
|
|
83
|
+
|
|
84
|
+
return React.createElement('div', { className: 'dvs-block' },
|
|
85
|
+
rows.map((row, i) => React.createElement('div', { className: 'dvs-row', key: i },
|
|
86
|
+
React.createElement('select', {
|
|
87
|
+
value: row.provider, disabled: !props.writable,
|
|
88
|
+
onChange: (e) => change(i, { provider: e.target.value }),
|
|
89
|
+
}, (options.indexOf(row.provider) < 0 ? options.concat([row.provider]) : options)
|
|
90
|
+
.map((p) => React.createElement('option', { key: p, value: p }, p))),
|
|
91
|
+
React.createElement('input', {
|
|
92
|
+
className: 'dvs-model', value: row.model || '', disabled: !props.writable,
|
|
93
|
+
placeholder: modelHint(row.provider), onChange: (e) => change(i, { model: e.target.value }),
|
|
94
|
+
}),
|
|
95
|
+
React.createElement('button', { type: 'button', className: 'dvs-mini', title: t('up'), disabled: !props.writable, onClick: () => move(i, -1) }, '↑'),
|
|
96
|
+
React.createElement('button', { type: 'button', className: 'dvs-mini', title: t('down'), disabled: !props.writable, onClick: () => move(i, 1) }, '↓'),
|
|
97
|
+
React.createElement('button', { type: 'button', className: 'dvs-mini', title: t('remove'), disabled: !props.writable, onClick: () => remove(i) }, '×'),
|
|
98
|
+
)),
|
|
99
|
+
React.createElement('div', { className: 'dvs-row' },
|
|
100
|
+
React.createElement('button', { type: 'button', className: 'dvs-mini', title: t('addProvider'), disabled: !props.writable, onClick: add }, '+'),
|
|
101
|
+
React.createElement('span', { className: 'dvs-sub' }, t('chainHint')),
|
|
102
|
+
),
|
|
103
|
+
)
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Custom providers: name, API template, where to call, how to authorize.
|
|
107
|
+
function CustomEditor(props) {
|
|
108
|
+
const rows = Array.isArray(props.value) ? props.value : []
|
|
109
|
+
const change = (i, patch) => props.onChange(rows.map((r, k) => (k === i ? Object.assign({}, r, patch) : r)))
|
|
110
|
+
const remove = (i) => props.onChange(rows.filter((_, k) => k !== i))
|
|
111
|
+
const add = () => props.onChange(rows.concat([
|
|
112
|
+
{ key: '', template: 'openai-transcriptions', baseURL: '', model: '', keyEnv: '', prompt: '' },
|
|
113
|
+
]))
|
|
114
|
+
const field = (i, row, name, placeholder, wide) => React.createElement('input', {
|
|
115
|
+
className: wide ? 'dvs-model' : '', value: row[name] || '', placeholder: placeholder,
|
|
116
|
+
disabled: !props.writable, onChange: (e) => change(i, { [name]: e.target.value }),
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
return React.createElement('div', { className: 'dvs-block' },
|
|
120
|
+
rows.map((row, i) => React.createElement('div', { className: 'dvs-card', key: i },
|
|
121
|
+
React.createElement('div', { className: 'dvs-row' },
|
|
122
|
+
field(i, row, 'key', t('customName')),
|
|
123
|
+
React.createElement('select', {
|
|
124
|
+
value: row.template || 'openai-transcriptions', disabled: !props.writable,
|
|
125
|
+
onChange: (e) => change(i, { template: e.target.value }),
|
|
126
|
+
}, TEMPLATES.map((t) => React.createElement('option', { key: t, value: t }, t))),
|
|
127
|
+
React.createElement('button', {
|
|
128
|
+
type: 'button', className: 'dvs-mini', title: t('remove'),
|
|
129
|
+
disabled: !props.writable, onClick: () => remove(i),
|
|
130
|
+
}, '\u00d7'),
|
|
131
|
+
),
|
|
132
|
+
React.createElement('div', { className: 'dvs-row' },
|
|
133
|
+
field(i, row, 'baseURL', 'https://openrouter.ai/api/v1', true),
|
|
134
|
+
),
|
|
135
|
+
React.createElement('div', { className: 'dvs-row' },
|
|
136
|
+
field(i, row, 'model', t('customModel'), true),
|
|
137
|
+
field(i, row, 'keyEnv', t('customKeyName')),
|
|
138
|
+
),
|
|
139
|
+
row.template === 'openai-chat-audio'
|
|
140
|
+
? React.createElement('div', { className: 'dvs-row' },
|
|
141
|
+
field(i, row, 'prompt', t('customModelHint'), true))
|
|
142
|
+
: null,
|
|
143
|
+
)),
|
|
144
|
+
React.createElement('div', { className: 'dvs-row' },
|
|
145
|
+
React.createElement('button', {
|
|
146
|
+
type: 'button', className: 'dvs-mini', title: t('addCustom'),
|
|
147
|
+
disabled: !props.writable, onClick: add,
|
|
148
|
+
}, '+'),
|
|
149
|
+
React.createElement('span', { className: 'dvs-sub' },
|
|
150
|
+
t('openrouterWarning')),
|
|
151
|
+
),
|
|
152
|
+
)
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function VoiceSection(props) {
|
|
156
|
+
const t = (props && props.t) || moduleT
|
|
157
|
+
const ctx = props.ctx
|
|
158
|
+
// On a non-localhost page the kernel disables settings entirely: the
|
|
159
|
+
// shared document is not readable, every section gets "unavailable" and
|
|
160
|
+
// writes are dropped. The server does not share that restriction, and
|
|
161
|
+
// dsh-lanmode rebuilds the same mechanics on the same calls. Prefer its
|
|
162
|
+
// service when installed, otherwise the kernel one (fine on loopback).
|
|
163
|
+
const scope = ((ctx.get && ctx.get('lanSettings')) || ctx.settingsScope).bind({ namespace: NS })
|
|
164
|
+
const [snap, setSnap] = React.useState(null)
|
|
165
|
+
const [draft, setDraft] = React.useState(null)
|
|
166
|
+
const [saved, setSaved] = React.useState(false)
|
|
167
|
+
const [err, setErr] = React.useState('')
|
|
168
|
+
|
|
169
|
+
// Hotkey picker: not a name field — press the key you want. Users should
|
|
170
|
+
// not need to know codes like KeyR.
|
|
171
|
+
//
|
|
172
|
+
// Hooks are declared here, above every return: declaring them later
|
|
173
|
+
// would leave fewer hooks on the not-ready branch and React would
|
|
174
|
+
// unmount the section with an error.
|
|
175
|
+
const [catching, setCatching] = React.useState(false)
|
|
176
|
+
React.useEffect(() => {
|
|
177
|
+
if (!catching) return undefined
|
|
178
|
+
const onKey = (event) => {
|
|
179
|
+
event.preventDefault()
|
|
180
|
+
event.stopPropagation()
|
|
181
|
+
if (event.key === 'Escape') { setCatching(false); return }
|
|
182
|
+
const chosen = keyFromEvent(event)
|
|
183
|
+
setDraft((d) => Object.assign({}, d || {}, { hotkey: chosen }))
|
|
184
|
+
setCatching(false)
|
|
185
|
+
}
|
|
186
|
+
document.addEventListener('keydown', onKey, true)
|
|
187
|
+
return () => document.removeEventListener('keydown', onKey, true)
|
|
188
|
+
}, [catching])
|
|
189
|
+
|
|
190
|
+
React.useEffect(() => {
|
|
191
|
+
let alive = true
|
|
192
|
+
const render = () => { if (alive) setSnap(scope.getSnapshot()) }
|
|
193
|
+
render()
|
|
194
|
+
const off = scope.subscribe(render)
|
|
195
|
+
return () => { alive = false; off() }
|
|
196
|
+
}, [])
|
|
197
|
+
|
|
198
|
+
const [devices, setDevices] = React.useState([])
|
|
199
|
+
React.useEffect(() => {
|
|
200
|
+
if (!navigator.mediaDevices || !navigator.mediaDevices.enumerateDevices) return
|
|
201
|
+
navigator.mediaDevices.enumerateDevices()
|
|
202
|
+
.then((list) => setDevices(list.filter((d) => d.kind === 'audioinput')))
|
|
203
|
+
.catch(() => {})
|
|
204
|
+
}, [])
|
|
205
|
+
|
|
206
|
+
// The snapshot carries a status, and status matters more than value.
|
|
207
|
+
// loading — no host answer yet;
|
|
208
|
+
// unavailable — host answered but does not know this namespace yet
|
|
209
|
+
// (page opened in the first seconds while plugins register);
|
|
210
|
+
// ready — values are present.
|
|
211
|
+
// At unavailable, writable comes from the document and stays true, so
|
|
212
|
+
// without a status check the card draws an empty but seemingly working
|
|
213
|
+
// form — that is exactly how this bug looked.
|
|
214
|
+
const ready = !!snap && snap.status === 'ready'
|
|
215
|
+
const value = ready && snap.value ? snap.value : {}
|
|
216
|
+
const writable = ready && snap.writable !== false
|
|
217
|
+
|
|
218
|
+
// The browser settings mirror reloads only on value writes and
|
|
219
|
+
// reconnect; a new namespace is not such a signal. It will not fix
|
|
220
|
+
// itself — ask it to reload until ready. The mirror is shared, so this
|
|
221
|
+
// also repairs other settings sections.
|
|
222
|
+
React.useEffect(() => {
|
|
223
|
+
if (ready) return undefined
|
|
224
|
+
let tries = 0
|
|
225
|
+
const timer = setInterval(() => {
|
|
226
|
+
if (tries >= 15) { clearInterval(timer); return }
|
|
227
|
+
tries += 1
|
|
228
|
+
try { ((ctx.get && ctx.get('lanSettings')) || ctx.settingsScope).describe().load() } catch (e) { /* service not up yet */ }
|
|
229
|
+
}, 1000)
|
|
230
|
+
return () => clearInterval(timer)
|
|
231
|
+
}, [ready])
|
|
232
|
+
|
|
233
|
+
// Seed the draft only from a ready snapshot. Previously it was filled
|
|
234
|
+
// from the first snapshot and an empty draft froze forever: later
|
|
235
|
+
// snapshots did not reseed it and the card stayed empty until reload.
|
|
236
|
+
React.useEffect(() => { if (ready && draft === null) setDraft(JSON.parse(JSON.stringify(value))) }, [ready, draft, value])
|
|
237
|
+
// The client needs the VAD threshold and cancel window from the same settings.
|
|
238
|
+
React.useEffect(() => {
|
|
239
|
+
if (!ready) return
|
|
240
|
+
voice.settings = Object.assign({}, voice.settings, {
|
|
241
|
+
vadSilenceMs: Number(value && value.dictation && value.dictation.vadSilenceMs) || 700,
|
|
242
|
+
autoSendMs: Number(value && value.message && value.message.autoSendMs) || 4000,
|
|
243
|
+
beep: !!(snap && snap.beep),
|
|
244
|
+
micDeviceId: String((snap && snap.micDeviceId) || ''),
|
|
245
|
+
historyLimit: Number(snap && snap.historyLimit),
|
|
246
|
+
voiceCommands: !!(snap && snap.voiceCommands),
|
|
247
|
+
sendDelayMs: Number(value && value.dictation && value.dictation.sendDelayMs) || 0,
|
|
248
|
+
stream: !!(value && value.dictation && value.dictation.stream),
|
|
249
|
+
streamChunkMs: Number(value && value.dictation && value.dictation.streamChunkMs) || 1200,
|
|
250
|
+
vadAdapt: Number(value && value.dictation && value.dictation.vadAdapt) || 0,
|
|
251
|
+
wakeWord: String((snap && snap.wakeWord) || ''),
|
|
252
|
+
bargeIn: !!(snap && snap.bargeIn),
|
|
253
|
+
polishSend: !!(value && value.message && value.message.polishSend),
|
|
254
|
+
sessionCommands: !!(value && value.message && value.message.sessionCommands),
|
|
255
|
+
polishBaseUrl: String((snap && snap.polishBaseUrl) || ''),
|
|
256
|
+
noiseSuppression: value.noiseSuppression !== false,
|
|
257
|
+
contextGlossary: value.contextGlossary !== false,
|
|
258
|
+
})
|
|
259
|
+
}, [ready, value, snap])
|
|
260
|
+
|
|
261
|
+
const [statsData, setStatsData] = React.useState({})
|
|
262
|
+
React.useEffect(() => {
|
|
263
|
+
let alive = true
|
|
264
|
+
fetch('/dsh-voice/status', { cache: 'no-store' })
|
|
265
|
+
.then((r) => r.json())
|
|
266
|
+
.then((data) => {
|
|
267
|
+
if (alive && data && data.providerStats) {
|
|
268
|
+
setStatsData(data.providerStats)
|
|
269
|
+
}
|
|
270
|
+
})
|
|
271
|
+
.catch(() => {})
|
|
272
|
+
return () => { alive = false }
|
|
273
|
+
}, [])
|
|
274
|
+
|
|
275
|
+
if (!ready) {
|
|
276
|
+
const waiting = !snap || snap.status === 'loading'
|
|
277
|
+
return React.createElement('div', { className: 'dvs-wrap' },
|
|
278
|
+
React.createElement('div', { className: 'dvs-wait' }, waiting
|
|
279
|
+
? t('loadingSettings')
|
|
280
|
+
: t('notReady1')
|
|
281
|
+
+ t('notReady2')),
|
|
282
|
+
)
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
const setIn = (mode, key, v) => setDraft((d) => {
|
|
286
|
+
const next = JSON.parse(JSON.stringify(d || {}))
|
|
287
|
+
next[mode] = next[mode] || {}
|
|
288
|
+
next[mode][key] = v
|
|
289
|
+
return next
|
|
290
|
+
})
|
|
291
|
+
const setTop = (key, v) => setDraft((d) => Object.assign({}, d || {}, { [key]: v }))
|
|
292
|
+
|
|
293
|
+
const save = async () => {
|
|
294
|
+
setErr(''); setSaved(false)
|
|
295
|
+
if (!draft) return
|
|
296
|
+
|
|
297
|
+
// Fields are written one by one. Previously the first failure broke
|
|
298
|
+
// the loop: later fields were never saved, the composer never got the
|
|
299
|
+
// hotkey reload signal, and the button looked like "nothing happens".
|
|
300
|
+
// Now every field is attempted and failures are collected by name.
|
|
301
|
+
const failed = []
|
|
302
|
+
for (const k of Object.keys(draft)) {
|
|
303
|
+
try {
|
|
304
|
+
await scope.set(k, draft[k])
|
|
305
|
+
} catch (e) {
|
|
306
|
+
failed.push(k + ': ' + String(e && e.message ? e.message : e))
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
Object.assign(voice.settings, {
|
|
311
|
+
vadSilenceMs: Number(draft.dictation && draft.dictation.vadSilenceMs) || 700,
|
|
312
|
+
autoSendMs: Number(draft.message && draft.message.autoSendMs) || 4000,
|
|
313
|
+
beep: !!draft.beep,
|
|
314
|
+
micDeviceId: String(draft.micDeviceId || ''),
|
|
315
|
+
historyLimit: Number(draft.historyLimit),
|
|
316
|
+
voiceCommands: !!draft.voiceCommands,
|
|
317
|
+
sendDelayMs: Number(draft.dictation && draft.dictation.sendDelayMs) || 0,
|
|
318
|
+
stream: !!(draft.dictation && draft.dictation.stream),
|
|
319
|
+
streamChunkMs: Number(draft.dictation && draft.dictation.streamChunkMs) || 1200,
|
|
320
|
+
vadAdapt: Number(draft.dictation && draft.dictation.vadAdapt) || 0,
|
|
321
|
+
noiseSuppression: draft.noiseSuppression !== false,
|
|
322
|
+
contextGlossary: draft.contextGlossary !== false,
|
|
323
|
+
visualizerStyle: draft.visualizerStyle || 'liquid-wave',
|
|
324
|
+
})
|
|
325
|
+
// The composer owns the hotkey handler — tell it to reload, otherwise
|
|
326
|
+
// a new key only works after a full page reload.
|
|
327
|
+
try { window.dispatchEvent(new CustomEvent('dsh-voice:settings-saved')) } catch (noEvents) { /* nobody */ }
|
|
328
|
+
|
|
329
|
+
if (failed.length) { setErr(t('saveFailed') + ' ' + failed.join('; ')); return }
|
|
330
|
+
setSaved(true); setTimeout(() => setSaved(false), 2000)
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
const modeVal = (mode, key, fallback) => {
|
|
334
|
+
const m = draft && draft[mode]
|
|
335
|
+
return m && m[key] !== undefined ? m[key] : fallback
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
// Custom provider names come from the draft so a just-added entry
|
|
339
|
+
// appears in chain selects immediately.
|
|
340
|
+
const chainOptions = BUILTIN.concat(
|
|
341
|
+
(draft && Array.isArray(draft.customProviders) ? draft.customProviders : [])
|
|
342
|
+
.map((c) => String(c && c.key || '').trim())
|
|
343
|
+
.filter((k) => k && BUILTIN.indexOf(k) < 0),
|
|
344
|
+
)
|
|
345
|
+
|
|
346
|
+
const hotkeyField = () => React.createElement('label', { className: 'dvs-field' },
|
|
347
|
+
t('hotkey'),
|
|
348
|
+
React.createElement('div', { className: 'dvs-row' },
|
|
349
|
+
React.createElement('button', {
|
|
350
|
+
type: 'button', className: 'dvs-save', disabled: !writable,
|
|
351
|
+
onClick: () => setCatching(true),
|
|
352
|
+
}, catching ? t('pressKey') : keyLabel(draft && draft.hotkey)),
|
|
353
|
+
React.createElement('button', {
|
|
354
|
+
type: 'button', className: 'dvs-mini', title: t('clearKey'),
|
|
355
|
+
disabled: !writable, onClick: () => setDraft((d) => Object.assign({}, d || {}, { hotkey: '' })),
|
|
356
|
+
}, '×'),
|
|
357
|
+
),
|
|
358
|
+
React.createElement('span', { className: 'dvs-sub' },
|
|
359
|
+
t('hotkeyHint1')
|
|
360
|
+
+ t('hotkeyHint2')))
|
|
361
|
+
|
|
362
|
+
const micField = () => React.createElement('label', { className: 'dvs-field' }, t('mic'),
|
|
363
|
+
React.createElement('select', {
|
|
364
|
+
value: String((snap && snap.micDeviceId) || ''), disabled: !writable,
|
|
365
|
+
onChange: (e) => setTop('micDeviceId', e.target.value),
|
|
366
|
+
},
|
|
367
|
+
React.createElement('option', { value: '' }, t('micDefault')),
|
|
368
|
+
devices.map((d) => React.createElement('option', { key: d.deviceId, value: d.deviceId },
|
|
369
|
+
d.label || d.deviceId.slice(0, 12)))))
|
|
370
|
+
const langField = (mode) => React.createElement('label', { className: 'dvs-field' }, t('language'),
|
|
371
|
+
React.createElement('select', {
|
|
372
|
+
value: modeVal(mode, 'language', 'ru'), disabled: !writable,
|
|
373
|
+
onChange: (e) => setIn(mode, 'language', e.target.value),
|
|
374
|
+
}, LANGS.map((o) => React.createElement('option', { key: o, value: o }, o))))
|
|
375
|
+
|
|
376
|
+
const numField = (mode, key, label, hint) => React.createElement('label', { className: 'dvs-field' }, label,
|
|
377
|
+
React.createElement('input', {
|
|
378
|
+
type: 'number', value: modeVal(mode, key, ''), disabled: !writable,
|
|
379
|
+
onChange: (e) => setIn(mode, key, Number(e.target.value)),
|
|
380
|
+
}),
|
|
381
|
+
React.createElement('span', { className: 'dvs-sub' }, hint))
|
|
382
|
+
|
|
383
|
+
const textField = (key, label, hint) => React.createElement('label', { className: 'dvs-field' }, label,
|
|
384
|
+
React.createElement('input', {
|
|
385
|
+
value: draft && draft[key] !== undefined ? draft[key] : '', disabled: !writable,
|
|
386
|
+
onChange: (e) => setTop(key, e.target.value),
|
|
387
|
+
}),
|
|
388
|
+
React.createElement('span', { className: 'dvs-sub' }, hint))
|
|
389
|
+
|
|
390
|
+
return React.createElement('div', { className: 'dvs-wrap' },
|
|
391
|
+
React.createElement('div', { className: 'dvs-block' },
|
|
392
|
+
React.createElement('div', { className: 'dvs-h' }, t('dictationBtn')),
|
|
393
|
+
React.createElement('div', { className: 'dvs-sub' }, t('dictationHint')),
|
|
394
|
+
React.createElement(ChainEditor, {
|
|
395
|
+
value: draft && draft.dictation ? draft.dictation.chain : [], writable: writable,
|
|
396
|
+
options: chainOptions,
|
|
397
|
+
onChange: (v) => setIn('dictation', 'chain', v),
|
|
398
|
+
}),
|
|
399
|
+
langField('dictation'),
|
|
400
|
+
numField('dictation', 'vadSilenceMs', t('pauseMs'), t('pauseHint')),
|
|
401
|
+
numField('dictation', 'sendDelayMs', t('sendDelay'), t('sendDelayHint')),
|
|
402
|
+
React.createElement('label', { className: 'dvs-field' }, t('polish'),
|
|
403
|
+
React.createElement('input', {
|
|
404
|
+
type: 'checkbox', checked: !!(draft && draft.dictation && draft.dictation.polish), disabled: !writable,
|
|
405
|
+
onChange: (e) => setIn('dictation', 'polish', e.target.checked),
|
|
406
|
+
})),
|
|
407
|
+
React.createElement('div', { className: 'dvs-sub' }, t('polishHint')),
|
|
408
|
+
React.createElement('label', { className: 'dvs-field', title: t('wakeWordHint') }, t('wakeWord'),
|
|
409
|
+
React.createElement('input', {
|
|
410
|
+
type: 'text', value: String((snap && snap.wakeWord) || ''), disabled: !writable,
|
|
411
|
+
onChange: (e) => setTop('wakeWord', e.target.value),
|
|
412
|
+
})),
|
|
413
|
+
React.createElement('label', { className: 'dvs-field', title: t('streamHint') }, t('stream'),
|
|
414
|
+
React.createElement('input', {
|
|
415
|
+
type: 'checkbox', checked: !!(draft && draft.dictation && draft.dictation.stream), disabled: !writable,
|
|
416
|
+
onChange: (e) => setIn('dictation', 'stream', e.target.checked),
|
|
417
|
+
})),
|
|
418
|
+
numField('dictation', 'streamChunkMs', t('streamChunkMs'), t('streamHint')),
|
|
419
|
+
React.createElement('label', { className: 'dvs-field', title: t('vadAdaptHint') }, t('vadAdapt'),
|
|
420
|
+
React.createElement('input', {
|
|
421
|
+
type: 'range', min: 0, max: 1, step: 0.1,
|
|
422
|
+
value: Number(draft && draft.dictation && draft.dictation.vadAdapt) || 0, disabled: !writable,
|
|
423
|
+
onChange: (e) => setIn('dictation', 'vadAdapt', Number(e.target.value)),
|
|
424
|
+
})),
|
|
425
|
+
),
|
|
426
|
+
React.createElement('div', { className: 'dvs-block' },
|
|
427
|
+
React.createElement('div', { className: 'dvs-h' }, t('messageTitle')),
|
|
428
|
+
React.createElement('div', { className: 'dvs-sub' }, t('messageHint')),
|
|
429
|
+
React.createElement(ChainEditor, {
|
|
430
|
+
value: draft && draft.message ? draft.message.chain : [], writable: writable,
|
|
431
|
+
options: chainOptions,
|
|
432
|
+
onChange: (v) => setIn('message', 'chain', v),
|
|
433
|
+
}),
|
|
434
|
+
langField('message'),
|
|
435
|
+
numField('message', 'autoSendMs', t('undoMs'), t('undoHint')),
|
|
436
|
+
React.createElement('label', { className: 'dvs-field' }, t('polish'),
|
|
437
|
+
React.createElement('input', {
|
|
438
|
+
type: 'checkbox', checked: !!(draft && draft.message && draft.message.polish), disabled: !writable,
|
|
439
|
+
onChange: (e) => setIn('message', 'polish', e.target.checked),
|
|
440
|
+
})),
|
|
441
|
+
React.createElement('label', { className: 'dvs-field', title: t('polishSendHint') }, t('polishSend'),
|
|
442
|
+
React.createElement('input', {
|
|
443
|
+
type: 'checkbox', checked: !!(draft && draft.message && draft.message.polishSend), disabled: !writable,
|
|
444
|
+
onChange: (e) => setIn('message', 'polishSend', e.target.checked),
|
|
445
|
+
})),
|
|
446
|
+
React.createElement('label', { className: 'dvs-field', title: t('sessionCommandsHint') }, t('sessionCommands'),
|
|
447
|
+
React.createElement('input', {
|
|
448
|
+
type: 'checkbox', checked: !!(draft && draft.message && draft.message.sessionCommands), disabled: !writable,
|
|
449
|
+
onChange: (e) => setIn('message', 'sessionCommands', e.target.checked),
|
|
450
|
+
})),
|
|
451
|
+
),
|
|
452
|
+
React.createElement('div', { className: 'dvs-block' },
|
|
453
|
+
React.createElement('div', { className: 'dvs-h' }, t('customTitle')),
|
|
454
|
+
React.createElement('div', { className: 'dvs-sub' },
|
|
455
|
+
t('customHint')),
|
|
456
|
+
React.createElement(CustomEditor, {
|
|
457
|
+
value: draft && draft.customProviders ? draft.customProviders : [], writable: writable,
|
|
458
|
+
onChange: (v) => setTop('customProviders', v),
|
|
459
|
+
}),
|
|
460
|
+
),
|
|
461
|
+
React.createElement('div', { className: 'dvs-block' },
|
|
462
|
+
React.createElement('div', { className: 'dvs-h' }, t('general')),
|
|
463
|
+
hotkeyField(),
|
|
464
|
+
textField('whisperUrl', t('whisperEndpoint'), t('whisperEndpointHint')),
|
|
465
|
+
textField('whisperBin', t('whisperBin'), t('whisperBinHint')),
|
|
466
|
+
textField('whisperModel', t('whisperModel'), t('whisperModelHint')),
|
|
467
|
+
textField('deepgramBaseUrl', t('deepgramEndpoint'), t('deepgramEndpointHint')),
|
|
468
|
+
React.createElement('label', { className: 'dvs-field' }, t('whisperAutostart'),
|
|
469
|
+
React.createElement('input', {
|
|
470
|
+
type: 'checkbox', checked: !!(draft && draft.autoStart), disabled: !writable,
|
|
471
|
+
onChange: (e) => setTop('autoStart', e.target.checked),
|
|
472
|
+
})),
|
|
473
|
+
React.createElement('label', { className: 'dvs-field', title: t('normalizeTranscriptHint') },
|
|
474
|
+
t('normalizeTranscript'),
|
|
475
|
+
React.createElement('input', {
|
|
476
|
+
type: 'checkbox', checked: !!(draft && draft.normalizeTranscript), disabled: !writable,
|
|
477
|
+
onChange: (e) => setTop('normalizeTranscript', e.target.checked),
|
|
478
|
+
})),
|
|
479
|
+
React.createElement('label', { className: 'dvs-field' }, t('beep'),
|
|
480
|
+
React.createElement('input', {
|
|
481
|
+
type: 'checkbox', checked: !!(draft && draft.beep), disabled: !writable,
|
|
482
|
+
onChange: (e) => setTop('beep', e.target.checked),
|
|
483
|
+
})),
|
|
484
|
+
React.createElement('label', { className: 'dvs-field', title: t('localOnlyHint') }, t('localOnly'),
|
|
485
|
+
React.createElement('input', {
|
|
486
|
+
type: 'checkbox', checked: !!(draft && draft.localOnly), disabled: !writable,
|
|
487
|
+
onChange: (e) => setTop('localOnly', e.target.checked),
|
|
488
|
+
})),
|
|
489
|
+
React.createElement('label', { className: 'dvs-field' }, t('voiceCommandsLabel'),
|
|
490
|
+
React.createElement('input', {
|
|
491
|
+
type: 'checkbox', checked: !!(draft && draft.voiceCommands), disabled: !writable,
|
|
492
|
+
onChange: (e) => setTop('voiceCommands', e.target.checked),
|
|
493
|
+
})),
|
|
494
|
+
micField(),
|
|
495
|
+
React.createElement('label', { className: 'dvs-field', title: t('noiseSuppressionHint') }, t('noiseSuppression'),
|
|
496
|
+
React.createElement('input', {
|
|
497
|
+
type: 'checkbox', checked: draft ? draft.noiseSuppression !== false : true, disabled: !writable,
|
|
498
|
+
onChange: (e) => setTop('noiseSuppression', e.target.checked),
|
|
499
|
+
})),
|
|
500
|
+
React.createElement('label', { className: 'dvs-field', title: t('contextGlossaryHint') }, t('contextGlossary'),
|
|
501
|
+
React.createElement('input', {
|
|
502
|
+
type: 'checkbox', checked: draft ? draft.contextGlossary !== false : true, disabled: !writable,
|
|
503
|
+
onChange: (e) => setTop('contextGlossary', e.target.checked),
|
|
504
|
+
})),
|
|
505
|
+
React.createElement('label', { className: 'dvs-field', title: t('visualizerStyleHint') }, t('visualizerStyle'),
|
|
506
|
+
React.createElement('select', {
|
|
507
|
+
value: String((draft && draft.visualizerStyle) || 'liquid-wave'), disabled: !writable,
|
|
508
|
+
onChange: (e) => setTop('visualizerStyle', e.target.value),
|
|
509
|
+
},
|
|
510
|
+
React.createElement('option', { value: 'liquid-wave' }, t('visLiquidWave')),
|
|
511
|
+
React.createElement('option', { value: 'dynamic-orb' }, t('visDynamicOrb')),
|
|
512
|
+
React.createElement('option', { value: 'bars' }, t('visBars')),
|
|
513
|
+
React.createElement('option', { value: 'off' }, t('visOff')),
|
|
514
|
+
)),
|
|
515
|
+
React.createElement('label', { className: 'dvs-field' }, t('vocabulary'),
|
|
516
|
+
React.createElement('textarea', {
|
|
517
|
+
rows: 3, disabled: !writable,
|
|
518
|
+
value: Array.isArray(draft && draft.vocabulary) ? draft.vocabulary.join('\n') : '',
|
|
519
|
+
onChange: (e) => setTop('vocabulary', e.target.value.split('\n').map((x) => x.trim()).filter(Boolean)),
|
|
520
|
+
})),
|
|
521
|
+
React.createElement('label', { className: 'dvs-field', title: t('polishBaseUrlHint') }, t('polishBaseUrl'),
|
|
522
|
+
React.createElement('input', {
|
|
523
|
+
type: 'text', value: String((snap && snap.polishBaseUrl) || ''), disabled: !writable,
|
|
524
|
+
onChange: (e) => setTop('polishBaseUrl', e.target.value),
|
|
525
|
+
})),
|
|
526
|
+
React.createElement('label', { className: 'dvs-field' }, t('polishModel'),
|
|
527
|
+
React.createElement('input', {
|
|
528
|
+
type: 'text', value: String((draft && draft.polishModel) || ''), disabled: !writable,
|
|
529
|
+
onChange: (e) => setTop('polishModel', e.target.value),
|
|
530
|
+
})),
|
|
531
|
+
React.createElement('label', { className: 'dvs-field' }, t('polishKeyEnv'),
|
|
532
|
+
React.createElement('input', {
|
|
533
|
+
type: 'text', value: String((draft && draft.polishKeyEnv) || ''), disabled: !writable,
|
|
534
|
+
onChange: (e) => setTop('polishKeyEnv', e.target.value),
|
|
535
|
+
})),
|
|
536
|
+
),
|
|
537
|
+
React.createElement('div', { className: 'dvs-block' },
|
|
538
|
+
React.createElement('div', { className: 'dvs-h' }, 'SenseVoice-ONNX / Sherpa-ONNX'),
|
|
539
|
+
React.createElement('div', { className: 'dvs-sub' }, t('sensevoiceHint')),
|
|
540
|
+
textField('sensevoiceUrl', t('sensevoiceEndpoint'), t('sensevoiceEndpointHint')),
|
|
541
|
+
textField('sensevoiceBin', t('sensevoiceBin'), t('sensevoiceBinHint')),
|
|
542
|
+
textField('sensevoiceModel', t('sensevoiceModel'), t('sensevoiceModelHint')),
|
|
543
|
+
React.createElement('label', { className: 'dvs-field' }, t('sensevoiceAutostart'),
|
|
544
|
+
React.createElement('input', {
|
|
545
|
+
type: 'checkbox', checked: !!(draft && draft.sensevoiceAutostart), disabled: !writable,
|
|
546
|
+
onChange: (e) => setTop('sensevoiceAutostart', e.target.checked),
|
|
547
|
+
})),
|
|
548
|
+
),
|
|
549
|
+
React.createElement('div', { className: 'dvs-block' },
|
|
550
|
+
React.createElement('div', { className: 'dvs-h' }, t('providerDashboard')),
|
|
551
|
+
React.createElement('div', { className: 'dvo-dash' },
|
|
552
|
+
React.createElement('div', { className: 'dvo-dash-grid' },
|
|
553
|
+
Object.keys(statsData).length === 0
|
|
554
|
+
? React.createElement('div', { className: 'dvs-sub' }, t('idle'))
|
|
555
|
+
: Object.entries(statsData).map(([key, item]) => {
|
|
556
|
+
const avg = item.avgTookMs || 0
|
|
557
|
+
const hasErrors = item.failures > 0
|
|
558
|
+
let badgeClass = 'dvo-badge-idle'
|
|
559
|
+
let badgeText = t('idle')
|
|
560
|
+
if (item.attempts > 0) {
|
|
561
|
+
if (hasErrors && item.successes === 0) {
|
|
562
|
+
badgeClass = 'dvo-badge-err'
|
|
563
|
+
badgeText = t('error')
|
|
564
|
+
} else if (avg > 0 && avg < 400) {
|
|
565
|
+
badgeClass = 'dvo-badge-fast'
|
|
566
|
+
badgeText = avg + 'ms · ' + t('fast')
|
|
567
|
+
} else if (avg >= 400 && avg <= 1500) {
|
|
568
|
+
badgeClass = 'dvo-badge-norm'
|
|
569
|
+
badgeText = avg + 'ms · ' + t('normal')
|
|
570
|
+
} else {
|
|
571
|
+
badgeClass = 'dvo-badge-slow'
|
|
572
|
+
badgeText = avg + 'ms · ' + t('slow')
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
const rate = item.attempts > 0 ? Math.round((item.successes / item.attempts) * 100) : 100
|
|
576
|
+
return React.createElement('div', { key, className: 'dvo-dash-item', title: item.lastError ? `Error: ${item.lastError}` : '' },
|
|
577
|
+
React.createElement('div', { className: 'dvo-dash-name' }, key),
|
|
578
|
+
React.createElement('div', { className: 'dvo-dash-row' },
|
|
579
|
+
React.createElement('span', { className: 'dvo-badge ' + badgeClass }, badgeText),
|
|
580
|
+
React.createElement('span', null, rate + '%'),
|
|
581
|
+
),
|
|
582
|
+
React.createElement('div', { className: 'dvo-dash-row' },
|
|
583
|
+
React.createElement('span', null, `${item.successes}/${item.attempts}`),
|
|
584
|
+
React.createElement('span', null, t('successRate')),
|
|
585
|
+
),
|
|
586
|
+
)
|
|
587
|
+
})
|
|
588
|
+
)
|
|
589
|
+
)
|
|
590
|
+
),
|
|
591
|
+
React.createElement('div', { className: 'dvs-row' },
|
|
592
|
+
React.createElement('button', { type: 'button', className: 'dvs-save', disabled: !writable, onClick: save }, t('save')),
|
|
593
|
+
saved ? React.createElement('span', { className: 'dvs-ok' }, t('saved')) : null,
|
|
594
|
+
err ? React.createElement('span', { className: 'dvs-bad' }, err) : null,
|
|
595
|
+
),
|
|
596
|
+
)
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
// Card in Settings → Plugins → Plugin settings (#18): the kernel only
|
|
600
|
+
// draws the list shell, so title, hint and collapse are ours.
|
|
601
|
+
// Body mounts on first expand; the settings snapshot arrives then.
|
|
602
|
+
function PluginCard(props) {
|
|
603
|
+
const [open, setOpen] = React.useState(false)
|
|
604
|
+
const tt = (props && props.t) || t
|
|
605
|
+
const title = tt('title')
|
|
606
|
+
return React.createElement('li',
|
|
607
|
+
{ className: open ? 'dvo-pcard dvo-pcardOpen' : 'dvo-pcard' },
|
|
608
|
+
React.createElement('button',
|
|
609
|
+
{
|
|
610
|
+
type: 'button', className: 'dvo-phead',
|
|
611
|
+
'aria-expanded': open ? 'true' : 'false',
|
|
612
|
+
'aria-label': (open ? tt('collapse') : tt('expand')) + ': ' + title,
|
|
613
|
+
onClick: () => setOpen((v) => !v),
|
|
614
|
+
},
|
|
615
|
+
React.createElement('span', { className: 'dvo-pheadtext' },
|
|
616
|
+
React.createElement('span', { className: 'dvo-ptitle' }, title),
|
|
617
|
+
React.createElement('span', { className: 'dvo-pdesc' }, tt('cardHint')),
|
|
618
|
+
),
|
|
619
|
+
React.createElement('span', { className: 'dvo-pchev' }, chevronIcon()),
|
|
620
|
+
),
|
|
621
|
+
open ? React.createElement('div', { className: 'dvo-pbody' }, React.createElement(VoiceSection, props)) : null,
|
|
622
|
+
)
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
function registerSettings(ctx) {
|
|
626
|
+
// Settings card under Settings → Plugins → Plugin settings (#18).
|
|
627
|
+
// The registration key must equal NS (the settings namespace), otherwise
|
|
628
|
+
// the tab silently skips the slot.
|
|
629
|
+
ctx.slots.inject('settings.plugin.item', () => ctx.slots.register(
|
|
630
|
+
{
|
|
631
|
+
name: 'settings.plugin.item',
|
|
632
|
+
key: NS,
|
|
633
|
+
// locale on the slot record is what makes the component receive props.t.
|
|
634
|
+
locale: NS,
|
|
635
|
+
inject: () => ({ ctx: ctx }),
|
|
636
|
+
},
|
|
637
|
+
PluginCard,
|
|
638
|
+
))
|
|
639
|
+
}
|
|
640
|
+
|