@goodandready/dsh-voice 0.8.24 → 0.8.26
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 +10 -0
- package/README.ru.md +10 -0
- package/README.zh.md +10 -0
- package/lib/client.js +386 -65
- package/lib/index.js +10 -0
- package/lib/providers.js +3 -0
- package/package.json +4 -2
- package/lib/client-src/00-open.js +0 -24
- package/lib/client-src/10-locale.js +0 -162
- package/lib/client-src/20-css.js +0 -39
- package/lib/client-src/30-core.js +0 -284
- package/lib/client-src/40-recording.js +0 -387
- package/lib/client-src/41-buttons.js +0 -30
- package/lib/client-src/50-visualizers.js +0 -121
- package/lib/client-src/60-composer.js +0 -285
- package/lib/client-src/70-settings-base.js +0 -126
- package/lib/client-src/71-chains.js +0 -88
- package/lib/client-src/72-voice-section.js +0 -520
- package/lib/client-src/73-plugin-card.js +0 -38
- package/lib/client-src/90-close.js +0 -23
|
@@ -1,285 +0,0 @@
|
|
|
1
|
-
function RecordPill(props) {
|
|
2
|
-
const v = useVoice()
|
|
3
|
-
const canvasRef = React.useRef(null)
|
|
4
|
-
const audioRef = React.useRef(null)
|
|
5
|
-
const [isPlaying, setIsPlaying] = React.useState(false)
|
|
6
|
-
voice.inputActions = props.inputActions
|
|
7
|
-
voice.input = props.input
|
|
8
|
-
const ctx = props.ctx
|
|
9
|
-
|
|
10
|
-
// VAD: accumulate silence and cut the phrase when the pause exceeds the threshold.
|
|
11
|
-
React.useEffect(() => {
|
|
12
|
-
if (v.phase !== 'recording') return
|
|
13
|
-
const tick = 50
|
|
14
|
-
const dispose = ctx.interval(() => {
|
|
15
|
-
const rec = voice.rec
|
|
16
|
-
if (!rec) return
|
|
17
|
-
const level = currentLevel(rec)
|
|
18
|
-
voice.levels.push(level)
|
|
19
|
-
if (voice.levels.length > 150) voice.levels.shift()
|
|
20
|
-
if (level > 0.06) { rec.hadSpeech = true; rec.silenceMs = 0 }
|
|
21
|
-
else if (rec.hadSpeech) rec.silenceMs += tick
|
|
22
|
-
const speaking = level > 0.06
|
|
23
|
-
if (voice.speaking !== speaking) { voice.speaking = speaking; voice.notify() }
|
|
24
|
-
const adapt = Number(voice.settings.vadAdapt) || 0
|
|
25
|
-
let effectiveVad = Number(voice.settings.vadSilenceMs) || 700
|
|
26
|
-
if (adapt > 0 && rec.hadSpeech) {
|
|
27
|
-
// Adaptive threshold (#41): speech density over ~1s (20 samples).
|
|
28
|
-
// Dense speech -> lower threshold (cut more precisely);
|
|
29
|
-
// pause-heavy -> threshold rises toward base (do not cut on breaths).
|
|
30
|
-
const win = voice.levels.slice(-20)
|
|
31
|
-
const density = win.length ? win.filter((v) => v > 0.06).length / win.length : 0
|
|
32
|
-
const k = adapt * (density - 0.5) * 2
|
|
33
|
-
effectiveVad = Math.max(150, Math.round(Number(voice.settings.vadSilenceMs) * (1 - k)))
|
|
34
|
-
}
|
|
35
|
-
// Continuous mode (#40): cut on a timer while speech continues,
|
|
36
|
-
// without waiting for a long pause.
|
|
37
|
-
const stream = !!voice.settings.stream && rec.mode === 'dictation'
|
|
38
|
-
if (stream && rec.hadSpeech && !rec.cutting) {
|
|
39
|
-
rec.streamMs += tick
|
|
40
|
-
const chunk = Number(voice.settings.streamChunkMs) || 1200
|
|
41
|
-
if (rec.streamMs >= chunk) { cutPhrase(); return }
|
|
42
|
-
} else {
|
|
43
|
-
rec.streamMs = 0
|
|
44
|
-
}
|
|
45
|
-
if (rec.mode === 'dictation' && rec.hadSpeech && rec.silenceMs >= effectiveVad) {
|
|
46
|
-
rec.streamMs = 0
|
|
47
|
-
cutPhrase()
|
|
48
|
-
}
|
|
49
|
-
}, tick)
|
|
50
|
-
return () => dispose()
|
|
51
|
-
}, [v.phase])
|
|
52
|
-
|
|
53
|
-
// Waveform / visualizer animation.
|
|
54
|
-
React.useEffect(() => {
|
|
55
|
-
if (v.phase !== 'recording') return
|
|
56
|
-
let frame = 0
|
|
57
|
-
const dispose = ctx.interval(() => {
|
|
58
|
-
const canvas = canvasRef.current
|
|
59
|
-
if (!canvas) return
|
|
60
|
-
const g = canvas.getContext('2d')
|
|
61
|
-
const w = canvas.width, h = canvas.height
|
|
62
|
-
g.clearRect(0, 0, w, h)
|
|
63
|
-
if (!voice.waveColor) {
|
|
64
|
-
try { voice.waveColor = getComputedStyle(canvas).color || '#fff' } catch (e) { voice.waveColor = '#fff' }
|
|
65
|
-
}
|
|
66
|
-
const levels = voice.levels
|
|
67
|
-
const style = (voice.settings && voice.settings.visualizerStyle) || 'liquid-wave'
|
|
68
|
-
frame++
|
|
69
|
-
|
|
70
|
-
if (style === 'off') {
|
|
71
|
-
g.beginPath()
|
|
72
|
-
g.globalAlpha = 0.25
|
|
73
|
-
g.strokeStyle = voice.waveColor
|
|
74
|
-
g.lineWidth = 1
|
|
75
|
-
g.moveTo(10, h / 2)
|
|
76
|
-
g.lineTo(w - 10, h / 2)
|
|
77
|
-
g.stroke()
|
|
78
|
-
g.globalAlpha = 1
|
|
79
|
-
return
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
if (style === 'dynamic-orb') {
|
|
83
|
-
drawDynamicOrb(g, w, h, levels, voice.waveColor, frame)
|
|
84
|
-
} else if (style === 'bars') {
|
|
85
|
-
drawClassicBars(g, w, h, levels, voice.waveColor)
|
|
86
|
-
} else {
|
|
87
|
-
// default: liquid-wave
|
|
88
|
-
drawLiquidWave(g, w, h, levels, voice.waveColor, frame)
|
|
89
|
-
}
|
|
90
|
-
}, 33)
|
|
91
|
-
return () => dispose()
|
|
92
|
-
}, [v.phase])
|
|
93
|
-
|
|
94
|
-
// Message-mode cancel window.
|
|
95
|
-
React.useEffect(() => {
|
|
96
|
-
if (v.phase !== 'pending') return
|
|
97
|
-
const tick = 100
|
|
98
|
-
const dispose = ctx.interval(() => {
|
|
99
|
-
const p = voice.pending
|
|
100
|
-
if (!p) return
|
|
101
|
-
if (p.undoOnly) {
|
|
102
|
-
// Undo-only mode (#29-5): when the window expires just hide the
|
|
103
|
-
// panel; the text either stayed or was already undone.
|
|
104
|
-
p.leftMs -= tick
|
|
105
|
-
if (p.leftMs <= 0) voice.set({ phase: 'idle' })
|
|
106
|
-
else voice.notify()
|
|
107
|
-
return
|
|
108
|
-
}
|
|
109
|
-
p.leftMs -= tick
|
|
110
|
-
if (p.leftMs <= 0) { submitPending(); return }
|
|
111
|
-
voice.notify()
|
|
112
|
-
}, tick)
|
|
113
|
-
return () => dispose()
|
|
114
|
-
}, [v.phase])
|
|
115
|
-
|
|
116
|
-
if (v.phase === 'idle') {
|
|
117
|
-
if (!voice.showPlayer || !voice.lastNote || !voice.lastNote.url) return null
|
|
118
|
-
return React.createElement('div', { className: 'dvo-pill' },
|
|
119
|
-
React.createElement('div', { className: 'dvo-audio-wrap' },
|
|
120
|
-
React.createElement('button', {
|
|
121
|
-
type: 'button', className: 'dvo-audio-play',
|
|
122
|
-
title: isPlaying ? t('pause') : t('play'),
|
|
123
|
-
onClick: () => {
|
|
124
|
-
const el = audioRef.current
|
|
125
|
-
if (!el) return
|
|
126
|
-
if (el.paused) { el.play().catch(() => {}); setIsPlaying(true) }
|
|
127
|
-
else { el.pause(); setIsPlaying(false) }
|
|
128
|
-
},
|
|
129
|
-
}, isPlaying ? pauseIcon() : playIcon()),
|
|
130
|
-
React.createElement('audio', {
|
|
131
|
-
ref: audioRef, src: voice.lastNote.url,
|
|
132
|
-
onEnded: () => setIsPlaying(false),
|
|
133
|
-
onPause: () => setIsPlaying(false),
|
|
134
|
-
onPlay: () => setIsPlaying(true),
|
|
135
|
-
}),
|
|
136
|
-
React.createElement('span', { className: 'dvo-audio-time' }, t('lastRecording')),
|
|
137
|
-
),
|
|
138
|
-
React.createElement('span', { className: 'dvo-status' }, voice.lastNote.text || ''),
|
|
139
|
-
React.createElement('button', {
|
|
140
|
-
type: 'button', className: 'dvo-pbtn', title: t('hide'),
|
|
141
|
-
onClick: () => {
|
|
142
|
-
if (audioRef.current) audioRef.current.pause()
|
|
143
|
-
setIsPlaying(false)
|
|
144
|
-
voice.set({ showPlayer: false })
|
|
145
|
-
},
|
|
146
|
-
}, xIcon()),
|
|
147
|
-
)
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
if (v.phase === 'recording') {
|
|
151
|
-
const inBrowser = !!voice.browser
|
|
152
|
-
const rec = voice.rec
|
|
153
|
-
const hint = v.mode === 'dictation' ? t('dictationPill') : t('messagePill')
|
|
154
|
-
// Live caption of what is heard right now. Until the browser emits a
|
|
155
|
-
// final chunk the text is interim and changes on screen.
|
|
156
|
-
const caption = voice.caption || (inBrowser ? '' : null)
|
|
157
|
-
return React.createElement('div', { className: 'dvo-pill' },
|
|
158
|
-
React.createElement('button', { type: 'button', className: 'dvo-pbtn', title: t('cancel'), onClick: cancelCurrent }, xIcon()),
|
|
159
|
-
inBrowser
|
|
160
|
-
? null
|
|
161
|
-
: React.createElement('canvas', { className: 'dvo-wave', ref: canvasRef, width: 720, height: 40 }),
|
|
162
|
-
React.createElement('span', { className: 'dvo-status' },
|
|
163
|
-
caption
|
|
164
|
-
? caption
|
|
165
|
-
: (voice.holding
|
|
166
|
-
? t('holdHint')
|
|
167
|
-
: (inBrowser
|
|
168
|
-
? t('listening')
|
|
169
|
-
: (rec && rec.hadSpeech ? (v.speaking ? t('speaking') : t('silence')) : hint)))),
|
|
170
|
-
React.createElement('button', { type: 'button', className: 'dvo-pbtn', title: t('stop'), onClick: stopCurrent }, stopIcon()),
|
|
171
|
-
)
|
|
172
|
-
}
|
|
173
|
-
|
|
174
|
-
if (v.phase === 'processing') {
|
|
175
|
-
return React.createElement('div', { className: 'dvo-pill' },
|
|
176
|
-
React.createElement('span', { className: 'dvo-status' }, spinIcon(), t('transcribing')))
|
|
177
|
-
}
|
|
178
|
-
|
|
179
|
-
if (v.phase === 'pending') {
|
|
180
|
-
const left = Math.max(0, Math.ceil((voice.pending ? voice.pending.leftMs : 0) / 1000))
|
|
181
|
-
if (voice.pending && voice.pending.undoOnly) {
|
|
182
|
-
// Cancel window for delayed dictation insert (#29-5).
|
|
183
|
-
return React.createElement('div', { className: 'dvo-pill' },
|
|
184
|
-
React.createElement('span', { className: 'dvo-status' }, t('undo'), ': ', left, t('secondsShort')),
|
|
185
|
-
React.createElement('button', {
|
|
186
|
-
type: 'button', className: 'dvo-pbtn', title: t('undo'),
|
|
187
|
-
onClick: async () => {
|
|
188
|
-
const msg = await undoLastInsert()
|
|
189
|
-
voice.set({ phase: 'idle', error: msg === t('undone') ? '' : msg })
|
|
190
|
-
},
|
|
191
|
-
}, xIcon()),
|
|
192
|
-
)
|
|
193
|
-
}
|
|
194
|
-
return React.createElement('div', { className: 'dvo-pill' },
|
|
195
|
-
voice.lastNote && voice.lastNote.url
|
|
196
|
-
? React.createElement('div', { className: 'dvo-audio-wrap' },
|
|
197
|
-
React.createElement('button', {
|
|
198
|
-
type: 'button', className: 'dvo-audio-play',
|
|
199
|
-
title: isPlaying ? t('pause') : t('play'),
|
|
200
|
-
onClick: () => {
|
|
201
|
-
const el = audioRef.current
|
|
202
|
-
if (!el) return
|
|
203
|
-
if (el.paused) { el.play().catch(() => {}); setIsPlaying(true) }
|
|
204
|
-
else { el.pause(); setIsPlaying(false) }
|
|
205
|
-
},
|
|
206
|
-
}, isPlaying ? pauseIcon() : playIcon()),
|
|
207
|
-
React.createElement('audio', {
|
|
208
|
-
ref: audioRef, src: voice.lastNote.url,
|
|
209
|
-
onEnded: () => setIsPlaying(false),
|
|
210
|
-
onPause: () => setIsPlaying(false),
|
|
211
|
-
onPlay: () => setIsPlaying(true),
|
|
212
|
-
}),
|
|
213
|
-
React.createElement('span', { className: 'dvo-audio-time' }, t('listenBack')),
|
|
214
|
-
)
|
|
215
|
-
: null,
|
|
216
|
-
React.createElement('span', { className: 'dvo-status' }, t('sendingIn')),
|
|
217
|
-
React.createElement('span', { className: 'dvo-count' }, left + t('secondsShort')),
|
|
218
|
-
React.createElement('button', { type: 'button', className: 'dvo-pbtn', title: t('keepPending'), onClick: keepPending }, xIcon()),
|
|
219
|
-
)
|
|
220
|
-
}
|
|
221
|
-
|
|
222
|
-
return React.createElement('div', { className: 'dvo-pill' },
|
|
223
|
-
React.createElement('span', { className: 'dvo-status dvo-err' }, warnIcon(), v.error),
|
|
224
|
-
React.createElement('button', { type: 'button', className: 'dvo-pbtn', title: t('hide'), onClick: () => voice.set({ phase: 'idle', error: '' }) }, xIcon()),
|
|
225
|
-
)
|
|
226
|
-
}
|
|
227
|
-
|
|
228
|
-
// --------------------------------------------------------------- slots
|
|
229
|
-
function registerComposer(ctx) {
|
|
230
|
-
// The hotkey lives for as long as the plugin is applied and can change
|
|
231
|
-
// without restart: the settings card broadcasts and the composer reloads.
|
|
232
|
-
ctx.effect(() => {
|
|
233
|
-
let dispose = () => {}
|
|
234
|
-
let alive = true
|
|
235
|
-
|
|
236
|
-
const reload = () => {
|
|
237
|
-
fetch('/dsh-voice/status', { cache: 'no-store' })
|
|
238
|
-
.then((res) => res.json())
|
|
239
|
-
.then((data) => {
|
|
240
|
-
if (!alive) return
|
|
241
|
-
dispose()
|
|
242
|
-
dispose = () => {}
|
|
243
|
-
const key = data && data.hotkey
|
|
244
|
-
if (key) dispose = installHotkey(ctx, key, 'message')
|
|
245
|
-
// The composer needs fresh settings without opening the card.
|
|
246
|
-
Object.assign(voice.settings, {
|
|
247
|
-
beep: !!(data && data.beep),
|
|
248
|
-
micDeviceId: String((data && data.micDeviceId) || ''),
|
|
249
|
-
historyLimit: Number(data && data.historyLimit),
|
|
250
|
-
voiceCommands: !!(data && data.voiceCommands),
|
|
251
|
-
sendDelayMs: Number(data && data.modes && data.modes.dictation && data.modes.dictation.sendDelayMs) || 0,
|
|
252
|
-
stream: !!(data && data.modes && data.modes.dictation && data.modes.dictation.stream),
|
|
253
|
-
streamChunkMs: Number(data && data.modes && data.modes.dictation && data.modes.dictation.streamChunkMs) || 1200,
|
|
254
|
-
vadAdapt: Number(data && data.modes && data.modes.dictation && data.modes.dictation.vadAdapt) || 0,
|
|
255
|
-
wakeWord: String((data && data.wakeWord) || ''),
|
|
256
|
-
bargeIn: !!(data && data.bargeIn),
|
|
257
|
-
polishSend: !!(data && data.modes && data.modes.message && data.modes.message.polishSend),
|
|
258
|
-
sessionCommands: !!(data && data.modes && data.modes.message && data.modes.message.sessionCommands),
|
|
259
|
-
noiseSuppression: data && data.noiseSuppression !== false,
|
|
260
|
-
contextGlossary: data && data.contextGlossary !== false,
|
|
261
|
-
visualizerStyle: (data && data.visualizerStyle) || 'liquid-wave',
|
|
262
|
-
})
|
|
263
|
-
})
|
|
264
|
-
.catch(() => { /* no host hint — no hotkey */ })
|
|
265
|
-
}
|
|
266
|
-
|
|
267
|
-
reload()
|
|
268
|
-
window.addEventListener('dsh-voice:settings-saved', reload)
|
|
269
|
-
return () => {
|
|
270
|
-
alive = false
|
|
271
|
-
window.removeEventListener('dsh-voice:settings-saved', reload)
|
|
272
|
-
dispose()
|
|
273
|
-
}
|
|
274
|
-
}, 'dsh-voice: hold hotkey')
|
|
275
|
-
|
|
276
|
-
ctx.slots.inject('conversation.input.right', () => ctx.slots.register(
|
|
277
|
-
{ name: 'conversation.input.right', id: '@goodandready/dsh-voice', order: 6, label: () => t('title') },
|
|
278
|
-
(props) => React.createElement(VoiceButtons, { input: props.input, inputActions: props.inputActions }),
|
|
279
|
-
))
|
|
280
|
-
ctx.slots.inject('conversation.input.dock', () => ctx.slots.register(
|
|
281
|
-
{ name: 'conversation.input.dock', id: 'dsh-voice-rec', order: 0, label: () => t('recordSlot') },
|
|
282
|
-
(props) => React.createElement(RecordPill, { input: props.input, inputActions: props.inputActions, ctx: ctx }),
|
|
283
|
-
))
|
|
284
|
-
}
|
|
285
|
-
|
|
@@ -1,126 +0,0 @@
|
|
|
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
|
-
'.cb-page{display:flex;flex-direction:column;gap:18px;padding:4px 0 24px;max-width:960px}' +
|
|
34
|
-
'.cb-section-card{border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-3);border-radius:12px;padding:16px 18px;display:flex;flex-direction:column;gap:12px}' +
|
|
35
|
-
'.cb-section-title{font-size:15px;font-weight:600;color:var(--dsw-alias-label-primary);display:flex;align-items:center;justify-content:space-between}' +
|
|
36
|
-
'.cb-section-desc{font-size:13px;color:var(--dsw-alias-label-secondary);margin-top:-4px;line-height:1.4}' +
|
|
37
|
-
'.cb-row{display:flex;flex-wrap:wrap;gap:10px;align-items:center}' +
|
|
38
|
-
'.cb-grid-2{display:grid;grid-template-columns:repeat(auto-fit, minmax(260px, 1fr));gap:12px}' +
|
|
39
|
-
'.cb-badge{font-size:11px;padding:2px 8px;border-radius:999px;border:1px solid var(--dsw-alias-border-l2);display:inline-flex;align-items:center;gap:4px;font-weight:500}' +
|
|
40
|
-
'.cb-badge-ok{border-color:var(--dsw-alias-state-success-primary);color:var(--dsw-alias-state-success-primary);background:rgba(16,185,129,0.08)}' +
|
|
41
|
-
'.cb-badge-warn{border-color:var(--dsw-alias-state-warning-primary);color:var(--dsw-alias-state-warning-primary);background:rgba(245,158,11,0.08)}' +
|
|
42
|
-
'.cb-badge-bad{border-color:var(--dsw-alias-state-error-primary);color:var(--dsw-alias-state-error-primary);background:rgba(239,68,68,0.08)}' +
|
|
43
|
-
'.cb-input{height:34px;border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-label-primary);border-radius:8px;padding:0 10px;font-size:13px;box-sizing:border-box}' +
|
|
44
|
-
'.cb-input:focus{outline:none;border-color:var(--dsw-alias-state-brand-primary)}' +
|
|
45
|
-
'.cb-btn{appearance:none;font:inherit;cursor:pointer;border:1px solid var(--dsw-alias-border-l2);border-radius:8px;padding:6px 12px;font-size:13px;background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-label-primary);font-weight:500;display:inline-flex;align-items:center;justify-content:center;gap:6px;transition:all .15s ease}' +
|
|
46
|
-
'.cb-btn:hover:not(:disabled){background:var(--dsw-alias-bg-layer-4, var(--dsw-alias-bg-layer-2));border-color:var(--dsw-alias-label-dimmed, var(--dsw-alias-border-l2))}' +
|
|
47
|
-
'.cb-btn-primary{background:var(--dsw-alias-label-primary);color:var(--dsw-alias-bg-layer-3);border-color:transparent}' +
|
|
48
|
-
'.cb-btn-primary:hover:not(:disabled){background:var(--dsw-alias-label-primary) !important;color:var(--dsw-alias-bg-layer-3) !important;opacity:0.88}' +
|
|
49
|
-
'.cb-btn-danger{color:var(--dsw-alias-state-error-primary);border-color:rgba(239,68,68,0.3)}' +
|
|
50
|
-
'.cb-btn-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;display:inline-flex;align-items:center;justify-content:center;padding:0;font-size:13px}' +
|
|
51
|
-
'.cb-btn-mini:hover:not(:disabled){background:var(--dsw-alias-bg-layer-2)}' +
|
|
52
|
-
'.cb-field{display:flex;flex-direction:column;gap:6px;padding:6px 0;font-size:13px;color:var(--dsw-alias-label-primary)}' +
|
|
53
|
-
'.cb-field select,.cb-field input[type="text"],.cb-field input[type="number"],.cb-field textarea{height:34px;border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-label-primary);border-radius:8px;padding:0 10px;font-size:13px;box-sizing:border-box}' +
|
|
54
|
-
'.cb-field textarea{height:auto;padding:8px 10px;font-family:inherit}' +
|
|
55
|
-
'.cb-check-label{display:flex;align-items:center;gap:8px;cursor:pointer;font-size:13px;color:var(--dsw-alias-label-primary);user-select:none}' +
|
|
56
|
-
'.cb-check-label input[type="checkbox"]{width:16px;height:16px;cursor:pointer}' +
|
|
57
|
-
'.cb-alert-err{padding:10px 14px;border-radius:8px;background:rgba(239,68,68,0.1);color:var(--dsw-alias-state-error-primary);font-size:13px}' +
|
|
58
|
-
'.dvs-wrap{display:flex;flex-direction:column;gap:18px;padding:4px 0;max-width:960px}' +
|
|
59
|
-
'.dvs-block{display:flex;flex-direction:column;gap:10px}' +
|
|
60
|
-
'.dvs-h{font-size:14px;font-weight:600;color:var(--dsw-alias-label-primary)}' +
|
|
61
|
-
'.dvs-sub{font-size:12px;color:var(--dsw-alias-label-secondary)}' +
|
|
62
|
-
'.dvs-row{display:flex;gap:8px;align-items:center}' +
|
|
63
|
-
'.dvs-row select,.dvs-row input{background:var(--dsw-alias-bg-layer-2);border:1px solid var(--dsw-alias-border-l2);color:var(--dsw-alias-label-primary);border-radius:8px;padding:6px 10px;font-size:13px}' +
|
|
64
|
-
'.dvs-row .dvs-model{flex:1}' +
|
|
65
|
-
'.dvs-card{display:flex;flex-direction:column;gap:8px;border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-2);border-radius:10px;padding:12px}' +
|
|
66
|
-
'.dvs-card input,.dvs-card select{background:var(--dsw-alias-bg-layer-3);border:1px solid var(--dsw-alias-border-l1);color:var(--dsw-alias-label-primary);border-radius:6px;padding:6px 8px;font-size:13px}' +
|
|
67
|
-
'.dvs-wait{font-size:13px;color:var(--dsw-alias-label-secondary);line-height:1.5;max-width:520px}' +
|
|
68
|
-
'.dvs-ok{font-size:12px;color:var(--dsw-alias-state-success-primary)}' +
|
|
69
|
-
'.dvs-bad{font-size:12px;color:var(--dsw-alias-state-error-primary)}' +
|
|
70
|
-
'.dvo-pcard{list-style:none;border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-3);border-radius:12px}' +
|
|
71
|
-
'.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}' +
|
|
72
|
-
'.dvo-pheadtext{display:flex;flex-direction:column;gap:2px;flex:1;min-width:0}' +
|
|
73
|
-
'.dvo-ptitle{color:var(--dsw-alias-label-primary);font-size:15px;font-weight:600;line-height:1.4}' +
|
|
74
|
-
'.dvo-pdesc{color:var(--dsw-alias-label-secondary);font-size:13px}' +
|
|
75
|
-
'.dvo-pchev{flex:none;display:flex;color:var(--dsw-alias-label-secondary);transition:transform .15s ease}' +
|
|
76
|
-
'.dvo-pcardOpen .dvo-pchev{transform:rotate(180deg)}' +
|
|
77
|
-
'.dvo-pbody{border-top:1px solid var(--dsw-alias-border-l2);margin:0 16px;padding-bottom:12px}'
|
|
78
|
-
const setCssId = 'dsh-voice/settings.module.css'
|
|
79
|
-
if (typeof document !== 'undefined' && !document.querySelector('style[data-dsh-plugin="dsh-voice"][data-plugin-css="' + setCssId + '"]')) {
|
|
80
|
-
const tag = document.createElement('style')
|
|
81
|
-
tag.textContent = SET_CSS
|
|
82
|
-
tag.setAttribute('data-dsh-plugin', 'dsh-voice')
|
|
83
|
-
tag.dataset.pluginCss = setCssId
|
|
84
|
-
document.head.appendChild(tag)
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
function createErrorBoundary() {
|
|
88
|
-
if (!React || typeof React.Component !== 'function') {
|
|
89
|
-
return function NoopBoundary(props) { return props?.children || null }
|
|
90
|
-
}
|
|
91
|
-
return class ErrorBoundary extends React.Component {
|
|
92
|
-
constructor(props) {
|
|
93
|
-
super(props)
|
|
94
|
-
this.state = { hasError: false, error: null }
|
|
95
|
-
}
|
|
96
|
-
static getDerivedStateFromError(error) {
|
|
97
|
-
return { hasError: true, error }
|
|
98
|
-
}
|
|
99
|
-
componentDidCatch(error, errorInfo) {
|
|
100
|
-
console.error('[dsh-voice] React Error:', error, errorInfo)
|
|
101
|
-
}
|
|
102
|
-
render() {
|
|
103
|
-
if (this.state.hasError) {
|
|
104
|
-
return React.createElement(
|
|
105
|
-
'div',
|
|
106
|
-
{ className: 'cb-alert-err', style: { margin: '12px 0', padding: '14px', borderRadius: '8px' } },
|
|
107
|
-
React.createElement('div', { style: { fontWeight: 600, marginBottom: '6px' } }, '⚠️ ' + t('uiError')),
|
|
108
|
-
React.createElement('div', { style: { fontSize: '12px', wordBreak: 'break-all' } }, String(this.state.error?.message || this.state.error)),
|
|
109
|
-
React.createElement(
|
|
110
|
-
'button',
|
|
111
|
-
{
|
|
112
|
-
type: 'button',
|
|
113
|
-
className: 'cb-btn',
|
|
114
|
-
style: { marginTop: '10px', fontSize: '12px', padding: '4px 10px' },
|
|
115
|
-
onClick: () => this.setState({ hasError: false, error: null }),
|
|
116
|
-
},
|
|
117
|
-
t('retry'),
|
|
118
|
-
),
|
|
119
|
-
)
|
|
120
|
-
}
|
|
121
|
-
return this.props?.children || null
|
|
122
|
-
}
|
|
123
|
-
}
|
|
124
|
-
}
|
|
125
|
-
const ErrorBoundary = createErrorBoundary()
|
|
126
|
-
|
|
@@ -1,88 +0,0 @@
|
|
|
1
|
-
// One chain editor: provider+model rows with reordering.
|
|
2
|
-
function ChainEditor(props) {
|
|
3
|
-
const rows = Array.isArray(props.value) ? props.value : []
|
|
4
|
-
const change = (i, patch) => {
|
|
5
|
-
const next = rows.map((r, k) => (k === i ? Object.assign({}, r, patch) : r))
|
|
6
|
-
props.onChange(next)
|
|
7
|
-
}
|
|
8
|
-
const move = (i, delta) => {
|
|
9
|
-
const j = i + delta
|
|
10
|
-
if (j < 0 || j >= rows.length) return
|
|
11
|
-
const next = rows.slice()
|
|
12
|
-
const tmp = next[i]; next[i] = next[j]; next[j] = tmp
|
|
13
|
-
props.onChange(next)
|
|
14
|
-
}
|
|
15
|
-
const remove = (i) => props.onChange(rows.filter((_, k) => k !== i))
|
|
16
|
-
const add = () => props.onChange(rows.concat([{ provider: 'local-whisper', model: '' }]))
|
|
17
|
-
const options = Array.isArray(props.options) && props.options.length ? props.options : BUILTIN
|
|
18
|
-
|
|
19
|
-
return React.createElement('div', { className: 'dvs-block' },
|
|
20
|
-
rows.map((row, i) => React.createElement('div', { className: 'dvs-row', key: i },
|
|
21
|
-
React.createElement('select', {
|
|
22
|
-
value: row.provider, disabled: !props.writable,
|
|
23
|
-
onChange: (e) => change(i, { provider: e.target.value }),
|
|
24
|
-
}, (options.indexOf(row.provider) < 0 ? options.concat([row.provider]) : options)
|
|
25
|
-
.map((p) => React.createElement('option', { key: p, value: p }, p))),
|
|
26
|
-
React.createElement('input', {
|
|
27
|
-
className: 'dvs-model', value: row.model || '', disabled: !props.writable,
|
|
28
|
-
placeholder: modelHint(row.provider), onChange: (e) => change(i, { model: e.target.value }),
|
|
29
|
-
}),
|
|
30
|
-
React.createElement('button', { type: 'button', className: 'dvs-mini', title: t('up'), disabled: !props.writable, onClick: () => move(i, -1) }, '↑'),
|
|
31
|
-
React.createElement('button', { type: 'button', className: 'dvs-mini', title: t('down'), disabled: !props.writable, onClick: () => move(i, 1) }, '↓'),
|
|
32
|
-
React.createElement('button', { type: 'button', className: 'dvs-mini', title: t('remove'), disabled: !props.writable, onClick: () => remove(i) }, '×'),
|
|
33
|
-
)),
|
|
34
|
-
React.createElement('div', { className: 'dvs-row' },
|
|
35
|
-
React.createElement('button', { type: 'button', className: 'dvs-mini', title: t('addProvider'), disabled: !props.writable, onClick: add }, '+'),
|
|
36
|
-
React.createElement('span', { className: 'dvs-sub' }, t('chainHint')),
|
|
37
|
-
),
|
|
38
|
-
)
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
// Custom providers: name, API template, where to call, how to authorize.
|
|
42
|
-
function CustomEditor(props) {
|
|
43
|
-
const rows = Array.isArray(props.value) ? props.value : []
|
|
44
|
-
const change = (i, patch) => props.onChange(rows.map((r, k) => (k === i ? Object.assign({}, r, patch) : r)))
|
|
45
|
-
const remove = (i) => props.onChange(rows.filter((_, k) => k !== i))
|
|
46
|
-
const add = () => props.onChange(rows.concat([
|
|
47
|
-
{ key: '', template: 'openai-transcriptions', baseURL: '', model: '', keyEnv: '', prompt: '' },
|
|
48
|
-
]))
|
|
49
|
-
const field = (i, row, name, placeholder, wide) => React.createElement('input', {
|
|
50
|
-
className: wide ? 'dvs-model' : '', value: row[name] || '', placeholder: placeholder,
|
|
51
|
-
disabled: !props.writable, onChange: (e) => change(i, { [name]: e.target.value }),
|
|
52
|
-
})
|
|
53
|
-
|
|
54
|
-
return React.createElement('div', { className: 'dvs-block' },
|
|
55
|
-
rows.map((row, i) => React.createElement('div', { className: 'dvs-card', key: i },
|
|
56
|
-
React.createElement('div', { className: 'dvs-row' },
|
|
57
|
-
field(i, row, 'key', t('customName')),
|
|
58
|
-
React.createElement('select', {
|
|
59
|
-
value: row.template || 'openai-transcriptions', disabled: !props.writable,
|
|
60
|
-
onChange: (e) => change(i, { template: e.target.value }),
|
|
61
|
-
}, TEMPLATES.map((t) => React.createElement('option', { key: t, value: t }, t))),
|
|
62
|
-
React.createElement('button', {
|
|
63
|
-
type: 'button', className: 'dvs-mini', title: t('remove'),
|
|
64
|
-
disabled: !props.writable, onClick: () => remove(i),
|
|
65
|
-
}, '\u00d7'),
|
|
66
|
-
),
|
|
67
|
-
React.createElement('div', { className: 'dvs-row' },
|
|
68
|
-
field(i, row, 'baseURL', 'https://openrouter.ai/api/v1', true),
|
|
69
|
-
),
|
|
70
|
-
React.createElement('div', { className: 'dvs-row' },
|
|
71
|
-
field(i, row, 'model', t('customModel'), true),
|
|
72
|
-
field(i, row, 'keyEnv', t('customKeyName')),
|
|
73
|
-
),
|
|
74
|
-
row.template === 'openai-chat-audio'
|
|
75
|
-
? React.createElement('div', { className: 'dvs-row' },
|
|
76
|
-
field(i, row, 'prompt', t('customModelHint'), true))
|
|
77
|
-
: null,
|
|
78
|
-
)),
|
|
79
|
-
React.createElement('div', { className: 'dvs-row' },
|
|
80
|
-
React.createElement('button', {
|
|
81
|
-
type: 'button', className: 'dvs-mini', title: t('addCustom'),
|
|
82
|
-
disabled: !props.writable, onClick: add,
|
|
83
|
-
}, '+'),
|
|
84
|
-
React.createElement('span', { className: 'dvs-sub' },
|
|
85
|
-
t('openrouterWarning')),
|
|
86
|
-
),
|
|
87
|
-
)
|
|
88
|
-
}
|