@goodandready/dsh-voice 0.8.22 → 0.8.24

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.
@@ -6,7 +6,7 @@
6
6
  '.dvo-pill{display:flex;align-items:center;gap:10px;height:52px;border-radius:26px;background:var(--dsw-alias-bg-layer-1);border:1px solid var(--dsw-alias-border-l1);padding:0 14px;width:100%;max-width:720px;margin:0 auto;box-shadow:0 8px 24px rgba(0,0,0,.18);box-sizing:border-box}' +
7
7
  '.dvo-pbtn{display:flex;align-items:center;justify-content:center;width:34px;height:34px;border-radius:50%;cursor:pointer;padding:0;flex:none;box-sizing:border-box;border:1px solid var(--dsw-alias-border-l2);background:transparent;color:var(--dsw-alias-label-primary)}' +
8
8
  '.dvo-pbtn:hover{background:var(--dsw-alias-bg-layer-2)}' +
9
- '.dvo-wave{flex:1;height:40px;width:100%;color:var(--dsw-alias-label-primary)}' +
9
+ '.dvo-wave{flex:1;min-width:0;max-width:100%;height:40px;width:100%;color:var(--dsw-alias-label-primary)}' +
10
10
  '.dvo-status{display:flex;align-items:center;gap:8px;color:var(--dsw-alias-label-secondary);font-size:13px;flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}' +
11
11
  '.dvo-err{color:var(--dsw-alias-state-error-primary)}' +
12
12
  '.dvo-count{font-variant-numeric:tabular-nums;font-size:13px;color:var(--dsw-alias-label-secondary)}' +
