@goodandready/dsh-voice 0.9.31 → 0.9.33
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 +255 -252
- package/README.ru.md +254 -251
- package/README.zh.md +182 -179
- package/lib/client.js +791 -60
- package/lib/dictation-modes.js +28 -0
- package/lib/host-routes.js +222 -188
- package/lib/macros.js +53 -0
- package/lib/polish.js +14 -6
- package/lib/providers.js +3 -1
- package/lib/schema.js +173 -161
- package/lib/updater.js +75 -8
- package/package.json +72 -72
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
// lib/dictation-modes.js — Dictation post-processing modes (#264)
|
|
2
|
+
|
|
3
|
+
export const DICTATION_MODES = ['verbatim', 'clean', 'task', 'note']
|
|
4
|
+
|
|
5
|
+
export const DEFAULT_DICTATION_PROMPTS = {
|
|
6
|
+
clean: 'Fix the punctuation and spelling of this dictated text and remove filler words ("um", "uh", etc.). Keep the original language, technical terms, and meaning intact without adding facts. Reply with the clean text only:\n\n{text}',
|
|
7
|
+
task: 'Convert the following dictated request into a clear, structured agent task with: Problem/Goal, Expected Outcome, Constraints, Acceptance Criteria. Do not invent details not mentioned in the dictation. Keep technical identifiers intact. Reply with the structured task only:\n\n{text}',
|
|
8
|
+
note: 'Extract key points, main ideas, and action items from the following dictation. Only include action items that were explicitly mentioned. Keep original terminology intact. Reply with the structured note only:\n\n{text}',
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Resolve prompt instruction for a dictation sub-mode.
|
|
13
|
+
* @param {string} mode 'verbatim' | 'clean' | 'task' | 'note'
|
|
14
|
+
* @param {Record<string, string>} [customPrompts]
|
|
15
|
+
* @param {string} text Dictated text to format
|
|
16
|
+
* @returns {string|null} null for verbatim (0 LLM calls), prompt string for LLM modes
|
|
17
|
+
*/
|
|
18
|
+
export function resolveDictationPrompt(mode, customPrompts, text) {
|
|
19
|
+
const m = DICTATION_MODES.includes(mode) ? mode : 'clean'
|
|
20
|
+
if (m === 'verbatim') return null
|
|
21
|
+
const template = (customPrompts && typeof customPrompts[m] === 'string' && customPrompts[m].trim())
|
|
22
|
+
? customPrompts[m]
|
|
23
|
+
: DEFAULT_DICTATION_PROMPTS[m]
|
|
24
|
+
if (template.includes('{text}')) {
|
|
25
|
+
return template.replaceAll('{text}', text)
|
|
26
|
+
}
|
|
27
|
+
return template + '\n\n' + text
|
|
28
|
+
}
|
package/lib/host-routes.js
CHANGED
|
@@ -1,188 +1,222 @@
|
|
|
1
|
-
//
|
|
2
|
-
import {
|
|
3
|
-
import { resolveSensevoiceProvider } from './sensevoice-installer.js'
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
const
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
writeJson(res,
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
let
|
|
145
|
-
try {
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
}
|
|
159
|
-
|
|
160
|
-
const
|
|
161
|
-
const
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
}
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
}
|
|
1
|
+
// HTTP host routes registered on ctx.webServer.
|
|
2
|
+
import { readBody, isLoopback, isTrustedCaller } from './http-util.js'
|
|
3
|
+
import { resolveSensevoiceProvider } from './sensevoice-installer.js'
|
|
4
|
+
import { applyVoiceMacros } from './macros.js'
|
|
5
|
+
|
|
6
|
+
function writeJson(res, status, obj) {
|
|
7
|
+
res.writeHead(status, { 'content-type': 'application/json' })
|
|
8
|
+
res.end(JSON.stringify(obj))
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function registerHostRoutes(ctx, deps) {
|
|
12
|
+
const {
|
|
13
|
+
live,
|
|
14
|
+
daemons,
|
|
15
|
+
statsTracker,
|
|
16
|
+
transcribe,
|
|
17
|
+
polishText,
|
|
18
|
+
sessionCommand,
|
|
19
|
+
extractVoiceActions,
|
|
20
|
+
applyJargonDictionary,
|
|
21
|
+
KNOWN_KEYS,
|
|
22
|
+
} = deps
|
|
23
|
+
|
|
24
|
+
const {
|
|
25
|
+
whisperAlive,
|
|
26
|
+
sensevoiceAlive,
|
|
27
|
+
getWhisperError,
|
|
28
|
+
getSensevoiceError,
|
|
29
|
+
startWhisper,
|
|
30
|
+
startSensevoice,
|
|
31
|
+
} = daemons
|
|
32
|
+
|
|
33
|
+
ctx.effect(() => ctx.webServer.register({
|
|
34
|
+
kind: 'exact',
|
|
35
|
+
path: '/dsh-voice/status',
|
|
36
|
+
handler: async (req, res) => {
|
|
37
|
+
if (req.method !== 'GET') { writeJson(res, 405, { ok: false, error: { code: 'method', message: 'GET only' } }); return }
|
|
38
|
+
if (!isTrustedCaller(req)) { writeJson(res, 403, { ok: false, error: { code: 'forbidden', message: 'forbidden source origin' } }); return }
|
|
39
|
+
const cfg = live()
|
|
40
|
+
const whisperOk = await whisperAlive()
|
|
41
|
+
const sensevoiceOk = await sensevoiceAlive()
|
|
42
|
+
const whisperError = whisperOk ? null : getWhisperError()
|
|
43
|
+
const sensevoiceError = sensevoiceOk ? null : getSensevoiceError()
|
|
44
|
+
writeJson(res, 200, {
|
|
45
|
+
ok: true,
|
|
46
|
+
whisperRunning: whisperOk,
|
|
47
|
+
whisperError: whisperOk ? null : whisperError,
|
|
48
|
+
sensevoiceRunning: sensevoiceOk,
|
|
49
|
+
sensevoiceError: sensevoiceOk ? null : sensevoiceError,
|
|
50
|
+
hotkey: cfg.hotkey,
|
|
51
|
+
providers: KNOWN_KEYS.concat(
|
|
52
|
+
(Array.isArray(cfg.customProviders) ? cfg.customProviders : [])
|
|
53
|
+
.map((c) => String(c && c.key || '').trim()).filter(Boolean),
|
|
54
|
+
),
|
|
55
|
+
modes: {
|
|
56
|
+
dictation: {
|
|
57
|
+
chain: cfg.dictation.chain, language: cfg.dictation.language,
|
|
58
|
+
vadSilenceMs: cfg.dictation.vadSilenceMs,
|
|
59
|
+
sendDelayMs: cfg.dictation.sendDelayMs, polish: cfg.dictation.polish,
|
|
60
|
+
stream: cfg.dictation.stream, streamChunkMs: cfg.dictation.streamChunkMs,
|
|
61
|
+
vadAdapt: cfg.dictation.vadAdapt,
|
|
62
|
+
},
|
|
63
|
+
message: {
|
|
64
|
+
chain: cfg.message.chain, language: cfg.message.language,
|
|
65
|
+
autoSendMs: cfg.message.autoSendMs, polish: cfg.message.polish,
|
|
66
|
+
sessionCommands: cfg.message.sessionCommands, polishSend: cfg.message.polishSend, editablePreview: cfg.message.editablePreview !== false,
|
|
67
|
+
},
|
|
68
|
+
},
|
|
69
|
+
beep: cfg.beep,
|
|
70
|
+
localOnly: cfg.localOnly,
|
|
71
|
+
micDeviceId: cfg.micDeviceId,
|
|
72
|
+
noiseGateDb: cfg.noiseGateDb !== undefined ? cfg.noiseGateDb : -45,
|
|
73
|
+
historyLimit: cfg.historyLimit,
|
|
74
|
+
voiceCommands: cfg.voiceCommands,
|
|
75
|
+
wakeWord: String(cfg.wakeWord || ''),
|
|
76
|
+
bargeIn: !!cfg.bargeIn,
|
|
77
|
+
polishBaseUrl: String(cfg.polishBaseUrl || ''),
|
|
78
|
+
noiseSuppression: cfg.noiseSuppression !== false,
|
|
79
|
+
contextGlossary: cfg.contextGlossary !== false,
|
|
80
|
+
sensevoiceUrl: cfg.sensevoiceUrl,
|
|
81
|
+
sensevoiceAutostart: !!cfg.sensevoiceAutostart,
|
|
82
|
+
sensevoiceProvider: cfg.sensevoiceProvider || 'cpu',
|
|
83
|
+
effectiveSensevoiceProvider: resolveSensevoiceProvider(cfg),
|
|
84
|
+
sensevoiceThreads: cfg.sensevoiceThreads || 4,
|
|
85
|
+
webgpuModel: cfg.webgpuModel || 'onnx-community/whisper-tiny',
|
|
86
|
+
deepgramBaseUrl: cfg.deepgramBaseUrl || 'https://api.deepgram.com',
|
|
87
|
+
visualizerStyle: cfg.visualizerStyle || 'liquid-wave',
|
|
88
|
+
gatedTurnTaking: cfg.gatedTurnTaking !== false,
|
|
89
|
+
autoSendVisualRing: cfg.autoSendVisualRing !== false,
|
|
90
|
+
voiceActions: cfg.voiceActions !== false,
|
|
91
|
+
techJargonCorrection: cfg.techJargonCorrection !== false,
|
|
92
|
+
jargonDictionary: Array.isArray(cfg.jargonDictionary) ? cfg.jargonDictionary : [],
|
|
93
|
+
liveInterimPreview: cfg.liveInterimPreview !== false,
|
|
94
|
+
structuredPromptVoice: cfg.structuredPromptVoice !== false,
|
|
95
|
+
dictationMode: cfg.dictationMode || 'clean',
|
|
96
|
+
dictationPrompts: cfg.dictationPrompts || {},
|
|
97
|
+
voiceMacros: Array.isArray(cfg.voiceMacros) ? cfg.voiceMacros : [],
|
|
98
|
+
providerStats: statsTracker.get(),
|
|
99
|
+
})
|
|
100
|
+
},
|
|
101
|
+
}), 'dsh-voice: /status route')
|
|
102
|
+
|
|
103
|
+
ctx.effect(() => ctx.webServer.register({
|
|
104
|
+
kind: 'exact',
|
|
105
|
+
path: '/dsh-voice/polish',
|
|
106
|
+
handler: async (req, res) => {
|
|
107
|
+
if (req.method !== 'POST') { writeJson(res, 405, { ok: false, error: { code: 'method', message: 'POST only' } }); return }
|
|
108
|
+
if (!isTrustedCaller(req)) { writeJson(res, 403, { ok: false, error: { code: 'forbidden', message: 'forbidden source origin' } }); return }
|
|
109
|
+
const cfg = live()
|
|
110
|
+
let raw
|
|
111
|
+
try { raw = await readBody(req, 1024 * 1024) } catch (e) {
|
|
112
|
+
if (e && (e.statusCode === 413 || e.message === 'body too large')) {
|
|
113
|
+
writeJson(res, 413, { ok: false, error: { code: 'too-large', message: 'body exceeds limit of 1MB' } })
|
|
114
|
+
return
|
|
115
|
+
}
|
|
116
|
+
writeJson(res, 400, { ok: false, error: { code: 'body', message: e.message } })
|
|
117
|
+
return
|
|
118
|
+
}
|
|
119
|
+
let payload = {}
|
|
120
|
+
try { payload = JSON.parse(raw.toString('utf8') || '{}') } catch { /* empty */ }
|
|
121
|
+
const text = typeof payload.text === 'string' ? payload.text.trim() : ''
|
|
122
|
+
if (!text) { writeJson(res, 400, { ok: false, error: { code: 'empty', message: 'text required' } }); return }
|
|
123
|
+
const subMode = payload.subMode || payload.dictationMode || cfg.dictationMode || 'clean'
|
|
124
|
+
const prompt = typeof payload.prompt === 'string' ? payload.prompt : null
|
|
125
|
+
const controller = new AbortController()
|
|
126
|
+
const timer = setTimeout(() => controller.abort(), cfg.timeoutMs)
|
|
127
|
+
try {
|
|
128
|
+
const out = await polishText(text, { polish: true, subMode, prompt }, controller.signal)
|
|
129
|
+
writeJson(res, 200, { ok: true, text: out, subMode })
|
|
130
|
+
} catch (e) {
|
|
131
|
+
writeJson(res, 502, { ok: false, error: { code: 'polish', message: String(e && e.message || e) } })
|
|
132
|
+
} finally { clearTimeout(timer) }
|
|
133
|
+
},
|
|
134
|
+
}), 'dsh-voice: /polish route')
|
|
135
|
+
|
|
136
|
+
ctx.effect(() => ctx.webServer.register({
|
|
137
|
+
kind: 'exact',
|
|
138
|
+
path: '/dsh-voice/transcribe',
|
|
139
|
+
handler: async (req, res) => {
|
|
140
|
+
if (req.method !== 'POST') { writeJson(res, 405, { ok: false, error: { code: 'method', message: 'POST only' } }); return }
|
|
141
|
+
if (!isTrustedCaller(req)) { writeJson(res, 403, { ok: false, error: { code: 'forbidden', message: 'forbidden source origin' } }); return }
|
|
142
|
+
const cfg = live()
|
|
143
|
+
const maxBodyBytes = Math.ceil((cfg.maxFileBytes * 4) / 3) + 64 * 1024
|
|
144
|
+
let raw
|
|
145
|
+
try {
|
|
146
|
+
raw = await readBody(req, maxBodyBytes)
|
|
147
|
+
} catch (e) {
|
|
148
|
+
if (e && e.message === 'body too large') {
|
|
149
|
+
writeJson(res, 413, { ok: false, error: { code: 'too-large', message: `audio body exceeds limit for max ${cfg.maxFileBytes} bytes` } })
|
|
150
|
+
return
|
|
151
|
+
}
|
|
152
|
+
writeJson(res, 400, { ok: false, error: { code: 'body', message: e.message } }); return
|
|
153
|
+
}
|
|
154
|
+
let payload
|
|
155
|
+
try { payload = JSON.parse(raw.toString('utf8') || '{}') } catch { payload = {} }
|
|
156
|
+
|
|
157
|
+
const dataBase64 = typeof payload.dataBase64 === 'string' ? payload.dataBase64 : ''
|
|
158
|
+
if (!dataBase64) { writeJson(res, 400, { ok: false, error: { code: 'no-audio', message: 'no audio data' } }); return }
|
|
159
|
+
const mime = typeof payload.mimeType === 'string' && payload.mimeType ? payload.mimeType : 'audio/webm'
|
|
160
|
+
const baseModeCfg = payload.mode === 'message' ? cfg.message : cfg.dictation
|
|
161
|
+
const modeCfg = Array.isArray(payload.chain)
|
|
162
|
+
? { ...baseModeCfg, chain: payload.chain }
|
|
163
|
+
: baseModeCfg
|
|
164
|
+
|
|
165
|
+
let bytes
|
|
166
|
+
try { bytes = Buffer.from(dataBase64, 'base64') } catch { bytes = null }
|
|
167
|
+
if (!bytes || bytes.length === 0) {
|
|
168
|
+
writeJson(res, 400, { ok: false, error: { code: 'decode', message: 'failed to decode audio' } }); return
|
|
169
|
+
}
|
|
170
|
+
if (bytes.length > cfg.maxFileBytes) {
|
|
171
|
+
writeJson(res, 413, { ok: false, error: { code: 'too-large', message: `audio is ${bytes.length} bytes, max ${cfg.maxFileBytes}` } }); return
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
if ((modeCfg.chain || []).some((e) => e.provider === 'local-whisper') && !(await whisperAlive())) {
|
|
175
|
+
await startWhisper()
|
|
176
|
+
}
|
|
177
|
+
if ((modeCfg.chain || []).some((e) => e.provider === 'sensevoice') && !(await sensevoiceAlive())) {
|
|
178
|
+
await startSensevoice()
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const controller = new AbortController()
|
|
182
|
+
const timer = setTimeout(() => controller.abort(), cfg.timeoutMs)
|
|
183
|
+
try {
|
|
184
|
+
const out = await transcribe(modeCfg, bytes, mime, controller.signal, payload.contextWords)
|
|
185
|
+
if (payload.mode === 'message' && modeCfg.sessionCommands === true) {
|
|
186
|
+
const cmd = sessionCommand(out.text)
|
|
187
|
+
if (cmd) { writeJson(res, 200, { ok: true, command: cmd, provider: out.provider, tookMs: out.tookMs }); return }
|
|
188
|
+
}
|
|
189
|
+
const subMode = payload.subMode || payload.dictationMode || cfg.dictationMode || 'clean'
|
|
190
|
+
const rawText = out.text
|
|
191
|
+
let text = out.text
|
|
192
|
+
|
|
193
|
+
// Dictation modes & polish (#264): verbatim skips LLM entirely (0 LLM calls)
|
|
194
|
+
if (subMode !== 'verbatim' && (modeCfg.polish === true || payload.polish === true)) {
|
|
195
|
+
text = await polishText(rawText, { ...modeCfg, subMode, prompt: payload.prompt }, controller.signal)
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// Voice action macros (#167)
|
|
199
|
+
if (Array.isArray(cfg.voiceMacros) && cfg.voiceMacros.length > 0) {
|
|
200
|
+
text = applyVoiceMacros(text, cfg.voiceMacros)
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
if (cfg.techJargonCorrection !== false) {
|
|
204
|
+
text = applyJargonDictionary(text, cfg.jargonDictionary)
|
|
205
|
+
}
|
|
206
|
+
let action = null
|
|
207
|
+
if (cfg.voiceActions !== false) {
|
|
208
|
+
const actionRes = extractVoiceActions(text)
|
|
209
|
+
if (actionRes.action) {
|
|
210
|
+
text = actionRes.text
|
|
211
|
+
action = actionRes.action
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
writeJson(res, 200, { ok: true, text, rawText, subMode, action, provider: out.provider, tookMs: out.tookMs })
|
|
215
|
+
} catch (e) {
|
|
216
|
+
writeJson(res, 502, { ok: false, error: { code: 'chain', message: String(e && e.message || e) } })
|
|
217
|
+
} finally {
|
|
218
|
+
clearTimeout(timer)
|
|
219
|
+
}
|
|
220
|
+
},
|
|
221
|
+
}), 'dsh-voice: /transcribe route')
|
|
222
|
+
}
|
package/lib/macros.js
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
// lib/macros.js — Dynamic voice action macros parsing & substitution (#167)
|
|
2
|
+
|
|
3
|
+
function escapeRegex(string) {
|
|
4
|
+
return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export function normalizeMacroPhrase(str) {
|
|
8
|
+
return String(str || '')
|
|
9
|
+
.trim()
|
|
10
|
+
.toLowerCase()
|
|
11
|
+
.replace(/[.,!?:;]+$/g, '')
|
|
12
|
+
.replace(/^[.,!?:;]+/g, '')
|
|
13
|
+
.trim()
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Apply configured voice macros to recognized text.
|
|
18
|
+
* @param {string} text Raw speech recognition output
|
|
19
|
+
* @param {Array<{trigger: string, template: string}>} macros Configured voice macros
|
|
20
|
+
* @returns {string} Substituted or original text
|
|
21
|
+
*/
|
|
22
|
+
export function applyVoiceMacros(text, macros) {
|
|
23
|
+
if (!text || !Array.isArray(macros) || macros.length === 0) return text
|
|
24
|
+
const cleanText = String(text).trim()
|
|
25
|
+
const normText = normalizeMacroPhrase(cleanText)
|
|
26
|
+
|
|
27
|
+
for (const macro of macros) {
|
|
28
|
+
if (!macro || typeof macro.trigger !== 'string' || !macro.template) continue
|
|
29
|
+
const trig = normalizeMacroPhrase(macro.trigger)
|
|
30
|
+
if (!trig) continue
|
|
31
|
+
|
|
32
|
+
// 1. Exact match
|
|
33
|
+
if (normText === trig) {
|
|
34
|
+
return macro.template.trim()
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// 2. Prefix match
|
|
38
|
+
const trigWordBoundary = new RegExp('^' + escapeRegex(trig) + '(?:\\s+|[:,\\-]\\s*|$)', 'i')
|
|
39
|
+
if (trigWordBoundary.test(normText)) {
|
|
40
|
+
const rest = cleanText.slice(macro.trigger.length).replace(/^[\s,:\-]+/, '').trim()
|
|
41
|
+
let template = String(macro.template).trim()
|
|
42
|
+
if (template.includes('{{text}}') || template.includes('{text}') || template.includes('{input}')) {
|
|
43
|
+
return template
|
|
44
|
+
.replaceAll('{{text}}', rest)
|
|
45
|
+
.replaceAll('{text}', rest)
|
|
46
|
+
.replaceAll('{input}', rest)
|
|
47
|
+
}
|
|
48
|
+
return rest ? (template + '\n' + rest) : template
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return cleanText
|
|
53
|
+
}
|
package/lib/polish.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { isLoopback } from './http-util.js'
|
|
2
|
+
import { resolveDictationPrompt } from './dictation-modes.js'
|
|
2
3
|
// Transcript polish. Pure factory: inject fetch, key resolver, LLM, config.
|
|
3
4
|
// On any failure return the original text — polish must never block input.
|
|
4
5
|
|
|
@@ -14,6 +15,9 @@ export function createPolishText({ resolveKey, fetchImpl, getConfig, llm, getAge
|
|
|
14
15
|
return async function polishText(text, modeCfg, signal) {
|
|
15
16
|
const enable = modeCfg && (modeCfg.polish === true || modeCfg.polishSend === true)
|
|
16
17
|
if (!enable || !text) return text
|
|
18
|
+
const subMode = (modeCfg && (modeCfg.subMode || modeCfg.dictationMode)) || 'clean'
|
|
19
|
+
if (subMode === 'verbatim') return text
|
|
20
|
+
|
|
17
21
|
const cfg = getConfig() || {}
|
|
18
22
|
if (cfg.localOnly) {
|
|
19
23
|
if (!cfg.polishBaseUrl) return text
|
|
@@ -24,11 +28,15 @@ export function createPolishText({ resolveKey, fetchImpl, getConfig, llm, getAge
|
|
|
24
28
|
return text
|
|
25
29
|
}
|
|
26
30
|
}
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
31
|
+
|
|
32
|
+
const customPrompts = (modeCfg && modeCfg.prompts) || cfg.dictationPrompts || {}
|
|
33
|
+
const customInstruction = (modeCfg && typeof modeCfg.prompt === 'string' && modeCfg.prompt.trim()) ? modeCfg.prompt : null
|
|
34
|
+
const ask = customInstruction
|
|
35
|
+
? (customInstruction.includes('{text}') ? customInstruction.replaceAll('{text}', text) : `${customInstruction}\n\n${text}`)
|
|
36
|
+
: (resolveDictationPrompt(subMode, customPrompts, text) || text)
|
|
37
|
+
|
|
38
|
+
if (ask === text) return text
|
|
39
|
+
|
|
32
40
|
try {
|
|
33
41
|
// Local OpenAI-compatible endpoint when configured.
|
|
34
42
|
if (cfg.polishBaseUrl) {
|
|
@@ -81,4 +89,4 @@ export function createPolishText({ resolveKey, fetchImpl, getConfig, llm, getAge
|
|
|
81
89
|
return text
|
|
82
90
|
}
|
|
83
91
|
}
|
|
84
|
-
}
|
|
92
|
+
}
|
package/lib/providers.js
CHANGED
|
@@ -98,7 +98,9 @@ export function makeProviders(deps, req) {
|
|
|
98
98
|
const model = pickModel(models, 'deepgram')
|
|
99
99
|
const base = String(cfg.deepgramBaseUrl || 'https://api.deepgram.com').replace(/\/+$/, '')
|
|
100
100
|
const url = `${base}/v1/listen?model=${encodeURIComponent(model)}`
|
|
101
|
-
+ (!isAutoLang(lang) ? `&language=${encodeURIComponent(lang)}` : '')
|
|
101
|
+
+ (!isAutoLang(lang) ? `&language=${encodeURIComponent(lang)}` : '')
|
|
102
|
+
+ (vocab ? `&keywords=${encodeURIComponent(vocab)}` : '')
|
|
103
|
+
+ '&smart_format=true'
|
|
102
104
|
const res = await fetchImpl(url, {
|
|
103
105
|
method: 'POST',
|
|
104
106
|
headers: { authorization: `Token ${key}`, 'content-type': mime },
|