@goodandready/dsh-voice 0.9.1 → 0.9.3

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/lib/client.js CHANGED
@@ -547,15 +547,15 @@ window.__ModuleLoader__.load({
547
547
  const trimmed = String(text || '').trim().toLowerCase()
548
548
  const { buttons, input } = promptInfo
549
549
  if (buttons && buttons.length > 0) {
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 || ''))
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 || ''))
555
555
  if (yesBtn) { yesBtn.click(); return true }
556
556
  }
557
- if (/^(нет|no|отмена|cancel)$/i.test(trimmed)) {
558
- const noBtn = buttons.find((b) => /нет|no|cancel|отмена/i.test(b.textContent || ''))
557
+ if (/^(нет|no|отмена|cancel|不|否|取消)$/i.test(trimmed)) {
558
+ const noBtn = buttons.find((b) => /нет|no|cancel|отмена|不|否|取消/i.test(b.textContent || ''))
559
559
  if (noBtn) { noBtn.click(); return true }
560
560
  }
561
561
  }
@@ -697,8 +697,8 @@ window.__ModuleLoader__.load({
697
697
  if (!res.ok || !parsed || !parsed.ok) {
698
698
  throw new Error((parsed && parsed.error && parsed.error.message) || `HTTP ${res.status}`)
699
699
  }
700
- if (parsed.command) return { command: parsed.command, text: '' }
701
- 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 }
702
702
  }
703
703
 