@@ -0,0 +1,126 @@
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
+
@@ -0,0 +1,88 @@
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
+ }
@@ -1,218 +1,4 @@
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
- // One chain editor: provider+model rows with reordering.
88
- function ChainEditor(props) {
89
- const rows = Array.isArray(props.value) ? props.value : []
90
- const change = (i, patch) => {
91
- const next = rows.map((r, k) => (k === i ? Object.assign({}, r, patch) : r))
92
- props.onChange(next)
93
- }
94
- const move = (i, delta) => {
95
- const j = i + delta
96
- if (j < 0 || j >= rows.length) return
97
- const next = rows.slice()
98
- const tmp = next[i]; next[i] = next[j]; next[j] = tmp
99
- props.onChange(next)
100
- }
101
- const remove = (i) => props.onChange(rows.filter((_, k) => k !== i))
102
- const add = () => props.onChange(rows.concat([{ provider: 'local-whisper', model: '' }]))
103
- const options = Array.isArray(props.options) && props.options.length ? props.options : BUILTIN
104
-
105
- return React.createElement('div', { className: 'dvs-block' },
106
- rows.map((row, i) => React.createElement('div', { className: 'dvs-row', key: i },
107
- React.createElement('select', {
108
- value: row.provider, disabled: !props.writable,
109
- onChange: (e) => change(i, { provider: e.target.value }),
110
- }, (options.indexOf(row.provider) < 0 ? options.concat([row.provider]) : options)
111
- .map((p) => React.createElement('option', { key: p, value: p }, p))),
112
- React.createElement('input', {
113
- className: 'dvs-model', value: row.model || '', disabled: !props.writable,
114
- placeholder: modelHint(row.provider), onChange: (e) => change(i, { model: e.target.value }),
115
- }),
116
- React.createElement('button', { type: 'button', className: 'dvs-mini', title: t('up'), disabled: !props.writable, onClick: () => move(i, -1) }, '↑'),
117
- React.createElement('button', { type: 'button', className: 'dvs-mini', title: t('down'), disabled: !props.writable, onClick: () => move(i, 1) }, '↓'),
118
- React.createElement('button', { type: 'button', className: 'dvs-mini', title: t('remove'), disabled: !props.writable, onClick: () => remove(i) }, '×'),
119
- )),
120
- React.createElement('div', { className: 'dvs-row' },
121
- React.createElement('button', { type: 'button', className: 'dvs-mini', title: t('addProvider'), disabled: !props.writable, onClick: add }, '+'),
122
- React.createElement('span', { className: 'dvs-sub' }, t('chainHint')),
123
- ),
124
- )
125
- }
126
-
127
- // Custom providers: name, API template, where to call, how to authorize.
128
- function CustomEditor(props) {
129
- const rows = Array.isArray(props.value) ? props.value : []
130
- const change = (i, patch) => props.onChange(rows.map((r, k) => (k === i ? Object.assign({}, r, patch) : r)))
131
- const remove = (i) => props.onChange(rows.filter((_, k) => k !== i))
132
- const add = () => props.onChange(rows.concat([
133
- { key: '', template: 'openai-transcriptions', baseURL: '', model: '', keyEnv: '', prompt: '' },
134
- ]))
135
- const field = (i, row, name, placeholder, wide) => React.createElement('input', {
136
- className: wide ? 'dvs-model' : '', value: row[name] || '', placeholder: placeholder,
137
- disabled: !props.writable, onChange: (e) => change(i, { [name]: e.target.value }),
138
- })
139
-
140
- return React.createElement('div', { className: 'dvs-block' },
141
- rows.map((row, i) => React.createElement('div', { className: 'dvs-card', key: i },
142
- React.createElement('div', { className: 'dvs-row' },
143
- field(i, row, 'key', t('customName')),
144
- React.createElement('select', {
145
- value: row.template || 'openai-transcriptions', disabled: !props.writable,
146
- onChange: (e) => change(i, { template: e.target.value }),
147
- }, TEMPLATES.map((t) => React.createElement('option', { key: t, value: t }, t))),
148
- React.createElement('button', {
149
- type: 'button', className: 'dvs-mini', title: t('remove'),
150
- disabled: !props.writable, onClick: () => remove(i),
151
- }, '\u00d7'),
152
- ),
153
- React.createElement('div', { className: 'dvs-row' },
154
- field(i, row, 'baseURL', 'https://openrouter.ai/api/v1', true),
155
- ),
156
- React.createElement('div', { className: 'dvs-row' },
157
- field(i, row, 'model', t('customModel'), true),
158
- field(i, row, 'keyEnv', t('customKeyName')),
159
- ),
160
- row.template === 'openai-chat-audio'
161
- ? React.createElement('div', { className: 'dvs-row' },
162
- field(i, row, 'prompt', t('customModelHint'), true))
163
- : null,
164
- )),
165
- React.createElement('div', { className: 'dvs-row' },
166
- React.createElement('button', {
167
- type: 'button', className: 'dvs-mini', title: t('addCustom'),
168
- disabled: !props.writable, onClick: add,
169
- }, '+'),
170
- React.createElement('span', { className: 'dvs-sub' },
171
- t('openrouterWarning')),
172
- ),
173
- )
174
- }
175
-
176
- function createErrorBoundary() {
177
- if (!React || typeof React.Component !== 'function') {
178
- return function NoopBoundary(props) { return props?.children || null }
179
- }
180
- return class ErrorBoundary extends React.Component {
181
- constructor(props) {
182
- super(props)
183
- this.state = { hasError: false, error: null }
184
- }
185
- static getDerivedStateFromError(error) {
186
- return { hasError: true, error }
187
- }
188
- componentDidCatch(error, errorInfo) {
189
- console.error('[dsh-voice] React Error:', error, errorInfo)
190
- }
191
- render() {
192
- if (this.state.hasError) {
193
- return React.createElement(
194
- 'div',
195
- { className: 'cb-alert-err', style: { margin: '12px 0', padding: '14px', borderRadius: '8px' } },
196
- React.createElement('div', { style: { fontWeight: 600, marginBottom: '6px' } }, '⚠️ ' + t('uiError')),
197
- React.createElement('div', { style: { fontSize: '12px', wordBreak: 'break-all' } }, String(this.state.error?.message || this.state.error)),
198
- React.createElement(
199
- 'button',
200
- {
201
- type: 'button',
202
- className: 'cb-btn',
203
- style: { marginTop: '10px', fontSize: '12px', padding: '4px 10px' },
204
- onClick: () => this.setState({ hasError: false, error: null }),
205
- },
206
- t('retry'),
207
- ),
208
- )
209
- }
210
- return this.props?.children || null
211
- }
212
- }
213
- }
214
- const ErrorBoundary = createErrorBoundary()
215
-
1
+ // ------------------------------------------------------- settings voice section
216
2
  function VoiceSection(props) {
217
3
  const t = (props && props.t) || moduleT
218
4
  const ctx = props.ctx
@@ -732,41 +518,3 @@
732
518
  )
