@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 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
@@ -164,7 +164,6 @@ dsh plugin --profile web add @goodandready/dsh-tts
164
164
 
165
165
  API 密钥不写入插件设置,只在链编辑器粘贴,经 `PUT /dsh-tts/credential` 写入 DSH credential store。
166
166
 
167
- 仅配置文件:`*KeyEnv` 字段(凭证槽位名称)。侧边栏 `settings.section` 仅为 fallback。
168
167
 
169
168
  ## ⚙️ 配置示例(`settings.yaml`)
170
169
 
@@ -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
- // Primary settings home is the plugin-settings tab card. The tab looks up
141
- // a slot by entryKey equal to the namespace name: key must equal NS or
142
- // the card never appears, with no log error.
143
- const tryPluginItem = () => {
144
- try {
145
- const res = ctx.slots.inject('settings.plugin.item', () => ctx.slots.register(
146
- {
147
- name: 'settings.plugin.item',
148
- key: NS,
149
- locale: NS,
150
- inject: () => ({ ctx: ctx }),
151
- },
152
- TtsCard,
153
- ))
154
- if (res === false) return false
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
- if (tryPluginItem()) return
161
- // Fallback for builds without settings.plugin.item: keep the sidebar section
162
- // so settings cannot disappear.
163
- ctx.slots.inject('settings.section', () => ctx.slots.register(
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.section',
166
- id: '@goodandready/dsh-tts',
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
- (props) => React.createElement(ErrorBoundary, null, React.createElement(TtsSection, props)),
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.key === 's' || e.key === 'S' || e.key === 'ы')) { stopPlayback(); e.preventDefault() }
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
- // Primary settings home is the plugin-settings tab card. The tab looks up
1925
- // a slot by entryKey equal to the namespace name: key must equal NS or
1926
- // the card never appears, with no log error.
1927
- const tryPluginItem = () => {
1928
- try {
1929
- const res = ctx.slots.inject('settings.plugin.item', () => ctx.slots.register(
1930
- {
1931
- name: 'settings.plugin.item',
1932
- key: NS,
1933
- locale: NS,
1934
- inject: () => ({ ctx: ctx }),
1935
- },
1936
- TtsCard,
1937
- ))
1938
- if (res === false) return false
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
- if (tryPluginItem()) return
1945
- // Fallback for builds without settings.plugin.item: keep the sidebar section
1946
- // so settings cannot disappear.
1947
- ctx.slots.inject('settings.section', () => ctx.slots.register(
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.section',
1950
- id: '@goodandready/dsh-tts',
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
- (props) => React.createElement(ErrorBoundary, null, React.createElement(TtsSection, props)),
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.key === 's' || e.key === 'S' || e.key === 'ы')) { stopPlayback(); e.preventDefault() }
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
- /** Reject cross-site POSTs. Do not require loopback or Origin==Host: the Web UI is used over LAN and reverse proxies. */
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
- return request.headers['sec-fetch-site'] !== 'cross-site'
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: whichever script has more letters wins.
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(/[\u4E00-\u9FFF]/g) || []).length
50
- const cyr = (s.match(/[\u0400-\u04FF]/g) || []).length
51
- const lat = (s.match(/[A-Za-z]/g) || []).length
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А-Яа-я\u4E00-\u9FFF])/g, '$1\u2024')
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.10",
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",