@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.
@@ -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
- }