@goodandready/dsh-tts 0.4.10 → 0.4.17
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/CHANGELOG.md +25 -0
- package/README.md +0 -1
- package/README.ru.md +0 -1
- package/README.zh.md +0 -1
- package/lib/client-src/00-open.js +4 -0
- package/lib/client-src/10-styles.js +1 -0
- package/lib/client-src/20-player.js +6 -6
- package/lib/client-src/40-editors.js +2 -28
- package/lib/client-src/60-card-dock.js +43 -29
- package/lib/client.js +56 -63
- package/lib/http-util.js +32 -2
- package/lib/routes.js +8 -1
- package/lib/text.js +7 -40
- package/lib/updater.js +333 -0
- package/package.json +2 -1
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
Notable changes to `@goodandready/dsh-tts`.
|
|
4
|
+
|
|
5
|
+
## 0.4.17
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
- Settings now register into the Plugins page's row seat, `plugins.row.config`,
|
|
10
|
+
keyed `@goodandready/dsh-tts#dsh-tts` (row id as `cordis.patch.yml` declares
|
|
11
|
+
it): the plugin's row on the Plugins page gains a configure control whose page
|
|
12
|
+
is the same settings form (`view: 'page'`), with a one-line state under the row
|
|
13
|
+
title (`view: 'summary'`). The previous seat, `settings.plugin.item`, stays as
|
|
14
|
+
a fallback for older cores, so settings never become unreachable (#127).
|
|
15
|
+
|
|
16
|
+
### Fixed
|
|
17
|
+
|
|
18
|
+
- The row-seat page renders the settings form **bare**: the host page already
|
|
19
|
+
draws the title, icon and crumb and provides the content padding, so our own
|
|
20
|
+
card chrome would double the border and shift the form out of the content area.
|
|
21
|
+
|
|
22
|
+
### Tests
|
|
23
|
+
|
|
24
|
+
- `test/row-config-seat.test.mjs` guards the row-seat key, the row id against
|
|
25
|
+
`cordis.patch.yml`, the fallback seats, and the bare view-aware rendering.
|
package/README.md
CHANGED
|
@@ -214,7 +214,6 @@ Provider **API keys are never stored in plugin settings**. Paste them in the cha
|
|
|
214
214
|
|
|
215
215
|
Config-only (not in the card): `*KeyEnv` fields (`openaiKeyEnv`, `elevenlabsKeyEnv`, …). They only rename the credential slot the plugin looks up. Change them in `settings.yaml` if you must rebind a key name; the defaults match the usual environment variable names.
|
|
216
216
|
|
|
217
|
-
The sidebar `settings.section` entry is a **fallback** and is registered only when `settings.plugin.item` is missing from the build.
|
|
218
217
|
|
|
219
218
|
## 🤖 HTTP Endpoints Reference
|
|
220
219
|
|
package/README.ru.md
CHANGED
|
@@ -197,7 +197,6 @@ dsh-tts:
|
|
|
197
197
|
|
|
198
198
|
Только в конфиге (нет в карточке): поля `*KeyEnv` (`openaiKeyEnv`, `elevenlabsKeyEnv`, …). Они задают **имя** credential-слота; по умолчанию совпадают с привычными именами env-переменных. Меняйте в `settings.yaml`, если нужно перевязать имя ключа.
|
|
199
199
|
|
|
200
|
-
Пункт `settings.section` в боковом меню — **fallback** и регистрируется, только если в сборке нет `settings.plugin.item`.
|
|
201
200
|
|
|
202
201
|
## 🤖 HTTP роуты API
|
|
203
202
|
|
package/README.zh.md
CHANGED
|
@@ -15,6 +15,10 @@ window.__ModuleLoader__.load({
|
|
|
15
15
|
ChevronIcon = null
|
|
16
16
|
}
|
|
17
17
|
const NS = 'dsh-tts'
|
|
18
|
+
// Plugins page row seat: key = '<package name>#<row id>', row id as cordis.patch.yml declares it.
|
|
19
|
+
const PKG = '@goodandready/dsh-tts'
|
|
20
|
+
const ROW_ID = 'dsh-tts'
|
|
21
|
+
const ROW_CONFIG_KEY = PKG + '#' + ROW_ID
|
|
18
22
|
const PROVIDERS = [
|
|
19
23
|
'openai', 'elevenlabs', 'google', 'azure', 'groq', 'deepgram', 'openrouter',
|
|
20
24
|
'edge', 'piper', 'espeak',
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
const SET_CSS =
|
|
3
3
|
'.dts-wrap{display:flex;flex-direction:column;gap:18px;padding:4px 0 24px;max-width:900px}' +
|
|
4
4
|
'.dts-header{display:flex;flex-direction:column;gap:8px;padding-bottom:14px;border-bottom:1px solid var(--dsw-alias-border-l2)}' +
|
|
5
|
+
'.dts-page{padding:0}' +
|
|
5
6
|
'.dts-page-title{font-size:20px;font-weight:700;color:var(--dsw-alias-label-primary);display:flex;align-items:center;gap:10px}' +
|
|
6
7
|
'.dts-page-sub{font-size:13px;color:var(--dsw-alias-label-secondary);line-height:1.5}' +
|
|
7
8
|
'.dts-block{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}' +
|
|
@@ -256,7 +256,7 @@
|
|
|
256
256
|
function connectSseStream() {
|
|
257
257
|
if (typeof window === 'undefined' || typeof window.EventSource === 'undefined') return
|
|
258
258
|
if (sseSource) {
|
|
259
|
-
try { sseSource.close() } catch {}
|
|
259
|
+
try { sseSource.close() } catch (alreadyClosed) { /* intentional cleanup */ }
|
|
260
260
|
sseSource = null
|
|
261
261
|
}
|
|
262
262
|
try {
|
|
@@ -281,7 +281,7 @@
|
|
|
281
281
|
drainQueue()
|
|
282
282
|
}
|
|
283
283
|
}
|
|
284
|
-
} catch {}
|
|
284
|
+
} catch (parseErr) { /* ignore malformed SSE payload */ }
|
|
285
285
|
})
|
|
286
286
|
sseSource.addEventListener('chime', (e) => {
|
|
287
287
|
sseAttempt = 0
|
|
@@ -291,11 +291,11 @@
|
|
|
291
291
|
player.queue.push(item)
|
|
292
292
|
drainQueue()
|
|
293
293
|
}
|
|
294
|
-
} catch {}
|
|
294
|
+
} catch (parseErr) { /* ignore malformed chime payload */ }
|
|
295
295
|
})
|
|
296
296
|
sseSource.onerror = () => {
|
|
297
297
|
if (sseSource) {
|
|
298
|
-
try { sseSource.close() } catch {}
|
|
298
|
+
try { sseSource.close() } catch (alreadyClosed) { /* intentional cleanup */ }
|
|
299
299
|
sseSource = null
|
|
300
300
|
}
|
|
301
301
|
sseAttempt++
|
|
@@ -307,7 +307,7 @@
|
|
|
307
307
|
connectSseStream()
|
|
308
308
|
}, delay)
|
|
309
309
|
}
|
|
310
|
-
} catch {}
|
|
310
|
+
} catch (esErr) { /* EventSource failed */ }
|
|
311
311
|
}
|
|
312
312
|
|
|
313
313
|
async function pollPending() {
|
|
@@ -413,7 +413,7 @@ async function pollPending() {
|
|
|
413
413
|
document.body.appendChild(a)
|
|
414
414
|
a.click()
|
|
415
415
|
document.body.removeChild(a)
|
|
416
|
-
setTimeout(() => { try { URL.revokeObjectURL(url) } catch {} }, 2000)
|
|
416
|
+
setTimeout(() => { try { URL.revokeObjectURL(url) } catch (alreadyRevoked) { /* already revoked */ } }, 2000)
|
|
417
417
|
})
|
|
418
418
|
.catch(() => {})
|
|
419
419
|
}
|
|
@@ -132,33 +132,7 @@
|
|
|
132
132
|
)
|
|
133
133
|
}
|
|
134
134
|
|
|
135
|
-
const CLIENT_IT_TERMS = [
|
|
136
|
-
{ from: 'SQL', to: 'сиквел', whole: true, lang: 'ru' },
|
|
137
|
-
{ from: 'Nginx', to: 'энджинкс', whole: true, lang: 'ru' },
|
|
138
|
-
{ from: 'Kubernetes', to: 'кубернетис', whole: true, lang: 'ru' },
|
|
139
|
-
{ from: 'K8s', to: 'кубернетис', whole: true, lang: 'ru' },
|
|
140
|
-
{ from: 'PostgreSQL', to: 'постгрес', whole: true, lang: 'ru' },
|
|
141
|
-
{ from: 'Redis', to: 'рэдис', whole: true, lang: 'ru' },
|
|
142
|
-
{ from: 'Docker', to: 'докер', whole: true, lang: 'ru' },
|
|
143
|
-
{ from: 'API', to: 'апи', whole: true, lang: 'ru' },
|
|
144
|
-
{ from: 'JSON', to: 'джейсон', whole: true, lang: 'ru' },
|
|
145
|
-
{ from: 'YAML', to: 'ямл', whole: true, lang: 'ru' },
|
|
146
|
-
{ from: 'GUI', to: 'гуи', whole: true, lang: 'ru' },
|
|
147
|
-
{ from: 'CLI', to: 'си элай', whole: true, lang: 'ru' },
|
|
148
|
-
{ from: 'CI/CD', to: 'си ай си ди', whole: false, lang: 'ru' },
|
|
149
|
-
{ from: 'PR', to: 'пулл реквест', whole: true, lang: 'ru' },
|
|
150
|
-
{ from: 'Regex', to: 'регэкс', whole: true, lang: 'ru' },
|
|
151
|
-
{ from: 'OAuth', to: 'о-аус', whole: true, lang: 'ru' },
|
|
152
|
-
{ from: 'HTTP', to: 'эйч ти ти пи', whole: true, lang: 'ru' },
|
|
153
|
-
{ from: 'HTTPS', to: 'эйч ти ти пи эс', whole: true, lang: 'ru' },
|
|
154
|
-
{ from: 'URL', to: 'юрл', whole: true, lang: 'ru' },
|
|
155
|
-
{ from: 'SSH', to: 'эс эс эйч', whole: true, lang: 'ru' },
|
|
156
|
-
{ from: 'IP', to: 'ай пи', whole: true, lang: 'ru' },
|
|
157
|
-
{ from: 'DNS', to: 'ди эн эс', whole: true, lang: 'ru' },
|
|
158
|
-
{ from: 'CPU', to: 'си пи ю', whole: true, lang: 'ru' },
|
|
159
|
-
{ from: 'GPU', to: 'джи пи ю', whole: true, lang: 'ru' },
|
|
160
|
-
{ from: 'RAM', to: 'рам', whole: true, lang: 'ru' },
|
|
161
|
-
]
|
|
135
|
+
const CLIENT_IT_TERMS = []
|
|
162
136
|
|
|
163
137
|
// Pronunciation dictionary editor: rules apply top-down.
|
|
164
138
|
function PronEditor(props) {
|
|
@@ -187,7 +161,7 @@
|
|
|
187
161
|
document.body.appendChild(a)
|
|
188
162
|
a.click()
|
|
189
163
|
document.body.removeChild(a)
|
|
190
|
-
setTimeout(() => { try { URL.revokeObjectURL(url) } catch {} }, 1000)
|
|
164
|
+
setTimeout(() => { try { URL.revokeObjectURL(url) } catch (alreadyRevoked) { /* already revoked */ } }, 1000)
|
|
191
165
|
}
|
|
192
166
|
|
|
193
167
|
const triggerImport = () => {
|
|
@@ -118,6 +118,26 @@
|
|
|
118
118
|
))
|
|
119
119
|
}
|
|
120
120
|
|
|
121
|
+
/**
|
|
122
|
+
* Plugins page row seat (`plugins.row.config`, current core).
|
|
123
|
+
*
|
|
124
|
+
* The host page draws the title, icon and crumb and provides the content
|
|
125
|
+
* padding, so the page view renders the settings form bare: our own card
|
|
126
|
+
* chrome would double the border and shift the form out of the content
|
|
127
|
+
* area. The summary view feeds the one-liner under the row title.
|
|
128
|
+
*/
|
|
129
|
+
function TtsRowConfig(props) {
|
|
130
|
+
const t = (props && props.t) || ((key) => key)
|
|
131
|
+
if (props && props.view === 'summary') {
|
|
132
|
+
return React.createElement('div', { className: 'dts-sub' }, t('subtitle'))
|
|
133
|
+
}
|
|
134
|
+
return React.createElement('div', { className: 'dts-page' },
|
|
135
|
+
React.createElement(ErrorBoundary, null,
|
|
136
|
+
React.createElement(TtsSection, Object.assign({}, props, { ctx: props && props.ctx })),
|
|
137
|
+
),
|
|
138
|
+
)
|
|
139
|
+
}
|
|
140
|
+
|
|
121
141
|
function registerSettings(ctx) {
|
|
122
142
|
// Dictionary packages may also register languages for other namespaces.
|
|
123
143
|
// Core throws on duplicate namespace+language; an unguarded call used to
|
|
@@ -137,39 +157,33 @@
|
|
|
137
157
|
// The sidebar draws the section label, not our component: props.t never
|
|
138
158
|
// reaches it, so bind the translator to the namespace ourselves.
|
|
139
159
|
const t = ctx.locale.bind(NS)
|
|
140
|
-
//
|
|
141
|
-
//
|
|
142
|
-
//
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
return true
|
|
156
|
-
} catch (noPluginItemSlot) {
|
|
157
|
-
return false
|
|
158
|
-
}
|
|
160
|
+
// Current core: settings live on the plugin's own row on the Plugins page
|
|
161
|
+
// (`plugins.row.config`). Key = '<package name>#<row id>'; the row gains a
|
|
162
|
+
// configure control that asks for view:'summary' and view:'page'.
|
|
163
|
+
try {
|
|
164
|
+
ctx.slots.inject('plugins.row.config', () => ctx.slots.register(
|
|
165
|
+
{
|
|
166
|
+
name: 'plugins.row.config',
|
|
167
|
+
key: ROW_CONFIG_KEY,
|
|
168
|
+
locale: NS,
|
|
169
|
+
inject: () => ({ ctx: ctx }),
|
|
170
|
+
},
|
|
171
|
+
TtsRowConfig,
|
|
172
|
+
))
|
|
173
|
+
} catch (rowSeatUnavailable) {
|
|
174
|
+
console.warn('[dsh-tts] row seat plugins.row.config not available:', rowSeatUnavailable && rowSeatUnavailable.message)
|
|
159
175
|
}
|
|
160
|
-
|
|
161
|
-
//
|
|
162
|
-
//
|
|
163
|
-
ctx.slots.inject('settings.
|
|
176
|
+
// Fallback for hosts that render the older plugin-settings tab card. The tab
|
|
177
|
+
// looks up a slot by entryKey equal to the namespace name: key must equal NS
|
|
178
|
+
// or the card never appears, with no log error.
|
|
179
|
+
ctx.slots.inject('settings.plugin.item', () => ctx.slots.register(
|
|
164
180
|
{
|
|
165
|
-
name: 'settings.
|
|
166
|
-
|
|
167
|
-
order: 32,
|
|
181
|
+
name: 'settings.plugin.item',
|
|
182
|
+
key: NS,
|
|
168
183
|
locale: NS,
|
|
169
|
-
label: () => t('title'),
|
|
170
184
|
inject: () => ({ ctx: ctx }),
|
|
171
185
|
},
|
|
172
|
-
|
|
186
|
+
TtsCard,
|
|
173
187
|
))
|
|
174
188
|
}
|
|
175
189
|
|
|
@@ -182,7 +196,7 @@
|
|
|
182
196
|
if (typeof window === 'undefined') return () => {}
|
|
183
197
|
const onKey = (e) => {
|
|
184
198
|
if (e.ctrlKey && e.key === 'Escape') { togglePause(); e.preventDefault() }
|
|
185
|
-
else if (e.altKey && (e.
|
|
199
|
+
else if (e.altKey && (e.code === 'KeyS' || e.key === 's' || e.key === 'S')) { stopPlayback(); e.preventDefault() }
|
|
186
200
|
else if (e.altKey && e.key === 'ArrowRight') { skipCurrent(); e.preventDefault() }
|
|
187
201
|
}
|
|
188
202
|
window.addEventListener('keydown', onKey)
|
package/lib/client.js
CHANGED
|
@@ -15,6 +15,10 @@ window.__ModuleLoader__.load({
|
|
|
15
15
|
ChevronIcon = null
|
|
16
16
|
}
|
|
17
17
|
const NS = 'dsh-tts'
|
|
18
|
+
// Plugins page row seat: key = '<package name>#<row id>', row id as cordis.patch.yml declares it.
|
|
19
|
+
const PKG = '@goodandready/dsh-tts'
|
|
20
|
+
const ROW_ID = 'dsh-tts'
|
|
21
|
+
const ROW_CONFIG_KEY = PKG + '#' + ROW_ID
|
|
18
22
|
const PROVIDERS = [
|
|
19
23
|
'openai', 'elevenlabs', 'google', 'azure', 'groq', 'deepgram', 'openrouter',
|
|
20
24
|
'edge', 'piper', 'espeak',
|
|
@@ -87,6 +91,7 @@ window.__ModuleLoader__.load({
|
|
|
87
91
|
const SET_CSS =
|
|
88
92
|
'.dts-wrap{display:flex;flex-direction:column;gap:18px;padding:4px 0 24px;max-width:900px}' +
|
|
89
93
|
'.dts-header{display:flex;flex-direction:column;gap:8px;padding-bottom:14px;border-bottom:1px solid var(--dsw-alias-border-l2)}' +
|
|
94
|
+
'.dts-page{padding:0}' +
|
|
90
95
|
'.dts-page-title{font-size:20px;font-weight:700;color:var(--dsw-alias-label-primary);display:flex;align-items:center;gap:10px}' +
|
|
91
96
|
'.dts-page-sub{font-size:13px;color:var(--dsw-alias-label-secondary);line-height:1.5}' +
|
|
92
97
|
'.dts-block{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}' +
|
|
@@ -395,7 +400,7 @@ window.__ModuleLoader__.load({
|
|
|
395
400
|
function connectSseStream() {
|
|
396
401
|
if (typeof window === 'undefined' || typeof window.EventSource === 'undefined') return
|
|
397
402
|
if (sseSource) {
|
|
398
|
-
try { sseSource.close() } catch {}
|
|
403
|
+
try { sseSource.close() } catch (alreadyClosed) { /* intentional cleanup */ }
|
|
399
404
|
sseSource = null
|
|
400
405
|
}
|
|
401
406
|
try {
|
|
@@ -420,7 +425,7 @@ window.__ModuleLoader__.load({
|
|
|
420
425
|
drainQueue()
|
|
421
426
|
}
|
|
422
427
|
}
|
|
423
|
-
} catch {}
|
|
428
|
+
} catch (parseErr) { /* ignore malformed SSE payload */ }
|
|
424
429
|
})
|
|
425
430
|
sseSource.addEventListener('chime', (e) => {
|
|
426
431
|
sseAttempt = 0
|
|
@@ -430,11 +435,11 @@ window.__ModuleLoader__.load({
|
|
|
430
435
|
player.queue.push(item)
|
|
431
436
|
drainQueue()
|
|
432
437
|
}
|
|
433
|
-
} catch {}
|
|
438
|
+
} catch (parseErr) { /* ignore malformed chime payload */ }
|
|
434
439
|
})
|
|
435
440
|
sseSource.onerror = () => {
|
|
436
441
|
if (sseSource) {
|
|
437
|
-
try { sseSource.close() } catch {}
|
|
442
|
+
try { sseSource.close() } catch (alreadyClosed) { /* intentional cleanup */ }
|
|
438
443
|
sseSource = null
|
|
439
444
|
}
|
|
440
445
|
sseAttempt++
|
|
@@ -446,7 +451,7 @@ window.__ModuleLoader__.load({
|
|
|
446
451
|
connectSseStream()
|
|
447
452
|
}, delay)
|
|
448
453
|
}
|
|
449
|
-
} catch {}
|
|
454
|
+
} catch (esErr) { /* EventSource failed */ }
|
|
450
455
|
}
|
|
451
456
|
|
|
452
457
|
async function pollPending() {
|
|
@@ -552,7 +557,7 @@ async function pollPending() {
|
|
|
552
557
|
document.body.appendChild(a)
|
|
553
558
|
a.click()
|
|
554
559
|
document.body.removeChild(a)
|
|
555
|
-
setTimeout(() => { try { URL.revokeObjectURL(url) } catch {} }, 2000)
|
|
560
|
+
setTimeout(() => { try { URL.revokeObjectURL(url) } catch (alreadyRevoked) { /* already revoked */ } }, 2000)
|
|
556
561
|
})
|
|
557
562
|
.catch(() => {})
|
|
558
563
|
}
|
|
@@ -1069,33 +1074,7 @@ async function pollPending() {
|
|
|
1069
1074
|
)
|
|
1070
1075
|
}
|
|
1071
1076
|
|
|
1072
|
-
const CLIENT_IT_TERMS = [
|
|
1073
|
-
{ from: 'SQL', to: 'сиквел', whole: true, lang: 'ru' },
|
|
1074
|
-
{ from: 'Nginx', to: 'энджинкс', whole: true, lang: 'ru' },
|
|
1075
|
-
{ from: 'Kubernetes', to: 'кубернетис', whole: true, lang: 'ru' },
|
|
1076
|
-
{ from: 'K8s', to: 'кубернетис', whole: true, lang: 'ru' },
|
|
1077
|
-
{ from: 'PostgreSQL', to: 'постгрес', whole: true, lang: 'ru' },
|
|
1078
|
-
{ from: 'Redis', to: 'рэдис', whole: true, lang: 'ru' },
|
|
1079
|
-
{ from: 'Docker', to: 'докер', whole: true, lang: 'ru' },
|
|
1080
|
-
{ from: 'API', to: 'апи', whole: true, lang: 'ru' },
|
|
1081
|
-
{ from: 'JSON', to: 'джейсон', whole: true, lang: 'ru' },
|
|
1082
|
-
{ from: 'YAML', to: 'ямл', whole: true, lang: 'ru' },
|
|
1083
|
-
{ from: 'GUI', to: 'гуи', whole: true, lang: 'ru' },
|
|
1084
|
-
{ from: 'CLI', to: 'си элай', whole: true, lang: 'ru' },
|
|
1085
|
-
{ from: 'CI/CD', to: 'си ай си ди', whole: false, lang: 'ru' },
|
|
1086
|
-
{ from: 'PR', to: 'пулл реквест', whole: true, lang: 'ru' },
|
|
1087
|
-
{ from: 'Regex', to: 'регэкс', whole: true, lang: 'ru' },
|
|
1088
|
-
{ from: 'OAuth', to: 'о-аус', whole: true, lang: 'ru' },
|
|
1089
|
-
{ from: 'HTTP', to: 'эйч ти ти пи', whole: true, lang: 'ru' },
|
|
1090
|
-
{ from: 'HTTPS', to: 'эйч ти ти пи эс', whole: true, lang: 'ru' },
|
|
1091
|
-
{ from: 'URL', to: 'юрл', whole: true, lang: 'ru' },
|
|
1092
|
-
{ from: 'SSH', to: 'эс эс эйч', whole: true, lang: 'ru' },
|
|
1093
|
-
{ from: 'IP', to: 'ай пи', whole: true, lang: 'ru' },
|
|
1094
|
-
{ from: 'DNS', to: 'ди эн эс', whole: true, lang: 'ru' },
|
|
1095
|
-
{ from: 'CPU', to: 'си пи ю', whole: true, lang: 'ru' },
|
|
1096
|
-
{ from: 'GPU', to: 'джи пи ю', whole: true, lang: 'ru' },
|
|
1097
|
-
{ from: 'RAM', to: 'рам', whole: true, lang: 'ru' },
|
|
1098
|
-
]
|
|
1077
|
+
const CLIENT_IT_TERMS = []
|
|
1099
1078
|
|
|
1100
1079
|
// Pronunciation dictionary editor: rules apply top-down.
|
|
1101
1080
|
function PronEditor(props) {
|
|
@@ -1124,7 +1103,7 @@ async function pollPending() {
|
|
|
1124
1103
|
document.body.appendChild(a)
|
|
1125
1104
|
a.click()
|
|
1126
1105
|
document.body.removeChild(a)
|
|
1127
|
-
setTimeout(() => { try { URL.revokeObjectURL(url) } catch {} }, 1000)
|
|
1106
|
+
setTimeout(() => { try { URL.revokeObjectURL(url) } catch (alreadyRevoked) { /* already revoked */ } }, 1000)
|
|
1128
1107
|
}
|
|
1129
1108
|
|
|
1130
1109
|
const triggerImport = () => {
|
|
@@ -1902,6 +1881,26 @@ async function pollPending() {
|
|
|
1902
1881
|
))
|
|
1903
1882
|
}
|
|
1904
1883
|
|
|
1884
|
+
/**
|
|
1885
|
+
* Plugins page row seat (`plugins.row.config`, current core).
|
|
1886
|
+
*
|
|
1887
|
+
* The host page draws the title, icon and crumb and provides the content
|
|
1888
|
+
* padding, so the page view renders the settings form bare: our own card
|
|
1889
|
+
* chrome would double the border and shift the form out of the content
|
|
1890
|
+
* area. The summary view feeds the one-liner under the row title.
|
|
1891
|
+
*/
|
|
1892
|
+
function TtsRowConfig(props) {
|
|
1893
|
+
const t = (props && props.t) || ((key) => key)
|
|
1894
|
+
if (props && props.view === 'summary') {
|
|
1895
|
+
return React.createElement('div', { className: 'dts-sub' }, t('subtitle'))
|
|
1896
|
+
}
|
|
1897
|
+
return React.createElement('div', { className: 'dts-page' },
|
|
1898
|
+
React.createElement(ErrorBoundary, null,
|
|
1899
|
+
React.createElement(TtsSection, Object.assign({}, props, { ctx: props && props.ctx })),
|
|
1900
|
+
),
|
|
1901
|
+
)
|
|
1902
|
+
}
|
|
1903
|
+
|
|
1905
1904
|
function registerSettings(ctx) {
|
|
1906
1905
|
// Dictionary packages may also register languages for other namespaces.
|
|
1907
1906
|
// Core throws on duplicate namespace+language; an unguarded call used to
|
|
@@ -1921,39 +1920,33 @@ async function pollPending() {
|
|
|
1921
1920
|
// The sidebar draws the section label, not our component: props.t never
|
|
1922
1921
|
// reaches it, so bind the translator to the namespace ourselves.
|
|
1923
1922
|
const t = ctx.locale.bind(NS)
|
|
1924
|
-
//
|
|
1925
|
-
//
|
|
1926
|
-
//
|
|
1927
|
-
|
|
1928
|
-
|
|
1929
|
-
|
|
1930
|
-
|
|
1931
|
-
|
|
1932
|
-
|
|
1933
|
-
|
|
1934
|
-
|
|
1935
|
-
|
|
1936
|
-
|
|
1937
|
-
|
|
1938
|
-
|
|
1939
|
-
return true
|
|
1940
|
-
} catch (noPluginItemSlot) {
|
|
1941
|
-
return false
|
|
1942
|
-
}
|
|
1923
|
+
// Current core: settings live on the plugin's own row on the Plugins page
|
|
1924
|
+
// (`plugins.row.config`). Key = '<package name>#<row id>'; the row gains a
|
|
1925
|
+
// configure control that asks for view:'summary' and view:'page'.
|
|
1926
|
+
try {
|
|
1927
|
+
ctx.slots.inject('plugins.row.config', () => ctx.slots.register(
|
|
1928
|
+
{
|
|
1929
|
+
name: 'plugins.row.config',
|
|
1930
|
+
key: ROW_CONFIG_KEY,
|
|
1931
|
+
locale: NS,
|
|
1932
|
+
inject: () => ({ ctx: ctx }),
|
|
1933
|
+
},
|
|
1934
|
+
TtsRowConfig,
|
|
1935
|
+
))
|
|
1936
|
+
} catch (rowSeatUnavailable) {
|
|
1937
|
+
console.warn('[dsh-tts] row seat plugins.row.config not available:', rowSeatUnavailable && rowSeatUnavailable.message)
|
|
1943
1938
|
}
|
|
1944
|
-
|
|
1945
|
-
//
|
|
1946
|
-
//
|
|
1947
|
-
ctx.slots.inject('settings.
|
|
1939
|
+
// Fallback for hosts that render the older plugin-settings tab card. The tab
|
|
1940
|
+
// looks up a slot by entryKey equal to the namespace name: key must equal NS
|
|
1941
|
+
// or the card never appears, with no log error.
|
|
1942
|
+
ctx.slots.inject('settings.plugin.item', () => ctx.slots.register(
|
|
1948
1943
|
{
|
|
1949
|
-
name: 'settings.
|
|
1950
|
-
|
|
1951
|
-
order: 32,
|
|
1944
|
+
name: 'settings.plugin.item',
|
|
1945
|
+
key: NS,
|
|
1952
1946
|
locale: NS,
|
|
1953
|
-
label: () => t('title'),
|
|
1954
1947
|
inject: () => ({ ctx: ctx }),
|
|
1955
1948
|
},
|
|
1956
|
-
|
|
1949
|
+
TtsCard,
|
|
1957
1950
|
))
|
|
1958
1951
|
}
|
|
1959
1952
|
|
|
@@ -1966,7 +1959,7 @@ async function pollPending() {
|
|
|
1966
1959
|
if (typeof window === 'undefined') return () => {}
|
|
1967
1960
|
const onKey = (e) => {
|
|
1968
1961
|
if (e.ctrlKey && e.key === 'Escape') { togglePause(); e.preventDefault() }
|
|
1969
|
-
else if (e.altKey && (e.
|
|
1962
|
+
else if (e.altKey && (e.code === 'KeyS' || e.key === 's' || e.key === 'S')) { stopPlayback(); e.preventDefault() }
|
|
1970
1963
|
else if (e.altKey && e.key === 'ArrowRight') { skipCurrent(); e.preventDefault() }
|
|
1971
1964
|
}
|
|
1972
1965
|
window.addEventListener('keydown', onKey)
|
package/lib/http-util.js
CHANGED
|
@@ -21,7 +21,37 @@ export function readBody(req, maxBytes) {
|
|
|
21
21
|
})
|
|
22
22
|
}
|
|
23
23
|
|
|
24
|
-
|
|
24
|
+
function isLoopback(address) {
|
|
25
|
+
if (!address) return false
|
|
26
|
+
return address === '127.0.0.1' || address === '::1' || address === '::ffff:127.0.0.1' || address === 'localhost'
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Strict fail-closed check for administrative/settings requests.
|
|
31
|
+
* Accepts loopback, same-origin/same-site sec-fetch-site, matching origin/host, or authorization header.
|
|
32
|
+
* Rejects cross-site or unauthenticated requests missing origin context.
|
|
33
|
+
*/
|
|
25
34
|
export function isTrustedSettingsRequest(request) {
|
|
26
|
-
|
|
35
|
+
if (!request || !request.headers) return false
|
|
36
|
+
|
|
37
|
+
const authHeader = request.headers['authorization'] || request.headers['x-dsh-auth']
|
|
38
|
+
if (authHeader && authHeader.length > 5) return true
|
|
39
|
+
|
|
40
|
+
const remoteAddr = request.socket?.remoteAddress || request.connection?.remoteAddress
|
|
41
|
+
if (remoteAddr && isLoopback(remoteAddr)) return true
|
|
42
|
+
|
|
43
|
+
const secFetchSite = request.headers['sec-fetch-site']
|
|
44
|
+
if (secFetchSite === 'same-origin' || secFetchSite === 'same-site') {
|
|
45
|
+
return true
|
|
46
|
+
}
|
|
47
|
+
const origin = request.headers['origin']
|
|
48
|
+
const host = request.headers['host']
|
|
49
|
+
if (origin && host) {
|
|
50
|
+
try {
|
|
51
|
+
const originHost = new URL(origin).host
|
|
52
|
+
if (originHost === host) return true
|
|
53
|
+
} catch { /* invalid origin URL */ }
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return false
|
|
27
57
|
}
|
package/lib/routes.js
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { resolve } from 'node:path'
|
|
2
|
+
import { registerPluginUpdater } from './updater.js'
|
|
1
3
|
import { writeJson, readBody, isTrustedSettingsRequest } from './http-util.js'
|
|
2
4
|
import { addStreamSubscriber, removeStreamSubscriber, streamSubscriberCount } from './stream-hub.js'
|
|
3
5
|
import { pendingKeyWrites, stripSecretsFromConfig, needsApiKey } from './keys.js'
|
|
@@ -229,7 +231,6 @@ export function registerHttpRoutes(ctx, api) {
|
|
|
229
231
|
const lang = String(cfg.language || '').toLowerCase()
|
|
230
232
|
let phrase = 'Voice check.'
|
|
231
233
|
if (lang.startsWith('zh')) phrase = '语音合成测试。'
|
|
232
|
-
else if (lang.startsWith('ru')) phrase = 'Проверка голоса.'
|
|
233
234
|
try {
|
|
234
235
|
const probe = Object.assign({}, cfg, {
|
|
235
236
|
chain: [{ provider: body.provider || '', model: body.model || '', voice: body.voice || '' }],
|
|
@@ -410,4 +411,10 @@ export function registerHttpRoutes(ctx, api) {
|
|
|
410
411
|
}
|
|
411
412
|
},
|
|
412
413
|
}), 'dsh-tts: /speak route')
|
|
414
|
+
|
|
415
|
+
registerPluginUpdater(ctx, {
|
|
416
|
+
endpoint: '/dsh-tts/updater',
|
|
417
|
+
packageName: '@goodandready/dsh-tts',
|
|
418
|
+
manifestUrl: new URL('../package.json', import.meta.url),
|
|
419
|
+
})
|
|
413
420
|
}
|
package/lib/text.js
CHANGED
|
@@ -15,14 +15,12 @@ export function blocksToText(content) {
|
|
|
15
15
|
// the configured speech language; {n} is filled with a line/row count.
|
|
16
16
|
export const SPEECH_PHRASES = {
|
|
17
17
|
en: { codeBlock: 'code block, {n} lines', table: 'table, {n} rows', summaryIntro: 'Summary of the reply.' },
|
|
18
|
-
ru: { codeBlock: 'блок кода, {n} строк', table: 'таблица, {n} строк', summaryIntro: 'Пересказ ответа.' },
|
|
19
18
|
zh: { codeBlock: '代码块,共 {n} 行', table: '表格,共 {n} 行', summaryIntro: '回复摘要。' },
|
|
20
19
|
}
|
|
21
20
|
|
|
22
21
|
export function speechPhrases(language) {
|
|
23
22
|
const lang = String(language || '').toLowerCase()
|
|
24
23
|
if (lang.startsWith('zh')) return SPEECH_PHRASES.zh
|
|
25
|
-
if (lang.startsWith('ru')) return SPEECH_PHRASES.ru
|
|
26
24
|
return SPEECH_PHRASES.en
|
|
27
25
|
}
|
|
28
26
|
|
|
@@ -43,12 +41,12 @@ export function applyNarrationFilters(text, cfg) {
|
|
|
43
41
|
return out
|
|
44
42
|
}
|
|
45
43
|
|
|
46
|
-
// Rough language heuristic
|
|
44
|
+
// Rough language heuristic using Unicode Script properties.
|
|
47
45
|
export function detectLang(text) {
|
|
48
46
|
const s = String(text || '')
|
|
49
|
-
const cjk = (s.match(/
|
|
50
|
-
const cyr = (s.match(/
|
|
51
|
-
const lat = (s.match(/
|
|
47
|
+
const cjk = (s.match(/\p{Script=Han}/gu) || []).length
|
|
48
|
+
const cyr = (s.match(/\p{Script=Cyrillic}/gu) || []).length
|
|
49
|
+
const lat = (s.match(/\p{Script=Latin}/gu) || []).length
|
|
52
50
|
if (cjk >= cyr && cjk >= lat && cjk > 0) return 'zh'
|
|
53
51
|
return cyr > lat ? 'ru' : 'en'
|
|
54
52
|
}
|
|
@@ -61,33 +59,7 @@ function escapeRe(s) {
|
|
|
61
59
|
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
62
60
|
}
|
|
63
61
|
|
|
64
|
-
export const BUILTIN_IT_DICTIONARY = [
|
|
65
|
-
{ from: 'SQL', to: 'сиквел', whole: true, lang: 'ru' },
|
|
66
|
-
{ from: 'Nginx', to: 'энджинкс', whole: true, lang: 'ru' },
|
|
67
|
-
{ from: 'Kubernetes', to: 'кубернетис', whole: true, lang: 'ru' },
|
|
68
|
-
{ from: 'K8s', to: 'кубернетис', whole: true, lang: 'ru' },
|
|
69
|
-
{ from: 'PostgreSQL', to: 'постгрес', whole: true, lang: 'ru' },
|
|
70
|
-
{ from: 'Redis', to: 'рэдис', whole: true, lang: 'ru' },
|
|
71
|
-
{ from: 'Docker', to: 'докер', whole: true, lang: 'ru' },
|
|
72
|
-
{ from: 'API', to: 'апи', whole: true, lang: 'ru' },
|
|
73
|
-
{ from: 'JSON', to: 'джейсон', whole: true, lang: 'ru' },
|
|
74
|
-
{ from: 'YAML', to: 'ямл', whole: true, lang: 'ru' },
|
|
75
|
-
{ from: 'GUI', to: 'гуи', whole: true, lang: 'ru' },
|
|
76
|
-
{ from: 'CLI', to: 'си элай', whole: true, lang: 'ru' },
|
|
77
|
-
{ from: 'CI/CD', to: 'си ай си ди', whole: false, lang: 'ru' },
|
|
78
|
-
{ from: 'PR', to: 'пулл реквест', whole: true, lang: 'ru' },
|
|
79
|
-
{ from: 'Regex', to: 'регэкс', whole: true, lang: 'ru' },
|
|
80
|
-
{ from: 'OAuth', to: 'о-аус', whole: true, lang: 'ru' },
|
|
81
|
-
{ from: 'HTTP', to: 'эйч ти ти пи', whole: true, lang: 'ru' },
|
|
82
|
-
{ from: 'HTTPS', to: 'эйч ти ти пи эс', whole: true, lang: 'ru' },
|
|
83
|
-
{ from: 'URL', to: 'юрл', whole: true, lang: 'ru' },
|
|
84
|
-
{ from: 'SSH', to: 'эс эс эйч', whole: true, lang: 'ru' },
|
|
85
|
-
{ from: 'IP', to: 'ай пи', whole: true, lang: 'ru' },
|
|
86
|
-
{ from: 'DNS', to: 'ди эн эс', whole: true, lang: 'ru' },
|
|
87
|
-
{ from: 'CPU', to: 'си пи ю', whole: true, lang: 'ru' },
|
|
88
|
-
{ from: 'GPU', to: 'джи пи ю', whole: true, lang: 'ru' },
|
|
89
|
-
{ from: 'RAM', to: 'рам', whole: true, lang: 'ru' },
|
|
90
|
-
]
|
|
62
|
+
export const BUILTIN_IT_DICTIONARY = []
|
|
91
63
|
|
|
92
64
|
export function applyPronunciation(text, rules, language, enableBuiltinIt = false) {
|
|
93
65
|
let out = String(text || '')
|
|
@@ -152,13 +124,8 @@ export function assistantText(message) {
|
|
|
152
124
|
return blocksToText(message.content)
|
|
153
125
|
}
|
|
154
126
|
|
|
155
|
-
// Protect common abbreviations from false sentence splits
|
|
127
|
+
// Protect common English abbreviations from false sentence splits
|
|
156
128
|
const PROTECTED_ABBREVIATIONS = [
|
|
157
|
-
// Russian abbreviations
|
|
158
|
-
'т.е.', 'т.д.', 'т.п.', 'т.к.', 'руб.', 'коп.', 'ул.', 'пер.', 'пр.', 'просп.',
|
|
159
|
-
'д.', 'корп.', 'стр.', 'кв.', 'г.', 'гг.', 'в.', 'вв.', 'см.', 'ср.', 'напр.',
|
|
160
|
-
'рис.', 'табл.', 'им.', 'акад.', 'проф.', 'доц.', 'млн.', 'млрд.', 'тыс.',
|
|
161
|
-
// English abbreviations
|
|
162
129
|
'e.g.', 'i.e.', 'vs.', 'etc.', 'dr.', 'mr.', 'mrs.', 'ms.', 'prof.',
|
|
163
130
|
'approx.', 'dept.', 'est.', 'min.', 'max.', 'no.', 'vol.', 'p.m.', 'a.m.'
|
|
164
131
|
]
|
|
@@ -173,7 +140,7 @@ export function protectAbbreviations(text) {
|
|
|
173
140
|
// Decimal numbers and versions: e.g. 3.14, v0.4.6, 192.168.0.1
|
|
174
141
|
res = res.replace(/(?<=[vV]?\d+)\.(?=\d+)/g, '\u2024')
|
|
175
142
|
// List numbering at line/word starts: 1. item, 2. item
|
|
176
|
-
res = res.replace(/(?<=^|[\s\n])(\d+)\.(?=\s+[A-Za-z
|
|
143
|
+
res = res.replace(/(?<=^|[\s\n])(\d+)\.(?=\s+[A-Za-z\u4E00-\u9FFF])/g, '$1\u2024')
|
|
177
144
|
// Common file extensions: e.g. package.json, index.js, script.py
|
|
178
145
|
res = res.replace(/(?<=[a-zA-Z0-9_-])\.(?:js|mjs|cjs|ts|tsx|jsx|json|ya?ml|md|py|sh|tgz|tar|gz|zip|html?|css|rs|go|c|cpp|h|txt)(?=[ \t\n"«'\)\]\},;\.\?!]|$)/gi, (m) => m.replace(/\./g, '\u2024'))
|
|
179
146
|
// Hostnames and domains: e.g. goodandready.app, github.com
|
package/lib/updater.js
ADDED
|
@@ -0,0 +1,333 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process'
|
|
2
|
+
import { existsSync, readFileSync } from 'node:fs'
|
|
3
|
+
import { readFile } from 'node:fs/promises'
|
|
4
|
+
import { homedir } from 'node:os'
|
|
5
|
+
import { basename, dirname, isAbsolute, resolve } from 'node:path'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Host-side one-click updater for @goodandready/dsh-tts.
|
|
9
|
+
* Implements the unified DSH plugin updater specification.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
const UPDATE_HEADER = 'x-dsh-plugin-update'
|
|
13
|
+
const UPDATE_TIMEOUT_MS = 10 * 60_000
|
|
14
|
+
const VERSION_CACHE_MS = 5 * 60_000
|
|
15
|
+
let latestCache = undefined
|
|
16
|
+
|
|
17
|
+
function header(request, name) {
|
|
18
|
+
const value = request?.headers?.[name]
|
|
19
|
+
return Array.isArray(value) ? value[0] : value
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function isLoopback(value) {
|
|
23
|
+
const address = value?.toLowerCase().replace(/^\[|\]$/g, '')
|
|
24
|
+
return (
|
|
25
|
+
address === 'localhost' ||
|
|
26
|
+
address === 'localhost.' ||
|
|
27
|
+
address === '::1' ||
|
|
28
|
+
address?.startsWith('127.') === true ||
|
|
29
|
+
address?.startsWith('::ffff:127.') === true
|
|
30
|
+
)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function isPrivateLan(value) {
|
|
34
|
+
const address = value?.toLowerCase().replace(/^\[|\]$/g, '')
|
|
35
|
+
if (!address) return false
|
|
36
|
+
const ipv4 = address.startsWith('::ffff:') ? address.slice(7) : address
|
|
37
|
+
return (
|
|
38
|
+
ipv4.startsWith('192.168.') ||
|
|
39
|
+
ipv4.startsWith('10.') ||
|
|
40
|
+
/^172\.(1[6-9]|2[0-9]|3[0-1])\./.test(ipv4)
|
|
41
|
+
)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function isTrustedUpdateRequest(request) {
|
|
45
|
+
if (header(request, UPDATE_HEADER) !== '1') return false
|
|
46
|
+
const remote = request.socket?.remoteAddress
|
|
47
|
+
if (!isLoopback(remote) && !isPrivateLan(remote)) return false
|
|
48
|
+
const site = header(request, 'sec-fetch-site')
|
|
49
|
+
if (site !== undefined && site !== 'same-origin') return false
|
|
50
|
+
const origin = header(request, 'origin')
|
|
51
|
+
const host = header(request, 'host')
|
|
52
|
+
if (origin === undefined || host === undefined) return false
|
|
53
|
+
try {
|
|
54
|
+
const url = new URL(origin)
|
|
55
|
+
return (
|
|
56
|
+
(url.protocol === 'http:' || url.protocol === 'https:') &&
|
|
57
|
+
(isLoopback(url.hostname) || isPrivateLan(url.hostname)) &&
|
|
58
|
+
url.host === host
|
|
59
|
+
)
|
|
60
|
+
} catch {
|
|
61
|
+
return false
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function validProfileName(value) {
|
|
66
|
+
return (
|
|
67
|
+
typeof value === 'string' &&
|
|
68
|
+
value !== '' &&
|
|
69
|
+
value !== '.' &&
|
|
70
|
+
value !== '..' &&
|
|
71
|
+
!value.includes('/') &&
|
|
72
|
+
!value.includes('\\') &&
|
|
73
|
+
!/[\0-\x1f\x7f]/.test(value)
|
|
74
|
+
)
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function profileNameFromArgv(argv) {
|
|
78
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
79
|
+
const arg = argv[index]
|
|
80
|
+
if (arg === '--profile' && argv[index + 1] !== undefined) return argv[index + 1]
|
|
81
|
+
if (typeof arg === 'string' && arg.startsWith('--profile=')) return arg.slice(10)
|
|
82
|
+
}
|
|
83
|
+
return undefined
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function findDshCliEntry() {
|
|
87
|
+
const entry = process.argv[1]
|
|
88
|
+
if (typeof entry !== 'string' || entry === '') return undefined
|
|
89
|
+
let directory = process.cwd()
|
|
90
|
+
for (let depth = 0; depth < 6; depth += 1) {
|
|
91
|
+
const manifestPath = resolve(directory, 'package.json')
|
|
92
|
+
if (existsSync(manifestPath)) {
|
|
93
|
+
try {
|
|
94
|
+
const manifest = JSON.parse(readFileSync(manifestPath, 'utf8'))
|
|
95
|
+
const bin =
|
|
96
|
+
typeof manifest.bin === 'string'
|
|
97
|
+
? manifest.bin
|
|
98
|
+
: typeof manifest.bin === 'object' && manifest.bin !== null
|
|
99
|
+
? manifest.bin.dsh
|
|
100
|
+
: undefined
|
|
101
|
+
if (
|
|
102
|
+
manifest.name === '@deepseek-ai/dsh' &&
|
|
103
|
+
typeof bin === 'string' &&
|
|
104
|
+
!isAbsolute(bin) &&
|
|
105
|
+
resolve(directory, bin) === resolve(entry)
|
|
106
|
+
) {
|
|
107
|
+
return entry
|
|
108
|
+
}
|
|
109
|
+
} catch {
|
|
110
|
+
// Continue searching parent directories
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
const parent = dirname(directory)
|
|
114
|
+
if (parent === directory) return undefined
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function runtime() {
|
|
119
|
+
const profileDir = resolve(
|
|
120
|
+
process.env.DSH_PROFILE_DIR ?? resolve(homedir(), '.dsh', 'profiles', 'web')
|
|
121
|
+
)
|
|
122
|
+
const selected = profileNameFromArgv(process.argv)
|
|
123
|
+
const profileName = validProfileName(selected)
|
|
124
|
+
? selected
|
|
125
|
+
: validProfileName(basename(profileDir))
|
|
126
|
+
? basename(profileDir)
|
|
127
|
+
: 'web'
|
|
128
|
+
const cliEntry = findDshCliEntry()
|
|
129
|
+
return cliEntry === undefined ? { profileName, profileDir } : { profileName, profileDir, cliEntry }
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function parseSemver(value) {
|
|
133
|
+
const match = /^v?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/.exec(value)
|
|
134
|
+
if (match === null) return undefined
|
|
135
|
+
return {
|
|
136
|
+
core: [Number(match[1]), Number(match[2]), Number(match[3])],
|
|
137
|
+
prerelease: match[4]?.split('.') ?? [],
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function comparePrerelease(left, right) {
|
|
142
|
+
if (left.length === 0 || right.length === 0) return left.length === right.length ? 0 : left.length === 0 ? 1 : -1
|
|
143
|
+
const length = Math.max(left.length, right.length)
|
|
144
|
+
for (let index = 0; index < length; index += 1) {
|
|
145
|
+
const a = left[index]
|
|
146
|
+
const b = right[index]
|
|
147
|
+
if (a === undefined || b === undefined) return a === b ? 0 : a === undefined ? -1 : 1
|
|
148
|
+
if (a === b) continue
|
|
149
|
+
const aNumeric = /^\d+$/.test(a)
|
|
150
|
+
const bNumeric = /^\d+$/.test(b)
|
|
151
|
+
if (aNumeric && bNumeric) {
|
|
152
|
+
const aNumber = BigInt(a)
|
|
153
|
+
const bNumber = BigInt(b)
|
|
154
|
+
if (aNumber !== bNumber) return aNumber > bNumber ? 1 : -1
|
|
155
|
+
continue
|
|
156
|
+
}
|
|
157
|
+
if (aNumeric !== bNumeric) return aNumeric ? -1 : 1
|
|
158
|
+
return a > b ? 1 : -1
|
|
159
|
+
}
|
|
160
|
+
return 0
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export function isNewerVersion(currentValue, candidateValue) {
|
|
164
|
+
const current = parseSemver(currentValue)
|
|
165
|
+
const candidate = parseSemver(candidateValue)
|
|
166
|
+
if (current === undefined || candidate === undefined) return false
|
|
167
|
+
for (let index = 0; index < 3; index += 1) {
|
|
168
|
+
if (candidate.core[index] !== current.core[index]) return candidate.core[index] > current.core[index]
|
|
169
|
+
}
|
|
170
|
+
return comparePrerelease(candidate.prerelease, current.prerelease) > 0
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
async function latestVersion(packageName, registry) {
|
|
174
|
+
if (
|
|
175
|
+
latestCache?.packageName === packageName &&
|
|
176
|
+
latestCache.registry === registry &&
|
|
177
|
+
Date.now() < latestCache.expiresAt
|
|
178
|
+
) {
|
|
179
|
+
return latestCache.version
|
|
180
|
+
}
|
|
181
|
+
try {
|
|
182
|
+
const response = await fetch(`${registry.replace(/\/$/, '')}/${encodeURIComponent(packageName)}/latest`, {
|
|
183
|
+
signal: typeof AbortSignal !== 'undefined' && AbortSignal.timeout ? AbortSignal.timeout(8000) : undefined,
|
|
184
|
+
})
|
|
185
|
+
if (!response.ok) return undefined
|
|
186
|
+
const value = await response.json()
|
|
187
|
+
if (typeof value?.version !== 'string' || value.version === '') return undefined
|
|
188
|
+
latestCache = { packageName, registry, version: value.version, expiresAt: Date.now() + VERSION_CACHE_MS }
|
|
189
|
+
return value.version
|
|
190
|
+
} catch {
|
|
191
|
+
return undefined
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
async function currentVersion(manifestUrl) {
|
|
196
|
+
const value = JSON.parse(await readFile(manifestUrl, 'utf8'))
|
|
197
|
+
if (typeof value?.version !== 'string' || value.version === '') {
|
|
198
|
+
throw new Error('Cannot read current plugin version.')
|
|
199
|
+
}
|
|
200
|
+
return value.version
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
export async function status(options, target) {
|
|
204
|
+
const current = await currentVersion(options.manifestUrl)
|
|
205
|
+
const latest = await latestVersion(options.packageName, options.registry ?? 'https://registry.npmjs.org')
|
|
206
|
+
return {
|
|
207
|
+
packageName: options.packageName,
|
|
208
|
+
currentVersion: current,
|
|
209
|
+
...(latest === undefined ? {} : { latestVersion: latest }),
|
|
210
|
+
latestCheckFailed: latest === undefined,
|
|
211
|
+
updateAvailable: latest !== undefined && isNewerVersion(current, latest),
|
|
212
|
+
profileName: target.profileName,
|
|
213
|
+
canAutoUpdate: target.cliEntry !== undefined,
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
async function installExact(target, packageSpec, options) {
|
|
218
|
+
if (target.cliEntry === undefined) throw new Error('Automatic update is unavailable in this runtime.')
|
|
219
|
+
await new Promise((resolvePromise, reject) => {
|
|
220
|
+
const child = spawn(
|
|
221
|
+
process.execPath,
|
|
222
|
+
[
|
|
223
|
+
target.cliEntry,
|
|
224
|
+
'plugin',
|
|
225
|
+
'--profile',
|
|
226
|
+
target.profileName,
|
|
227
|
+
'add',
|
|
228
|
+
'--config.minimumReleaseAge=0',
|
|
229
|
+
packageSpec,
|
|
230
|
+
`--registry=${options.registry ?? 'https://registry.npmjs.org/'}`,
|
|
231
|
+
],
|
|
232
|
+
{
|
|
233
|
+
cwd: target.profileDir,
|
|
234
|
+
windowsHide: true,
|
|
235
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
236
|
+
env: { ...process.env, NO_COLOR: '1' },
|
|
237
|
+
}
|
|
238
|
+
)
|
|
239
|
+
let detail = ''
|
|
240
|
+
child.stdout?.on('data', (chunk) => {
|
|
241
|
+
detail = (detail + String(chunk)).slice(-4000)
|
|
242
|
+
})
|
|
243
|
+
child.stderr?.on('data', (chunk) => {
|
|
244
|
+
detail = (detail + String(chunk)).slice(-4000)
|
|
245
|
+
})
|
|
246
|
+
const timer = setTimeout(() => {
|
|
247
|
+
child.kill()
|
|
248
|
+
reject(new Error('Update timed out; use the normal DSH update flow.'))
|
|
249
|
+
}, UPDATE_TIMEOUT_MS)
|
|
250
|
+
child.once('error', (error) => {
|
|
251
|
+
clearTimeout(timer)
|
|
252
|
+
reject(error)
|
|
253
|
+
})
|
|
254
|
+
child.once('exit', (code) => {
|
|
255
|
+
clearTimeout(timer)
|
|
256
|
+
if (code === 0) resolvePromise()
|
|
257
|
+
else reject(new Error(detail.trim() || `Update exited with code ${String(code)}.`))
|
|
258
|
+
})
|
|
259
|
+
})
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function json(response, statusCode, value) {
|
|
263
|
+
response.writeHead(statusCode, {
|
|
264
|
+
'content-type': 'application/json; charset=utf-8',
|
|
265
|
+
'cache-control': 'no-store',
|
|
266
|
+
})
|
|
267
|
+
response.end(JSON.stringify(value))
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
export function registerPluginUpdater(ctx, options) {
|
|
271
|
+
let installing = false
|
|
272
|
+
if (typeof ctx?.webServer?.register !== 'function') {
|
|
273
|
+
ctx?.logger?.warn?.('[dsh-tts] webServer service unavailable, updater skipped')
|
|
274
|
+
return () => {}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
return ctx.webServer.register({
|
|
278
|
+
kind: 'exact',
|
|
279
|
+
path: options.endpoint,
|
|
280
|
+
handler: async (request, response) => {
|
|
281
|
+
try {
|
|
282
|
+
const target = runtime()
|
|
283
|
+
if (request.method === 'GET' || request.method === 'HEAD') {
|
|
284
|
+
const payload = await status(options, target)
|
|
285
|
+
response.writeHead(200, {
|
|
286
|
+
'content-type': 'application/json; charset=utf-8',
|
|
287
|
+
'cache-control': 'no-store',
|
|
288
|
+
})
|
|
289
|
+
response.end(request.method === 'HEAD' ? undefined : JSON.stringify(payload))
|
|
290
|
+
return
|
|
291
|
+
}
|
|
292
|
+
if (request.method !== 'POST') {
|
|
293
|
+
response.writeHead(405, { allow: 'GET, HEAD, POST' })
|
|
294
|
+
response.end()
|
|
295
|
+
return
|
|
296
|
+
}
|
|
297
|
+
if (!isTrustedUpdateRequest(request)) {
|
|
298
|
+
json(response, 403, { error: 'Rejected non-local or cross-origin update request.' })
|
|
299
|
+
return
|
|
300
|
+
}
|
|
301
|
+
if (installing) {
|
|
302
|
+
json(response, 409, { error: 'This plugin is already updating.' })
|
|
303
|
+
return
|
|
304
|
+
}
|
|
305
|
+
installing = true
|
|
306
|
+
try {
|
|
307
|
+
const before = await status(options, target)
|
|
308
|
+
if (before.latestVersion === undefined) {
|
|
309
|
+
json(response, 503, { error: 'The latest version is temporarily unavailable.' })
|
|
310
|
+
return
|
|
311
|
+
}
|
|
312
|
+
if (!before.updateAvailable) {
|
|
313
|
+
json(response, 200, before)
|
|
314
|
+
return
|
|
315
|
+
}
|
|
316
|
+
await installExact(target, `${options.packageName}@${before.latestVersion}`, options)
|
|
317
|
+
json(response, 200, {
|
|
318
|
+
...before,
|
|
319
|
+
updatedVersion: before.latestVersion,
|
|
320
|
+
restartRequired: true,
|
|
321
|
+
})
|
|
322
|
+
} catch (err) {
|
|
323
|
+
json(response, 503, { error: `Plugin update failed: ${String(err?.message || err)}` })
|
|
324
|
+
} finally {
|
|
325
|
+
installing = false
|
|
326
|
+
}
|
|
327
|
+
} catch (error) {
|
|
328
|
+
ctx?.logger?.warn?.(`[dsh-tts] plugin updater failed: ${String(error)}`)
|
|
329
|
+
json(response, 503, { error: 'Plugin update failed; see server logs.' })
|
|
330
|
+
}
|
|
331
|
+
},
|
|
332
|
+
})
|
|
333
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@goodandready/dsh-tts",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.17",
|
|
4
4
|
"description": "Text-to-speech for DeepSeek Harness: speak agent replies in the Web UI with a provider fallback chain (OpenAI, ElevenLabs, Google, Azure, Groq, Deepgram, OpenRouter, Edge, Piper, eSpeak).",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
"lib/",
|
|
16
16
|
"scripts/",
|
|
17
17
|
"cordis.patch.yml",
|
|
18
|
+
"CHANGELOG.md",
|
|
18
19
|
"README.md",
|
|
19
20
|
"LICENSE",
|
|
20
21
|
"README.ru.md",
|