704
704
  function tidyPhrase(text) {
@@ -773,6 +773,9 @@ window.__ModuleLoader__.load({
773
773
 
774
774
  function keyLabel(name) {
775
775
  if (!name) return t('keyUnset')
776
+ if (typeof name === 'string' && name.includes('+')) {
777
+ return name.split('+').map((p) => keyLabel(p)).join(' + ')
778
+ }
776
779
  if (name === 'Space') return t('keySpace')
777
780
  if (KEY_LABELS[name]) return KEY_LABELS[name]
778
781
  // Drop the Key/Digit prefix from codes like KeyR and Digit5.
@@ -781,10 +784,23 @@ window.__ModuleLoader__.load({
781
784
 
782
785
  // What to store on key press. Pure modifiers are remembered by name:
783
786
  // left and right have different codes but the user means "either".
787
+ // Supports combos like Control+Space or Alt+KeyV.
784
788
  function keyFromEvent(event) {
785
- if (['Control', 'Alt', 'Shift', 'Meta'].includes(event.key)) return event.key
786
- if (event.code) return event.code
787
- return event.key || ''
789
+ if (!event) return ''
790
+ const modifiers = []
791
+ if (event.ctrlKey && event.key !== 'Control') modifiers.push('Control')
792
+ if (event.altKey && event.key !== 'Alt') modifiers.push('Alt')
793
+ if (event.shiftKey && event.key !== 'Shift') modifiers.push('Shift')
794
+ if (event.metaKey && event.key !== 'Meta') modifiers.push('Meta')
795
+
796
+ const main = ['Control', 'Alt', 'Shift', 'Meta'].includes(event.key)
797
+ ? event.key
798
+ : (event.code || event.key || '')
799
+
800
+ if (modifiers.length > 0 && !['Control', 'Alt', 'Shift', 'Meta'].includes(main)) {
801
+ return modifiers.concat(main).join('+')
802
+ }
803
+ return main
788
804
  }
789
805
 
790
806
  // Announce that the user started speaking.
@@ -893,7 +909,10 @@ window.__ModuleLoader__.load({
893
909
  chainsPromise = fetch('/dsh-voice/status', { cache: 'no-store' })
894
910
  .then((res) => res.json())
895
911
  .then((data) => (data && data.modes) || {})
896
- .catch(() => ({}))
912
+ .catch(() => {
913
+ chainsPromise = null
914
+ return {}
915
+ })
897
916
  }
898
917
  return chainsPromise.then((modes) => {
899
918
  const row = modes[mode] || {}
@@ -1212,18 +1231,56 @@ window.__ModuleLoader__.load({
1212
1231
  // Hotkey: holding a key is easier than aiming the mouse. While the key is
1213
1232
  // down we record; Escape cancels.
1214
1233
  function hotkeyMatches(event, name) {
1234
+ if (!event || !name) return false
1235
+ if (typeof name === 'string' && name.includes('+')) {
1236
+ const parts = name.split('+')
1237
+ const main = parts[parts.length - 1]
1238
+ const needCtrl = parts.includes('Control')
1239
+ const needAlt = parts.includes('Alt')
1240
+ const needShift = parts.includes('Shift')
1241
+ const needMeta = parts.includes('Meta')
1242
+ if (needCtrl && !event.ctrlKey) return false
1243
+ if (needAlt && !event.altKey) return false
1244
+ if (needShift && !event.shiftKey) return false
1245
+ if (needMeta && !event.metaKey) return false
1246
+ return event.code === main || event.key === main
1247
+ }
1215
1248
  if (name === 'Control') return event.key === 'Control'
1216
1249
  if (name === 'Alt') return event.key === 'Alt'
1217
1250
  if (name === 'Shift') return event.key === 'Shift'
1251
+ if (name === 'Meta') return event.key === 'Meta'
1218
1252
  return event.code === name || event.key === name
1219
1253
  }
1220
1254
 
1255
+ function isAllowedInEditable(name) {
1256
+ if (!name) return false
1257
+ if (['Control', 'Alt', 'Shift', 'Meta'].includes(name)) return true
1258
+ if (/^F\d{1,2}$/.test(name)) return true
1259
+ if (typeof name === 'string' && name.includes('+')) return true
1260
+ if (['Tab', 'Pause', 'ScrollLock', 'Insert'].includes(name)) return true
1261
+ return false
1262
+ }
1263
+
1264
+ let activeHotkeyCleanup = null
1265
+
1266
+ function clearGlobalHotkey() {
1267
+ if (typeof activeHotkeyCleanup === 'function') {
1268
+ try { activeHotkeyCleanup() } catch (_) { /* ignore */ }
1269
+ activeHotkeyCleanup = null
1270
+ }
1271
+ if (typeof window !== 'undefined' && typeof window.__dsh_voice_hotkey_cleanup === 'function') {
1272
+ try { window.__dsh_voice_hotkey_cleanup() } catch (_) { /* ignore */ }
1273
+ window.__dsh_voice_hotkey_cleanup = null
1274
+ }
1275
+ }
1276
+
1221
1277
  function installHotkey(ctx, keyName, mode) {
1278
+ clearGlobalHotkey()
1222
1279
  if (typeof document === 'undefined' || !keyName) return () => {}
1223
1280
  const down = (event) => {
1224
1281
  if (event.repeat) return
1225
- // A modifier hotkey does not fight the text field: modifiers do not
1226
- // type. A plain letter key must not be hijacked while typing.
1282
+ // A modifier, function key or combo does not fight the text field:
1283
+ // modifiers/combos do not type plain text. A plain letter key must not be hijacked while typing.
1227
1284
  const target = event.target
1228
1285
  const isEditable = target && (
1229
1286
  target.tagName === 'INPUT' ||
@@ -1231,23 +1288,41 @@ window.__ModuleLoader__.load({
1231
1288
  target.isContentEditable ||
1232
1289
  (typeof target.getAttribute === 'function' && target.getAttribute('contenteditable') === 'true')
1233
1290
  )
1234
- if (isEditable && !['Control', 'Alt', 'Shift'].includes(keyName)) return
1291
+ if (isEditable && !isAllowedInEditable(keyName)) return
1235
1292
 
1236
- if (hotkeyMatches(event, keyName)) beginHold(mode)
1293
+ if (hotkeyMatches(event, keyName)) {
1294
+ event.preventDefault()
1295
+ event.stopPropagation()
1296
+ beginHold(mode)
1297
+ }
1237
1298
  }
1238
1299
  const up = (event) => {
1239
- if (hotkeyMatches(event, keyName)) endHold(false)
1240
- else if (event.key === 'Escape' && hold.armed) endHold(true)
1300
+ if (hotkeyMatches(event, keyName)) {
1301
+ event.preventDefault()
1302
+ endHold(false)
1303
+ } else if (event.key === 'Escape' && hold.armed) {
1304
+ event.preventDefault()
1305
+ endHold(true)
1306
+ }
1241
1307
  }
1242
1308
  const blur = () => { if (hold.armed) endHold(true) }
1243
1309
  document.addEventListener('keydown', down, true)
1244
1310
  document.addEventListener('keyup', up, true)
1245
1311
  window.addEventListener('blur', blur)
1246
- return () => {
1312
+ const cleanup = () => {
1247
1313
  document.removeEventListener('keydown', down, true)
1248
1314
  document.removeEventListener('keyup', up, true)
1249
1315
  window.removeEventListener('blur', blur)
1316
+ if (activeHotkeyCleanup === cleanup) activeHotkeyCleanup = null
1317
+ if (typeof window !== 'undefined' && window.__dsh_voice_hotkey_cleanup === cleanup) {
1318
+ window.__dsh_voice_hotkey_cleanup = null
1319
+ }
1320
+ }
1321
+ activeHotkeyCleanup = cleanup
1322
+ if (typeof window !== 'undefined') {
1323
+ window.__dsh_voice_hotkey_cleanup = cleanup
1250
1324
  }
1325
+ return cleanup
1251
1326
  }
1252
1327
 
1253
1328
  function cancelCurrent() {
@@ -1938,6 +2013,7 @@ window.__ModuleLoader__.load({
1938
2013
  alive = false
1939
2014
  window.removeEventListener('dsh-voice:settings-saved', reload)
1940
2015
  dispose()
2016
+ clearGlobalHotkey()
1941
2017
  }
1942
2018
  }, 'dsh-voice: hold hotkey')
1943
2019
 
@@ -2187,6 +2263,7 @@ window.__ModuleLoader__.load({
2187
2263
  const [err, setErr] = React.useState('')
2188
2264
  const [serverConfig, setServerConfig] = React.useState(null)
2189
2265
  const [configLoading, setConfigLoading] = React.useState(true)
2266
+ const [jargonInput, setJargonInput] = React.useState(null)
2190
2267
 
2191
2268
  React.useEffect(() => {
2192
2269
  let alive = true
@@ -2215,11 +2292,17 @@ window.__ModuleLoader__.load({
2215
2292
  if (event.key === 'Escape') { setCatching(false); return }
2216
2293
  const chosen = keyFromEvent(event)
2217
2294
  setDraft((d) => Object.assign({}, d || {}, { hotkey: chosen }))
2295
+ voice.hotkey = chosen || ''
2296
+ if (chosen) {
2297
+ installHotkey(ctx, chosen, 'message')
2298
+ } else {
2299
+ clearGlobalHotkey()
2300
+ }
2218
2301
  setCatching(false)
2219
2302
  }
2220
2303
  document.addEventListener('keydown', onKey, true)
2221
2304
  return () => document.removeEventListener('keydown', onKey, true)
2222
- }, [catching])
2305
+ }, [catching, ctx])
2223
2306
 
2224
2307
  React.useEffect(() => {
2225
2308
  let alive = true
@@ -2505,6 +2588,21 @@ window.__ModuleLoader__.load({
2505
2588
  const save = async () => {
2506
2589
  setErr(''); setSaved(false)
2507
2590
  if (!draft) return
2591
+ if (jargonInput !== null) {
2592
+ const lines = jargonInput.split('\n')
2593
+ const parsed = lines
2594
+ .map((line) => {
2595
+ const parts = line.split(/->|=|:/)
2596
+ if (parts.length >= 2) {
2597
+ const from = parts[0].trim()
2598
+ const to = parts.slice(1).join(':').trim()
2599
+ if (from && to) return { from, to }
2600
+ }
2601
+ return null
2602
+ })
2603
+ .filter(Boolean)
2604
+ draft.jargonDictionary = parsed
2605
+ }
2508
2606
 
2509
2607
  let httpOk = false
2510
2608
  let httpError = null
@@ -2543,7 +2641,14 @@ window.__ModuleLoader__.load({
2543
2641
  }
2544
2642
 
2545
2643
  const d = draft || {}
2644
+ voice.hotkey = d.hotkey || ''
2645
+ if (voice.hotkey) {
2646
+ installHotkey(ctx, voice.hotkey, 'message')
2647
+ } else {
2648
+ clearGlobalHotkey()
2649
+ }
2546
2650
  Object.assign(voice.settings, {
2651
+ hotkey: d.hotkey || '',
2547
2652
  vadSilenceMs: Number(d.dictation && d.dictation.vadSilenceMs) || 700,
2548
2653
  autoSendMs: Number(d.message && d.message.autoSendMs) || 4000,
2549
2654
  beep: !!d.beep,
@@ -2592,7 +2697,11 @@ window.__ModuleLoader__.load({
2592
2697
  }, catching ? t('pressKey') : keyLabel(draft && draft.hotkey)),
2593
2698
  React.createElement('button', {
2594
2699
  type: 'button', className: 'cb-btn cb-btn-mini', title: t('clearKey'),
2595
- disabled: !writable, onClick: () => setDraft((d) => Object.assign({}, d || {}, { hotkey: '' })),
2700
+ disabled: !writable, onClick: () => {
2701
+ setDraft((d) => Object.assign({}, d || {}, { hotkey: '' }))
2702
+ voice.hotkey = ''
2703
+ clearGlobalHotkey()
2704
+ },
2596
2705
  }, '×'),
2597
2706
  ),
2598
2707
  React.createElement('span', { className: 'dvs-sub' }, t('hotkeyHint1') + t('hotkeyHint2')),
@@ -3028,16 +3137,24 @@ window.__ModuleLoader__.load({
3028
3137
  rows: 3,
3029
3138
  disabled: !writable,
3030
3139
  placeholder: t('jargonPlaceholder'),
3031
- value: Array.isArray(draft && draft.jargonDictionary)
3032
- ? draft.jargonDictionary.map((item) => item && item.from ? `${item.from} -> ${item.to}` : '').filter(Boolean).join('\n')
3033
- : '',
3140
+ value: jargonInput !== null
3141
+ ? jargonInput
3142
+ : (Array.isArray(draft && draft.jargonDictionary)
3143
+ ? draft.jargonDictionary.map((item) => item && item.from ? `${item.from} -> ${item.to}` : '').filter(Boolean).join('\n')
3144
+ : ''),
3034
3145
  onChange: (e) => {
3035
- const lines = e.target.value.split('\n')
3146
+ setJargonInput(e.target.value)
3147
+ },
3148
+ onBlur: () => {
3149
+ if (jargonInput === null) return
3150
+ const lines = jargonInput.split('\n')
3036
3151
  const parsed = lines
3037
3152
  .map((line) => {
3038
3153
  const parts = line.split(/->|=|:/)
3039
3154
  if (parts.length >= 2) {
3040
- return { from: parts[0].trim(), to: parts.slice(1).join(':').trim() }
3155
+ const from = parts[0].trim()
3156
+ const to = parts.slice(1).join(':').trim()
3157
+ if (from && to) return { from, to }
3041
3158
  }
3042
3159
  return null
3043
3160
  })
@@ -3110,7 +3227,7 @@ window.__ModuleLoader__.load({
3110
3227
  React.createElement('div', { className: 'cb-section-desc' }, t('versionHint')),
3111
3228
  React.createElement('div', { className: 'cb-row', style: { alignItems: 'center', gap: '12px' } },
3112
3229
  React.createElement('span', { className: 'cb-badge cb-badge-ok' },
3113
- 'v' + ((updaterStatus && updaterStatus.currentVersion) || '0.9.1')
3230
+ 'v' + ((updaterStatus && updaterStatus.currentVersion) || '0.9.3')
3114
3231
  ),
3115
3232
  updaterStatus && updaterStatus.updateAvailable
3116
3233
  ? React.createElement('span', { className: 'cb-badge cb-badge-warn' },
package/lib/index.js CHANGED
@@ -27,14 +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
33
  import { getAllowedAudioRoots, validateAudioPath, validateAudioContent } from './audio-guard.js'
34
-
35
- function isAutoLang(lang) {
36
- return !lang || lang === 'auto' || String(lang).includes(',')
37
- }
34
+ import { isAutoLang } from './provider-http.js'
38
35
 
39
36
  export const name = '@goodandready/dsh-voice'
40
37
  export const NS = 'dsh-voice'
@@ -324,9 +321,12 @@ export function apply(ctx, baseConfig) {
324
321
  try {
325
322
  let port = '6006'
326
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)}` : ''
327
326
  const spec = ctx.shell.resolve({
328
327
  command: `${JSON.stringify(cfg.sensevoiceBin)}`
329
328
  + ` --sense-voice-model=${JSON.stringify(cfg.sensevoiceModel)}`
329
+ + tokensArg
330
330
  + ` --port=${port} --num-threads=4`,
331
331
  timeoutMs: 0,
332
332
  stdoutMaxBytes: 4 * 1024 * 1024,
@@ -595,7 +595,7 @@ export function apply(ctx, baseConfig) {
595
595
  },
596
596
  }), 'dsh-voice: /transcribe route')
597
597
 
598
- ctx.tools.register(
598
+ ctx.effect(() => ctx.tools.register(
599
599
  defineTool({
600
600
  name: 'transcribe_audio',
601
601
  description:
@@ -649,7 +649,7 @@ export function apply(ctx, baseConfig) {
649
649
  return out
650
650
  },
651
651
  }),
652
- )
652
+ ), 'dsh-voice: transcribe_audio tool')
653
653
 
654
654
  ctx.effect(() => () => {
655
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'
@@ -161,6 +161,7 @@ export async function startSensevoiceInstall(ctx, cfg, updateConfigFn, startSens
161
161
  installerState.step = 'extracting'
162
162
  installerState.progress = 95
163
163
  await extractTarBz2(archivePath, modelDir)
164
+ await unlink(archivePath).catch(() => {})
164
165
 
165
166
  const modelFile = findModelFile(modelDir)
166
167
  if (!modelFile) throw new Error('model.int8.onnx not found after extraction')
@@ -183,6 +184,7 @@ export async function startSensevoiceInstall(ctx, cfg, updateConfigFn, startSens
183
184
 
184
185
  return { ok: true, modelPath: modelFile }
185
186
  } catch (err) {
187
+ await unlink(archivePath).catch(() => {})
186
188
  installerState.installing = false
187
189
  installerState.error = String(err?.message || err)
188
190
  installerState.step = 'failed'
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-voice",
3
- "version": "0.9.1",
3
+ "version": "0.9.3",
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"