733
519
  }
734
520
 
735
- // Card in Settings → Plugins → Plugin settings (#18)
736
- function PluginCard(props) {
737
- const [open, setOpen] = React.useState(false)
738
- const tt = (props && props.t) || t
739
- const title = tt('title')
740
- return React.createElement(ErrorBoundary, null,
741
- React.createElement('li',
742
- { className: open ? 'dvo-pcard dvo-pcardOpen' : 'dvo-pcard' },
743
- React.createElement('button',
744
- {
745
- type: 'button', className: 'dvo-phead',
746
- 'aria-expanded': open ? 'true' : 'false',
747
- 'aria-label': (open ? tt('collapse') : tt('expand')) + ': ' + title,
748
- onClick: () => setOpen((v) => !v),
749
- },
750
- React.createElement('span', { className: 'dvo-pheadtext' },
751
- React.createElement('span', { className: 'dvo-ptitle' }, title),
752
- React.createElement('span', { className: 'dvo-pdesc' }, tt('cardHint')),
753
- ),
754
- React.createElement('span', { className: 'dvo-pchev' }, chevronIcon()),
755
- ),
756
- open ? React.createElement('div', { className: 'dvo-pbody' }, React.createElement(VoiceSection, props)) : null,
757
- ),
758
- )
759
- }
760
-
761
- function registerSettings(ctx) {
762
- ctx.slots.inject('settings.plugin.item', () => ctx.slots.register(
763
- {
764
- name: 'settings.plugin.item',
765
- key: NS,
766
- locale: NS,
767
- inject: () => ({ ctx: ctx }),
768
- },
769
- PluginCard,
770
- ))
771
- }
772
-
@@ -0,0 +1,38 @@
1
+ // Card in Settings → Plugins → Plugin settings (#18)
2
+ function PluginCard(props) {
3
+ const [open, setOpen] = React.useState(false)
4
+ const tt = (props && props.t) || t
5
+ const title = tt('title')
6
+ return React.createElement(ErrorBoundary, null,
7
+ React.createElement('li',
8
+ { className: open ? 'dvo-pcard dvo-pcardOpen' : 'dvo-pcard' },
9
+ React.createElement('button',
10
+ {
11
+ type: 'button', className: 'dvo-phead',
12
+ 'aria-expanded': open ? 'true' : 'false',
13
+ 'aria-label': (open ? tt('collapse') : tt('expand')) + ': ' + title,
14
+ onClick: () => setOpen((v) => !v),
15
+ },
16
+ React.createElement('span', { className: 'dvo-pheadtext' },
17
+ React.createElement('span', { className: 'dvo-ptitle' }, title),
18
+ React.createElement('span', { className: 'dvo-pdesc' }, tt('cardHint')),
19
+ ),
20
+ React.createElement('span', { className: 'dvo-pchev' }, chevronIcon()),
21
+ ),
22
+ open ? React.createElement('div', { className: 'dvo-pbody' }, React.createElement(VoiceSection, props)) : null,
23
+ ),
24
+ )
25
+ }
26
+
27
+ function registerSettings(ctx) {
28
+ ctx.slots.inject('settings.plugin.item', () => ctx.slots.register(
29
+ {
30
+ name: 'settings.plugin.item',
31
+ key: NS,
32
+ locale: NS,
33
+ inject: () => ({ ctx: ctx }),
34
+ },
35
+ PluginCard,
36
+ ))
37
+ }
38
+
package/lib/client.js CHANGED
@@ -192,7 +192,7 @@ window.__ModuleLoader__.load({
192
192
  '.dvo-pill{display:flex;align-items:center;gap:10px;height:52px;border-radius:26px;background:var(--dsw-alias-bg-layer-1);border:1px solid var(--dsw-alias-border-l1);padding:0 14px;width:100%;max-width:720px;margin:0 auto;box-shadow:0 8px 24px rgba(0,0,0,.18);box-sizing:border-box}' +
193
193
  '.dvo-pbtn{display:flex;align-items:center;justify-content:center;width:34px;height:34px;border-radius:50%;cursor:pointer;padding:0;flex:none;box-sizing:border-box;border:1px solid var(--dsw-alias-border-l2);background:transparent;color:var(--dsw-alias-label-primary)}' +
194
194
  '.dvo-pbtn:hover{background:var(--dsw-alias-bg-layer-2)}' +
195
- '.dvo-wave{flex:1;height:40px;width:100%;color:var(--dsw-alias-label-primary)}' +
195
+ '.dvo-wave{flex:1;min-width:0;max-width:100%;height:40px;width:100%;color:var(--dsw-alias-label-primary)}' +
196
196
  '.dvo-status{display:flex;align-items:center;gap:8px;color:var(--dsw-alias-label-secondary);font-size:13px;flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}' +
197
197
  '.dvo-err{color:var(--dsw-alias-state-error-primary)}' +
198
198
  '.dvo-count{font-variant-numeric:tabular-nums;font-size:13px;color:var(--dsw-alias-label-secondary)}' +
@@ -1416,6 +1416,46 @@ window.__ModuleLoader__.load({
1416
1416
  document.head.appendChild(tag)
1417
1417
  }
1418
1418
 
1419
+ function createErrorBoundary() {
1420
+ if (!React || typeof React.Component !== 'function') {
1421
+ return function NoopBoundary(props) { return props?.children || null }
1422
+ }
1423
+ return class ErrorBoundary extends React.Component {
1424
+ constructor(props) {
1425
+ super(props)
1426
+ this.state = { hasError: false, error: null }
1427
+ }
1428
+ static getDerivedStateFromError(error) {
1429
+ return { hasError: true, error }
1430
+ }
1431
+ componentDidCatch(error, errorInfo) {
1432
+ console.error('[dsh-voice] React Error:', error, errorInfo)
1433
+ }
1434
+ render() {
1435
+ if (this.state.hasError) {
1436
+ return React.createElement(
1437
+ 'div',
1438
+ { className: 'cb-alert-err', style: { margin: '12px 0', padding: '14px', borderRadius: '8px' } },
1439
+ React.createElement('div', { style: { fontWeight: 600, marginBottom: '6px' } }, '⚠️ ' + t('uiError')),
1440
+ React.createElement('div', { style: { fontSize: '12px', wordBreak: 'break-all' } }, String(this.state.error?.message || this.state.error)),
1441
+ React.createElement(
1442
+ 'button',
1443
+ {
1444
+ type: 'button',
1445
+ className: 'cb-btn',
1446
+ style: { marginTop: '10px', fontSize: '12px', padding: '4px 10px' },
1447
+ onClick: () => this.setState({ hasError: false, error: null }),
1448
+ },
1449
+ t('retry'),
1450
+ ),
1451
+ )
1452
+ }
1453
+ return this.props?.children || null
1454
+ }
1455
+ }
1456
+ }
1457
+ const ErrorBoundary = createErrorBoundary()
1458
+
1419
1459
  // One chain editor: provider+model rows with reordering.
1420
1460
  function ChainEditor(props) {
1421
1461
  const rows = Array.isArray(props.value) ? props.value : []
@@ -1504,47 +1544,7 @@ window.__ModuleLoader__.load({
1504
1544
  ),
1505
1545
  )
1506
1546
  }
1507
-
1508
- function createErrorBoundary() {
1509
- if (!React || typeof React.Component !== 'function') {
1510
- return function NoopBoundary(props) { return props?.children || null }
1511
- }
1512
- return class ErrorBoundary extends React.Component {
1513
- constructor(props) {
1514
- super(props)
1515
- this.state = { hasError: false, error: null }
1516
- }
1517
- static getDerivedStateFromError(error) {
1518
- return { hasError: true, error }
1519
- }
1520
- componentDidCatch(error, errorInfo) {
1521
- console.error('[dsh-voice] React Error:', error, errorInfo)
1522
- }
1523
- render() {
1524
- if (this.state.hasError) {
1525
- return React.createElement(
1526
- 'div',
1527
- { className: 'cb-alert-err', style: { margin: '12px 0', padding: '14px', borderRadius: '8px' } },
1528
- React.createElement('div', { style: { fontWeight: 600, marginBottom: '6px' } }, '⚠️ ' + t('uiError')),
1529
- React.createElement('div', { style: { fontSize: '12px', wordBreak: 'break-all' } }, String(this.state.error?.message || this.state.error)),
1530
- React.createElement(
1531
- 'button',
1532
- {
1533
- type: 'button',
1534
- className: 'cb-btn',
1535
- style: { marginTop: '10px', fontSize: '12px', padding: '4px 10px' },
1536
- onClick: () => this.setState({ hasError: false, error: null }),
1537
- },
1538
- t('retry'),
1539
- ),
1540
- )
1541
- }
1542
- return this.props?.children || null
1543
- }
1544
- }
1545
- }
1546
- const ErrorBoundary = createErrorBoundary()
1547
-
1547
+ // ------------------------------------------------------- settings voice section
1548
1548
  function VoiceSection(props) {
1549
1549
  const t = (props && props.t) || moduleT
1550
1550
  const ctx = props.ctx
@@ -0,0 +1,28 @@
1
+ // Pure HTTP helpers shared by host routes. No cordis, no network.
2
+
3
+ export function writeJson(res, code, body) {
4
+ try {
5
+ res.writeHead(code, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' })
6
+ res.end(JSON.stringify(body))
7
+ } catch { /* socket may already be closed */ }
8
+ }
9
+
10
+ /**
11
+ * Collect request body with a hard size cap.
12
+ * @param {import('node:stream').Readable} req
13
+ * @param {number} maxBytes
14
+ * @returns {Promise<Buffer>}
15
+ */
16
+ export function readBody(req, maxBytes) {
17
+ return new Promise((resolve, reject) => {
18
+ const chunks = []
19
+ let size = 0
20
+ req.on('data', (c) => {
21
+ size += c.length
22
+ if (size > maxBytes) { reject(new Error('body too large')); req.destroy(); return }
23
+ chunks.push(c)
24
+ })
25
+ req.on('end', () => resolve(Buffer.concat(chunks)))
26
+ req.on('error', reject)
27
+ })
28
+ }
package/lib/index.js CHANGED
@@ -22,7 +22,10 @@ import { makeProviders, PROVIDER_KEYS, PRESET_KEYS, KNOWN_KEYS, DEFAULT_MODELS,
22
22
  import { toWav16k } from './wav.js'
23
23
  import { normalizePhrase } from './normalize.js'
24
24
  import { createStatsTracker, mergeContextVocabulary } from './stats.js'
25
+ import { writeJson, readBody } from './http-util.js'
26
+ import { createPolishText } from './polish.js'
25
27
  import { createUserMessage } from '@deepseek-ai/dsh-llm'
28
+ import { buildProviderOrder, sessionCommand } from './transcribe-core.js'
26
29
 
27
30
  function isAutoLang(lang) {
28
31
  return !lang || lang === 'auto' || String(lang).includes(',')
@@ -164,26 +167,6 @@ const MIME_BY_EXT = {
164
167
  '.ogg': 'audio/ogg', '.oga': 'audio/ogg', '.flac': 'audio/flac', '.webm': 'audio/webm', '.aac': 'audio/aac',
165
168
  }
166
169
 
167
- function writeJson(res, code, body) {
168
- try {
169
- res.writeHead(code, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' })
170
- res.end(JSON.stringify(body))
171
- } catch { /* socket may already be closed */ }
172
- }
173
-
174
- function readBody(req, maxBytes) {
175
- return new Promise((resolve, reject) => {
176
- const chunks = []
177
- let size = 0
178
- req.on('data', (c) => {
179
- size += c.length
180
- if (size > maxBytes) { reject(new Error('body too large')); req.destroy(); return }
181
- chunks.push(c)
182
- })
183
- req.on('end', () => resolve(Buffer.concat(chunks)))
184
- req.on('error', reject)
185
- })
186
- }
187
170
 
188
171
  export function apply(ctx, baseConfig) {
189
172
  let child = null
@@ -297,22 +280,13 @@ export function apply(ctx, baseConfig) {
297
280
  const cfg = live()
298
281
  const customKeys = (Array.isArray(cfg.customProviders) ? cfg.customProviders : [])
299
282
  .map((c) => String(c && c.key || '').trim()).filter(Boolean)
300
- // localOnly mode: restrict the chain to local-whisper.
301
- const chain = cfg.localOnly
302
- ? (modeCfg.chain || []).filter((e) => e.provider === 'local-whisper')
303
- : (modeCfg.chain || [])
304
- const models = {}
305
- const order = []
306
- for (const entry of chain) {
307
- if (!KNOWN_KEYS.includes(entry.provider) && !customKeys.includes(entry.provider)) continue
308
- order.push(entry.provider)
309
- // Custom provider default models live in their spec; makeProviders fills
310
- // them in — empty here means "use the provider default".
311
- models[entry.provider] = entry.model || DEFAULT_MODELS[entry.provider] || ''
312
- }
313
- if (cfg.localOnly && order.length === 0) {
314
- throw new Error('localOnly mode is on, but local-whisper is not in the chain')
315
- }
283
+ const { order, models } = buildProviderOrder({
284
+ localOnly: !!cfg.localOnly,
285
+ chain: modeCfg.chain,
286
+ customKeys,
287
+ knownKeys: KNOWN_KEYS,
288
+ defaultModels: DEFAULT_MODELS,
289
+ })
316
290
 
317
291
  const vocab = cfg.contextGlossary
318
292
  ? mergeContextVocabulary(cfg.vocabulary, contextWords)
@@ -327,75 +301,18 @@ export function apply(ctx, baseConfig) {
327
301
 
328
302
  // Transcript polish (#35) with local LLM support (#47).
329
303
  // Errors/timeouts do not block: return the raw text.
330
- async function polishText(text, modeCfg, signal) {
331
- const enable = modeCfg && (modeCfg.polish === true || modeCfg.polishSend === true)
332
- if (!enable || !text) return text
333
- const cfg = live()
334
- const ask =
335
- 'Fix the punctuation and spelling of this dictated text and split it into '
336
- + 'paragraphs where the speaker changes topic. Remove filler words ("um", "uh", '
337
- + '"ээ", "ну", "как бы"). Keep the original language, wording and meaning. '
338
- + 'Reply with the polished text only:\n\n' + text
339
- try {
340
- // Local OpenAI-compatible endpoint (#47) when configured.
341
- if (cfg.polishBaseUrl) {
342
- const headers = { 'content-type': 'application/json' }
343
- if (cfg.polishKeyEnv) {
344
- const key = await resolveKey(cfg.polishKeyEnv)
345
- if (key) headers.authorization = 'Bearer ' + key
346
- }
347
- const res = await fetch(
348
- String(cfg.polishBaseUrl).replace(/\/+$/, '') + '/chat/completions',
349
- { method: 'POST', headers, signal, body: JSON.stringify({
350
- model: cfg.polishModel || 'local-model',
351
- messages: [{ role: 'user', content: ask }],
352
- }) })
353
- if (!res.ok) return text
354
- const data = await res.json().catch(() => null)
355
- const pick = data && data.choices && data.choices[0] && data.choices[0].message
356
- && data.choices[0].message.content
357
- return (pick && String(pick).trim()) || text
358
- }
359
- // Harness model (DSH 0.1.2-alpha.1 API).
360
- const llm = ctx.llm
361
- if (!llm || typeof llm.stream !== 'function') return text
362
- // provider/model are required in GenerateOptions; fall back to
363
- // agentDefaultModel when not set explicitly.
364
- const sel = (ctx.get && ctx.get('agentDefaultModel') && ctx.get('agentDefaultModel').currentSelection)
365
- ? ctx.get('agentDefaultModel').currentSelection()
366
- : null
367
- const provider = cfg.polishProvider || (sel && sel.provider) || ''
368
- const model = cfg.polishModelId || (sel && sel.model) || ''
369
- if (!provider || !model) return text
370
- let acc = ''
371
- for await (const chunk of llm.stream({
372
- provider, model,
373
- messages: [createUserMessage({ content: [{ type: 'text', text: ask }], source: { kind: 'user' } })],
374
- signal,
375
- })) {
376
- if (chunk && chunk.type === 'text-delta' && typeof chunk.text === 'string') acc += chunk.text
377
- }
378
- const clean = acc.trim()
379
- return clean || text
380
- } catch { return text }
381
- }
304
+ const polishText = createPolishText({
305
+ resolveKey,
306
+ fetchImpl: (...args) => fetch(...args),
307
+ getConfig: live,
308
+ llm: ctx.llm,
309
+ createUserMessage,
310
+ getAgentDefaultModel: () => (ctx.get && ctx.get('agentDefaultModel') && ctx.get('agentDefaultModel').currentSelection)
311
+ ? ctx.get('agentDefaultModel').currentSelection()
312
+ : null,
313
+ })
382
314
 
383
- // Clean session voice commands (#48). Returns the command name or null.
384
- // Matched on the host so they never enter the composer text.
385
- const SESSION_COMMANDS = [
386
- { re: /^(отправь|отправить|пошли|send)\s*[.!?]*$/i, cmd: 'send' },
387
- { re: /^(отмени|отмена|cancel|отменить)\s*[.!?]*$/i, cmd: 'cancel' },
388
- { re: /^(стоп|stop|хватит)\s*[.!?]*$/i, cmd: 'stop' },
389
- { re: /^(продолжи|continue|продолжай)\s*[.!?]*$/i, cmd: 'continue' },
390
- ]
391
- function sessionCommand(text) {
392
- const t = String(text || '').trim().toLowerCase()
393
- if (!t) return null
394
- for (const { re, cmd } of SESSION_COMMANDS) {
395
- if (re.test(t)) return cmd
396
- }
397
- return null
398
- }
315
+ // sessionCommand imported from ./transcribe-core.js
399
316
 
400
317
  ctx.effect(() => ctx.webServer.register({
401
318
  kind: 'exact',
package/lib/polish.js ADDED
@@ -0,0 +1,74 @@
1
+ // Transcript polish. Pure factory: inject fetch, key resolver, LLM, config.
2
+ // On any failure return the original text — polish must never block input.
3
+
4
+ /**
5
+ * @param {object} deps
6
+ * @param {(ref: string) => Promise<string>} deps.resolveKey
7
+ * @param {typeof fetch} deps.fetchImpl
8
+ * @param {() => object} deps.getConfig live plugin config
9
+ * @param {{ stream: Function } | null} deps.llm harness LLM service (optional)
10
+ * @param {() => ({provider: string, model: string} | null)} [deps.getAgentDefaultModel]
11
+ */
12
+ export function createPolishText({ resolveKey, fetchImpl, getConfig, llm, getAgentDefaultModel, createUserMessage: createUserMessageFn }) {
13
+ return async function polishText(text, modeCfg, signal) {
14
+ const enable = modeCfg && (modeCfg.polish === true || modeCfg.polishSend === true)
15
+ if (!enable || !text) return text
16
+ const cfg = getConfig() || {}
17
+ const ask =
18
+ 'Fix the punctuation and spelling of this dictated text and split it into '
19
+ + 'paragraphs where the speaker changes topic. Remove filler words ("um", "uh", '
20
+ + '"ээ", "ну", "как бы"). Keep the original language, wording and meaning. '
21
+ + 'Reply with the polished text only:\n\n' + text
22
+ try {
23
+ // Local OpenAI-compatible endpoint when configured.
24
+ if (cfg.polishBaseUrl) {
25
+ const headers = { 'content-type': 'application/json' }
26
+ if (cfg.polishKeyEnv) {
27
+ const key = await resolveKey(cfg.polishKeyEnv)
28
+ if (key) headers.authorization = 'Bearer ' + key
29
+ }
30
+ const res = await fetchImpl(
31
+ String(cfg.polishBaseUrl).replace(/\/+$/, '') + '/chat/completions',
32
+ {
33
+ method: 'POST',
34
+ headers,
35
+ signal,
36
+ body: JSON.stringify({
37
+ model: cfg.polishModel || 'local-model',
38
+ messages: [{ role: 'user', content: ask }],
39
+ }),
40
+ },
41
+ )
42
+ if (!res.ok) return text
43
+ const data = await res.json().catch(() => null)
44
+ const pick = data && data.choices && data.choices[0] && data.choices[0].message
45
+ && data.choices[0].message.content
46
+ return (pick && String(pick).trim()) || text
47
+ }
48
+ // Harness model.
49
+ if (!llm || typeof llm.stream !== 'function') return text
50
+ const sel = (typeof getAgentDefaultModel === 'function' && getAgentDefaultModel()) || null
51
+ const provider = cfg.polishProvider || (sel && sel.provider) || ''
52
+ const model = cfg.polishModelId || (sel && sel.model) || ''
53
+ if (!provider || !model) return text
54
+ let acc = ''
55
+ // createUserMessage comes from @deepseek-ai/dsh-llm. Inject it here so
56
+ // tests can exercise the harness path without installing peer deps;
57
+ // production wires the real helper via index.js.
58
+ const makeUser = createUserMessageFn
59
+ || (await import('@deepseek-ai/dsh-llm')).createUserMessage
60
+ for await (const chunk of llm.stream({
61
+ provider,
62
+ model,
63
+ messages: [makeUser({ content: [{ type: 'text', text: ask }], source: { kind: 'user' } })],
64
+ signal,
65
+ })) {
66
+ if (chunk && chunk.type === 'text-delta' && typeof chunk.text === 'string') acc += chunk.text
67
+ }
68
+ const clean = acc.trim()
69
+ return clean || text
70
+ } catch {
71
+ return text
72
+ }
73
+ }
74
+ }
@@ -0,0 +1,49 @@
1
+ // Pure helpers for the transcribe path: chain selection and session commands.
2
+
3
+ /**
4
+ * Build provider order for a mode, honouring localOnly.
5
+ * @param {object} args
6
+ * @param {boolean} args.localOnly
7
+ * @param {{provider: string, model?: string}[]} args.chain
8
+ * @param {string[]} args.customKeys
9
+ * @param {string[]} args.knownKeys
10
+ * @param {Record<string, string>} args.defaultModels
11
+ * @returns {{order: string[], models: Record<string, string>}}
12
+ */
13
+ export function buildProviderOrder({ localOnly, chain, customKeys, knownKeys, defaultModels }) {
14
+ const source = localOnly
15
+ ? (chain || []).filter((e) => e && e.provider === 'local-whisper')
16
+ : (chain || [])
17
+ const models = {}
18
+ const order = []
19
+ for (const entry of source) {
20
+ if (!entry || !entry.provider) continue
21
+ if (!knownKeys.includes(entry.provider) && !customKeys.includes(entry.provider)) continue
22
+ order.push(entry.provider)
23
+ models[entry.provider] = entry.model || defaultModels[entry.provider] || ''
24
+ }
25
+ if (localOnly && order.length === 0) {
26
+ throw new Error('localOnly mode is on, but local-whisper is not in the chain')
27
+ }
28
+ return { order, models }
29
+ }
30
+
31
+ const SESSION_COMMANDS = [
32
+ { re: /^(отправь|отправить|пошли|send)\s*[.!?]*$/i, cmd: 'send' },
33
+ { re: /^(отмени|отмена|cancel|отменить)\s*[.!?]*$/i, cmd: 'cancel' },
34
+ { re: /^(стоп|stop|хватит)\s*[.!?]*$/i, cmd: 'stop' },
35
+ { re: /^(продолжи|continue|продолжай)\s*[.!?]*$/i, cmd: 'continue' },
36
+ ]
37
+
38
+ /**
39
+ * @param {string} text
40
+ * @returns {'send'|'cancel'|'stop'|'continue'|null}
41
+ */
42
+ export function sessionCommand(text) {
43
+ const t = String(text || '').trim().toLowerCase()
44
+ if (!t) return null
45
+ for (const { re, cmd } of SESSION_COMMANDS) {
46
+ if (re.test(t)) return cmd
47
+ }
48
+ return null
49
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-voice",
3
- "version": "0.8.22",
3
+ "version": "0.8.24",
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",