@goodandready/dsh-voice 0.9.35 → 0.9.37
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 +1225 -1190
- package/lib/host-routes.js +3 -2
- package/lib/http-util.js +46 -6
- package/lib/index.js +16 -15
- package/lib/local-daemon.js +3 -1
- package/lib/providers.js +9 -7
- package/lib/schema.js +4 -0
- package/lib/sensevoice-installer.js +34 -3
- package/lib/transcribe-core.js +44 -1
- package/package.json +1 -1
package/lib/host-routes.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// HTTP host routes registered on ctx.webServer.
|
|
2
2
|
import os from 'node:os'
|
|
3
|
-
import { readBody, isLoopback, isTrustedCaller } from './http-util.js'
|
|
3
|
+
import { readBody, isLoopback, isTrustedCaller, getVoiceSessionToken } from './http-util.js'
|
|
4
4
|
import { resolveSensevoiceProvider } from './sensevoice-installer.js'
|
|
5
5
|
import { applyVoiceMacros } from './macros.js'
|
|
6
6
|
import { createSyntheticWav } from './wav.js'
|
|
@@ -45,6 +45,7 @@ export function registerHostRoutes(ctx, deps) {
|
|
|
45
45
|
const sensevoiceError = sensevoiceOk ? null : getSensevoiceError()
|
|
46
46
|
writeJson(res, 200, {
|
|
47
47
|
ok: true,
|
|
48
|
+
sessionToken: getVoiceSessionToken(),
|
|
48
49
|
whisperRunning: whisperOk,
|
|
49
50
|
whisperError: whisperOk ? null : whisperError,
|
|
50
51
|
sensevoiceRunning: sensevoiceOk,
|
|
@@ -154,7 +155,7 @@ export function registerHostRoutes(ctx, deps) {
|
|
|
154
155
|
path: '/dsh-voice/benchmark',
|
|
155
156
|
handler: async (req, res) => {
|
|
156
157
|
if (req.method !== 'POST') { writeJson(res, 405, { ok: false, error: { code: 'method', message: 'POST only' } }); return }
|
|
157
|
-
if (!isTrustedCaller(req)) { writeJson(res, 403, { ok: false, error: { code: 'forbidden', message: 'forbidden source origin' } }); return }
|
|
158
|
+
if (!isTrustedCaller(req, { loopbackOnly: true })) { writeJson(res, 403, { ok: false, error: { code: 'forbidden', message: 'forbidden source origin' } }); return }
|
|
158
159
|
|
|
159
160
|
const cfg = live()
|
|
160
161
|
const effectiveProvider = resolveSensevoiceProvider(cfg)
|
package/lib/http-util.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
// Pure HTTP helpers shared by host routes. No cordis, no network.
|
|
2
|
+
import { randomBytes, timingSafeEqual } from 'node:crypto'
|
|
2
3
|
|
|
3
4
|
export class BodyTooLargeError extends Error {
|
|
4
5
|
constructor(message = 'body too large') {
|
|
@@ -9,6 +10,29 @@ export class BodyTooLargeError extends Error {
|
|
|
9
10
|
}
|
|
10
11
|
}
|
|
11
12
|
|
|
13
|
+
let sessionSecret = ''
|
|
14
|
+
export function getVoiceSessionToken() {
|
|
15
|
+
if (!sessionSecret) {
|
|
16
|
+
sessionSecret = randomBytes(24).toString('hex')
|
|
17
|
+
}
|
|
18
|
+
return sessionSecret
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function setVoiceSessionToken(t) {
|
|
22
|
+
sessionSecret = String(t || '')
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function verifyVoiceSessionToken(token) {
|
|
26
|
+
if (!token || typeof token !== 'string') return false
|
|
27
|
+
const secret = getVoiceSessionToken()
|
|
28
|
+
if (token.length !== secret.length) return false
|
|
29
|
+
try {
|
|
30
|
+
return timingSafeEqual(Buffer.from(token), Buffer.from(secret))
|
|
31
|
+
} catch {
|
|
32
|
+
return false
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
12
36
|
export function writeJson(res, code, body) {
|
|
13
37
|
try {
|
|
14
38
|
const headers = { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' }
|
|
@@ -77,12 +101,16 @@ export function isLoopback(addr) {
|
|
|
77
101
|
}
|
|
78
102
|
|
|
79
103
|
/**
|
|
80
|
-
* Verify that the HTTP request originates from a trusted caller
|
|
81
|
-
* Fail-closed security guard for state-changing or quota-consuming POST routes.
|
|
104
|
+
* Verify that the HTTP request originates from a trusted caller.
|
|
105
|
+
* Fail-closed security guard for state-changing or quota-consuming POST routes (#117, #173, #277).
|
|
106
|
+
* - Loopback callers (127.0.0.1, ::1) are trusted unless rejected by browser security headers.
|
|
107
|
+
* - Non-loopback LAN mutating/quota-consuming POST/PUT requests require valid session token (#277).
|
|
108
|
+
* - Routes marked loopbackOnly strictly reject non-loopback callers.
|
|
82
109
|
* @param {import('node:http').IncomingMessage} req
|
|
110
|
+
* @param {{ loopbackOnly?: boolean }} [options]
|
|
83
111
|
* @returns {boolean}
|
|
84
112
|
*/
|
|
85
|
-
export function isTrustedCaller(req) {
|
|
113
|
+
export function isTrustedCaller(req, options = {}) {
|
|
86
114
|
if (!req) return false
|
|
87
115
|
|
|
88
116
|
// 1. Browser security headers: immediately reject cross-site and same-site requests (#117, #173)
|
|
@@ -113,7 +141,6 @@ export function isTrustedCaller(req) {
|
|
|
113
141
|
}
|
|
114
142
|
|
|
115
143
|
// 4. For state-changing or data-carrying requests (POST/PUT/PATCH), require application/json or octet-stream
|
|
116
|
-
// Cross-origin simple form submissions cannot set application/json without a CORS preflight.
|
|
117
144
|
const method = String(req.method || 'GET').toUpperCase()
|
|
118
145
|
if (method === 'POST' || method === 'PUT' || method === 'PATCH') {
|
|
119
146
|
const contentType = String(req.headers?.['content-type'] || '').toLowerCase()
|
|
@@ -128,7 +155,20 @@ export function isTrustedCaller(req) {
|
|
|
128
155
|
const remote = req.socket?.remoteAddress || req.connection?.remoteAddress || ''
|
|
129
156
|
if (isLoopback(remote)) return true
|
|
130
157
|
|
|
131
|
-
//
|
|
158
|
+
// If route strictly requires loopback (e.g. host model installer or hardware benchmark)
|
|
159
|
+
if (options && options.loopbackOnly) return false
|
|
160
|
+
|
|
161
|
+
// 6. Non-loopback LAN requests: POST/PUT/PATCH quota-burning operations require session auth token (#277)
|
|
162
|
+
const isMutating = method === 'POST' || method === 'PUT' || method === 'PATCH'
|
|
163
|
+
if (isMutating) {
|
|
164
|
+
const incomingToken = String(req.headers?.['x-dsh-voice-token'] || req.headers?.['x-voice-token'] || '')
|
|
165
|
+
if (incomingToken && verifyVoiceSessionToken(incomingToken)) {
|
|
166
|
+
return true
|
|
167
|
+
}
|
|
168
|
+
return false
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// 7. Non-state-changing GET requests from LAN: verify same-origin
|
|
132
172
|
if (secSite === 'same-origin') return true
|
|
133
173
|
if (origin) {
|
|
134
174
|
try {
|
|
@@ -146,4 +186,4 @@ export function isTrustedCaller(req) {
|
|
|
146
186
|
}
|
|
147
187
|
|
|
148
188
|
return false
|
|
149
|
-
}
|
|
189
|
+
}
|
package/lib/index.js
CHANGED
|
@@ -17,6 +17,7 @@ import { registerConfigRoutes } from './config-routes.js'
|
|
|
17
17
|
import { registerHostRoutes } from './host-routes.js'
|
|
18
18
|
import { createConfigReader } from './config-compat.js'
|
|
19
19
|
import { createStatsTracker, mergeContextVocabulary } from './stats.js'
|
|
20
|
+
import { buildTranscriptionPrompt } from './transcribe-core.js'
|
|
20
21
|
import { registerTranscribeAudioTool } from './tool.js'
|
|
21
22
|
import { createPolishText } from './polish.js'
|
|
22
23
|
import { buildProviderOrder, sessionCommand } from './transcribe-core.js'
|
|
@@ -114,7 +115,9 @@ export function apply(ctx, baseConfig) {
|
|
|
114
115
|
ctx.inject(['settings'], (sctx) => {
|
|
115
116
|
settingsService = sctx.settings
|
|
116
117
|
try {
|
|
117
|
-
|
|
118
|
+
if (typeof sctx.settings?.register === 'function') {
|
|
119
|
+
scope = sctx.settings.register(NS, Config, { base: baseConfig })
|
|
120
|
+
}
|
|
118
121
|
if (scope && typeof scope.get === 'function') {
|
|
119
122
|
getConfig = () => scope.get() ?? describeRow()?.config ?? readEntryConfig()
|
|
120
123
|
} else {
|
|
@@ -133,16 +136,10 @@ export function apply(ctx, baseConfig) {
|
|
|
133
136
|
triggerAutostart()
|
|
134
137
|
})
|
|
135
138
|
|
|
136
|
-
if (typeof ctx.on === 'function') {
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
ctx.effect(() => ctx.on('config', () => triggerAutostart()), 'dsh-voice: config autostart')
|
|
141
|
-
} else {
|
|
142
|
-
ctx.on('loader/volatile-update', () => triggerAutostart())
|
|
143
|
-
ctx.on('settings/document-updated', () => triggerAutostart())
|
|
144
|
-
ctx.on('config', () => triggerAutostart())
|
|
145
|
-
}
|
|
139
|
+
if (typeof ctx.effect === 'function' && typeof ctx.on === 'function') {
|
|
140
|
+
ctx.effect(() => ctx.on('loader/volatile-update', () => triggerAutostart()), 'dsh-voice: volatile autostart')
|
|
141
|
+
ctx.effect(() => ctx.on('settings/document-updated', () => triggerAutostart()), 'dsh-voice: settings autostart')
|
|
142
|
+
ctx.effect(() => ctx.on('config', () => triggerAutostart()), 'dsh-voice: config autostart')
|
|
146
143
|
}
|
|
147
144
|
|
|
148
145
|
triggerAutostart()
|
|
@@ -161,13 +158,17 @@ export function apply(ctx, baseConfig) {
|
|
|
161
158
|
defaultModels: DEFAULT_MODELS,
|
|
162
159
|
})
|
|
163
160
|
|
|
164
|
-
const vocab =
|
|
165
|
-
|
|
166
|
-
:
|
|
161
|
+
const { promptText, keywords, vocabulary: vocab } = buildTranscriptionPrompt({
|
|
162
|
+
vocabulary: cfg.vocabulary,
|
|
163
|
+
contextWords: cfg.contextGlossary ? contextWords : [],
|
|
164
|
+
bilingualDeveloperPrompt: cfg.bilingualDeveloperPrompt !== false,
|
|
165
|
+
bilingualCustomPrompt: cfg.bilingualCustomPrompt || '',
|
|
166
|
+
language: modeCfg.language,
|
|
167
|
+
})
|
|
167
168
|
|
|
168
169
|
const providers = makeProviders(
|
|
169
170
|
{ resolveKey, fetchImpl: fetch, cfg, toWav: (b, sig) => toWav16k(b, cfg.ffmpegBin, sig || signal) },
|
|
170
|
-
{ bytes, mime, lang: modeCfg.language, signal, models, vocabulary: vocab },
|
|
171
|
+
{ bytes, mime, lang: modeCfg.language, signal, models, vocabulary: vocab, promptText, keywords },
|
|
171
172
|
)
|
|
172
173
|
return runChain(order, providers, statsTracker.record)
|
|
173
174
|
}
|
package/lib/local-daemon.js
CHANGED
|
@@ -35,8 +35,10 @@ export function createLocalDaemons({ live, ctx }) {
|
|
|
35
35
|
try { child.kill() } catch { /* ignore */ }
|
|
36
36
|
child = null
|
|
37
37
|
}
|
|
38
|
+
const rawLang = cfg?.dictation?.language
|
|
39
|
+
const safeLang = isAutoLang(rawLang) ? 'auto' : String(rawLang || '').replace(/[^a-zA-Z0-9_-]/g, '') || 'auto'
|
|
38
40
|
const command = `${JSON.stringify(cfg.whisperBin)} -m ${JSON.stringify(cfg.whisperModel)}`
|
|
39
|
-
+ ` --host 127.0.0.1 --port 8001 -t 8 -p 1 -l ${
|
|
41
|
+
+ ` --host 127.0.0.1 --port 8001 -t 8 -p 1 -l ${JSON.stringify(safeLang)}`
|
|
40
42
|
const specOpts = { command, onExpiry: 'none', timeoutMs: 60000, stdoutMaxBytes: 4 * 1024 * 1024 }
|
|
41
43
|
const spec = typeof ctx.shell?.resolve === 'function' ? ctx.shell.resolve(specOpts) : specOpts
|
|
42
44
|
|
package/lib/providers.js
CHANGED
|
@@ -87,10 +87,12 @@ function pickModel(models, key) {
|
|
|
87
87
|
|
|
88
88
|
export function makeProviders(deps, req) {
|
|
89
89
|
const { resolveKey, fetchImpl, cfg } = deps
|
|
90
|
-
const { bytes, mime, lang, signal, models } = req
|
|
90
|
+
const { bytes, mime, lang, signal, models, promptText, keywords } = req
|
|
91
91
|
const vocab = Array.isArray(req.vocabulary)
|
|
92
92
|
? req.vocabulary.map((w) => String(w || '').trim()).filter(Boolean).join(', ')
|
|
93
93
|
: ''
|
|
94
|
+
const effectivePrompt = (typeof promptText === 'string' && promptText.trim()) ? promptText.trim() : vocab
|
|
95
|
+
const effectiveKeywords = (typeof keywords === 'string' && keywords.trim()) ? keywords.trim() : vocab
|
|
94
96
|
|
|
95
97
|
async function deepgram() {
|
|
96
98
|
const key = await resolveKey(cfg.deepgramKeyEnv)
|
|
@@ -99,7 +101,7 @@ export function makeProviders(deps, req) {
|
|
|
99
101
|
const base = String(cfg.deepgramBaseUrl || 'https://api.deepgram.com').replace(/\/+$/, '')
|
|
100
102
|
const url = `${base}/v1/listen?model=${encodeURIComponent(model)}`
|
|
101
103
|
+ (!isAutoLang(lang) ? `&language=${encodeURIComponent(lang)}` : '')
|
|
102
|
-
+ (
|
|
104
|
+
+ (effectiveKeywords ? `&keywords=${encodeURIComponent(effectiveKeywords)}` : '')
|
|
103
105
|
+ '&smart_format=true'
|
|
104
106
|
const res = await fetchImpl(url, {
|
|
105
107
|
method: 'POST',
|
|
@@ -124,7 +126,7 @@ export function makeProviders(deps, req) {
|
|
|
124
126
|
form.append('file', new Blob([bytes], { type: mime }), fileName(mime))
|
|
125
127
|
form.append('model', pickModel(models, 'groq'))
|
|
126
128
|
if (!isAutoLang(lang)) form.append('language', lang)
|
|
127
|
-
if (
|
|
129
|
+
if (effectivePrompt) form.append('prompt', effectivePrompt)
|
|
128
130
|
form.append('response_format', 'json')
|
|
129
131
|
const res = await fetchImpl('https://api.groq.com/openai/v1/audio/transcriptions', {
|
|
130
132
|
method: 'POST',
|
|
@@ -187,7 +189,7 @@ export function makeProviders(deps, req) {
|
|
|
187
189
|
// Also override a server started with --translate: this plugin always needs
|
|
188
190
|
// a transcript in the spoken language, never Whisper's English translation.
|
|
189
191
|
form.append('translate', 'false')
|
|
190
|
-
if (
|
|
192
|
+
if (effectivePrompt) form.append('prompt', effectivePrompt)
|
|
191
193
|
form.append('response_format', 'json')
|
|
192
194
|
const res = await fetchImpl(cfg.whisperUrl, { method: 'POST', body: form, signal })
|
|
193
195
|
// whisper.cpp returns 400 with a JSON body on internal failures (e.g. decode
|
|
@@ -228,7 +230,7 @@ export function makeProviders(deps, req) {
|
|
|
228
230
|
if (isOpenAI) {
|
|
229
231
|
form.append('model', pickModel(models, 'sensevoice'))
|
|
230
232
|
if (!isAutoLang(lang)) form.append('language', lang)
|
|
231
|
-
if (
|
|
233
|
+
if (effectivePrompt) form.append('prompt', effectivePrompt)
|
|
232
234
|
form.append('response_format', 'json')
|
|
233
235
|
}
|
|
234
236
|
|
|
@@ -282,7 +284,7 @@ export function makeProviders(deps, req) {
|
|
|
282
284
|
form.append('file', new Blob([bytes], { type: mime }), fileName(mime))
|
|
283
285
|
form.append('model', model)
|
|
284
286
|
if (lang && lang !== 'auto') form.append('language', lang)
|
|
285
|
-
if (
|
|
287
|
+
if (effectivePrompt) form.append('prompt', effectivePrompt)
|
|
286
288
|
form.append('response_format', 'json')
|
|
287
289
|
const res = await fetchImpl(`${base}/audio/transcriptions`, {
|
|
288
290
|
method: 'POST', headers, body: form, signal,
|
|
@@ -303,7 +305,7 @@ export function makeProviders(deps, req) {
|
|
|
303
305
|
format = 'wav'
|
|
304
306
|
}
|
|
305
307
|
const ask = (spec.prompt || CHAT_AUDIO_PROMPT)
|
|
306
|
-
+ (
|
|
308
|
+
+ (effectivePrompt ? ` Vocabulary hints (spell these correctly): ${effectivePrompt}.` : '')
|
|
307
309
|
+ (lang && lang !== 'auto' ? ` The audio language is ${lang}.` : '')
|
|
308
310
|
const res = await fetchImpl(`${base}/chat/completions`, {
|
|
309
311
|
method: 'POST',
|
package/lib/schema.js
CHANGED
|
@@ -112,6 +112,10 @@ export const BaseConfig = z.object({
|
|
|
112
112
|
.description('Custom words (names, terms) hinted to providers so they recognize them correctly.'),
|
|
113
113
|
contextGlossary: z.boolean().default(true)
|
|
114
114
|
.description('Automatically extract code words and terms from context and hint them to STT providers.'),
|
|
115
|
+
bilingualDeveloperPrompt: z.boolean().default(true)
|
|
116
|
+
.description('Inject bilingual developer primer (git, docker, npm, PR, etc.) into the STT prompt so mixed RU/EN technical speech is transcribed accurately.'),
|
|
117
|
+
bilingualCustomPrompt: z.string().default('')
|
|
118
|
+
.description('Optional custom terms or prompt primer for the bilingual developer prompt.'),
|
|
115
119
|
noiseSuppression: z.boolean().default(true)
|
|
116
120
|
.description('Enable hardware noise suppression, echo cancellation, and auto gain control.'),
|
|
117
121
|
voiceCommands: z.boolean().default(false)
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
// lib/sensevoice-installer.js — 1-Click installer for SenseVoice-Small & Whisper Large-v3-Turbo
|
|
2
2
|
// Downloads pre-quantized ONNX models and configures autostart on the host via sherpa-onnx.
|
|
3
3
|
|
|
4
|
-
import { createWriteStream, existsSync, readdirSync } from 'node:fs'
|
|
4
|
+
import { createWriteStream, createReadStream, existsSync, readdirSync } from 'node:fs'
|
|
5
|
+
import { createHash } from 'node:crypto'
|
|
5
6
|
import { mkdir, unlink } from 'node:fs/promises'
|
|
6
7
|
import { homedir } from 'node:os'
|
|
7
8
|
import path from 'node:path'
|
|
@@ -20,6 +21,29 @@ export const WHISPER_TURBO_MODEL_URL =
|
|
|
20
21
|
export const WHISPER_TURBO_MODEL_MIRROR =
|
|
21
22
|
'https://huggingface.co/csukuangfj/sherpa-onnx-whisper-turbo/resolve/main/sherpa-onnx-whisper-turbo.tar.bz2'
|
|
22
23
|
|
|
24
|
+
export const MODEL_SHA256 = {
|
|
25
|
+
'sensevoice': 'f6b2a72ebcb1ac7a764d4cfccd886e6bcb2a95c4657c2199d0ba95ed4b9ea71a',
|
|
26
|
+
'whisper-turbo': 'b11acbbcd660b44a8e0df33724feb5aaa709cf65668f2823d59f656312544f22',
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function verifyArchiveSha256(filePath, expectedSha256) {
|
|
30
|
+
if (!expectedSha256) return Promise.resolve(true)
|
|
31
|
+
return new Promise((resolve, reject) => {
|
|
32
|
+
const hash = createHash('sha256')
|
|
33
|
+
const stream = createReadStream(filePath)
|
|
34
|
+
stream.on('data', (chunk) => hash.update(chunk))
|
|
35
|
+
stream.on('end', () => {
|
|
36
|
+
const digest = hash.digest('hex').toLowerCase()
|
|
37
|
+
if (digest === expectedSha256.toLowerCase()) {
|
|
38
|
+
resolve(true)
|
|
39
|
+
} else {
|
|
40
|
+
reject(new Error(`SHA-256 verification failed: expected ${expectedSha256}, got ${digest}`))
|
|
41
|
+
}
|
|
42
|
+
})
|
|
43
|
+
stream.on('error', reject)
|
|
44
|
+
})
|
|
45
|
+
}
|
|
46
|
+
|
|
23
47
|
let installerState = {
|
|
24
48
|
installing: false,
|
|
25
49
|
progress: 0,
|
|
@@ -176,7 +200,7 @@ export async function downloadArchive(url, destPath, onProgress) {
|
|
|
176
200
|
|
|
177
201
|
export function extractTarBz2(archivePath, targetDir) {
|
|
178
202
|
return new Promise((resolve, reject) => {
|
|
179
|
-
const child = spawn('tar', ['-xjf', archivePath, '-C', targetDir], { windowsHide: true })
|
|
203
|
+
const child = spawn('tar', ['--no-same-owner', '-xjf', archivePath, '-C', targetDir], { windowsHide: true })
|
|
180
204
|
let stderr = ''
|
|
181
205
|
child.stderr.on('data', (d) => { stderr += d })
|
|
182
206
|
child.on('close', (code) => {
|
|
@@ -233,6 +257,13 @@ export async function startSensevoiceInstall(ctx, cfg, updateConfigFn, startSens
|
|
|
233
257
|
|
|
234
258
|
if (!downloadOk) throw new Error(`Could not download ${targetModel} model from primary or mirror URL`)
|
|
235
259
|
|
|
260
|
+
installerState.step = 'verifying checksum'
|
|
261
|
+
installerState.progress = 92
|
|
262
|
+
const expectedHash = MODEL_SHA256[targetModel]
|
|
263
|
+
if (expectedHash) {
|
|
264
|
+
await verifyArchiveSha256(archivePath, expectedHash)
|
|
265
|
+
}
|
|
266
|
+
|
|
236
267
|
installerState.step = 'extracting'
|
|
237
268
|
installerState.progress = 95
|
|
238
269
|
await extractTarBz2(archivePath, modelDir)
|
|
@@ -281,7 +312,7 @@ export function registerSensevoiceInstaller(ctx, { liveConfig, isAlive, updateCo
|
|
|
281
312
|
kind: 'exact',
|
|
282
313
|
path: '/dsh-voice/sensevoice-installer',
|
|
283
314
|
handler: async (req, res) => {
|
|
284
|
-
if (!isTrustedCaller(req)) {
|
|
315
|
+
if (!isTrustedCaller(req, { loopbackOnly: true })) {
|
|
285
316
|
writeJson(res, 403, { ok: false, error: 'Forbidden: untrusted caller' })
|
|
286
317
|
return
|
|
287
318
|
}
|
package/lib/transcribe-core.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { mergeContextVocabulary } from './stats.js'
|
|
1
2
|
// Pure helpers for the transcribe path: chain selection and session commands.
|
|
2
3
|
|
|
3
4
|
/**
|
|
@@ -47,4 +48,46 @@ export function sessionCommand(text) {
|
|
|
47
48
|
if (re.test(t)) return cmd
|
|
48
49
|
}
|
|
49
50
|
return null
|
|
50
|
-
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export const DEFAULT_BILINGUAL_PROMPT = 'TypeScript, Python, JavaScript, Docker, Git commit, PR, push, merge, branch, npm, pnpm, API, JSON, async, await, bug, deploy, Linux, bash. Смешанная речь разработчика: русский язык с английскими терминами, командами и кодом.'
|
|
54
|
+
|
|
55
|
+
export function buildTranscriptionPrompt({
|
|
56
|
+
vocabulary = [],
|
|
57
|
+
contextWords = [],
|
|
58
|
+
bilingualDeveloperPrompt = true,
|
|
59
|
+
bilingualCustomPrompt = '',
|
|
60
|
+
language = 'ru',
|
|
61
|
+
maxWords = 50,
|
|
62
|
+
} = {}) {
|
|
63
|
+
const mergedVocab = mergeContextVocabulary(vocabulary, contextWords, maxWords)
|
|
64
|
+
const isRuOrAuto = !language || language === 'ru' || language === 'auto'
|
|
65
|
+
let primer = ''
|
|
66
|
+
if (bilingualDeveloperPrompt && isRuOrAuto) {
|
|
67
|
+
primer = (typeof bilingualCustomPrompt === 'string' && bilingualCustomPrompt.trim())
|
|
68
|
+
? bilingualCustomPrompt.trim()
|
|
69
|
+
: DEFAULT_BILINGUAL_PROMPT
|
|
70
|
+
}
|
|
71
|
+
const vocabStr = mergedVocab.join(', ')
|
|
72
|
+
let promptText = ''
|
|
73
|
+
if (primer && vocabStr) {
|
|
74
|
+
promptText = `${primer} Термины: ${vocabStr}.`
|
|
75
|
+
} else if (primer) {
|
|
76
|
+
promptText = primer
|
|
77
|
+
} else {
|
|
78
|
+
promptText = vocabStr
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const keywords = [...mergedVocab]
|
|
82
|
+
if (bilingualDeveloperPrompt && isRuOrAuto) {
|
|
83
|
+
for (const w of ['Docker', 'Git', 'commit', 'PR', 'merge', 'branch', 'pnpm', 'npm', 'API', 'JSON', 'TypeScript']) {
|
|
84
|
+
if (!keywords.includes(w)) keywords.push(w)
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return {
|
|
89
|
+
vocabulary: mergedVocab,
|
|
90
|
+
promptText: promptText.trim(),
|
|
91
|
+
keywords: keywords.slice(0, maxWords).join(', '),
|
|
92
|
+
}
|
|
93
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@goodandready/dsh-voice",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.37",
|
|
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",
|