dsh-pf-stream 0.2.4 → 0.3.0

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.
Files changed (80) hide show
  1. package/README.en.md +145 -33
  2. package/README.md +142 -30
  3. package/client.js +1156 -0
  4. package/icon.svg +18 -0
  5. package/lib/asr/catalog.d.ts +74 -0
  6. package/lib/asr/catalog.js +168 -0
  7. package/lib/asr/catalog.js.map +1 -0
  8. package/lib/asr/detect.d.ts +33 -0
  9. package/lib/asr/detect.js +117 -0
  10. package/lib/asr/detect.js.map +1 -0
  11. package/lib/asr/engine.d.ts +43 -0
  12. package/lib/asr/engine.js +143 -0
  13. package/lib/asr/engine.js.map +1 -0
  14. package/lib/asr/install.d.ts +56 -0
  15. package/lib/asr/install.js +317 -0
  16. package/lib/asr/install.js.map +1 -0
  17. package/lib/asr/verify.d.ts +22 -0
  18. package/lib/asr/verify.js +174 -0
  19. package/lib/asr/verify.js.map +1 -0
  20. package/lib/audio/acousticVad.d.ts +45 -0
  21. package/lib/audio/acousticVad.js +179 -0
  22. package/lib/audio/acousticVad.js.map +1 -0
  23. package/lib/audio/asrProviders.d.ts +51 -0
  24. package/lib/audio/asrProviders.js +251 -0
  25. package/lib/audio/asrProviders.js.map +1 -0
  26. package/lib/audio/asrRouter.d.ts +30 -0
  27. package/lib/audio/asrRouter.js +187 -0
  28. package/lib/audio/asrRouter.js.map +1 -0
  29. package/lib/audio/hallucinationGuard.d.ts +18 -0
  30. package/lib/audio/hallucinationGuard.js +97 -0
  31. package/lib/audio/hallucinationGuard.js.map +1 -0
  32. package/lib/audio/sidecar.d.ts +76 -0
  33. package/lib/audio/sidecar.js +362 -0
  34. package/lib/audio/sidecar.js.map +1 -0
  35. package/lib/audio/subtitleExtractor.d.ts +36 -0
  36. package/lib/audio/subtitleExtractor.js +218 -0
  37. package/lib/audio/subtitleExtractor.js.map +1 -0
  38. package/lib/audio/transcriptCache.d.ts +33 -0
  39. package/lib/audio/transcriptCache.js +126 -0
  40. package/lib/audio/transcriptCache.js.map +1 -0
  41. package/lib/audio/types.d.ts +136 -0
  42. package/lib/audio/types.js +10 -0
  43. package/lib/audio/types.js.map +1 -0
  44. package/lib/host/api.d.ts +23 -0
  45. package/lib/host/api.js +286 -0
  46. package/lib/host/api.js.map +1 -0
  47. package/lib/host/runtime.d.ts +167 -0
  48. package/lib/host/runtime.js +335 -0
  49. package/lib/host/runtime.js.map +1 -0
  50. package/lib/index.d.ts +229 -1
  51. package/lib/index.js +103 -2
  52. package/lib/index.js.map +1 -1
  53. package/lib/session/state.js +4 -0
  54. package/lib/session/state.js.map +1 -1
  55. package/lib/stage0/decoder.d.ts +1 -9
  56. package/lib/stage0/decoder.js +15 -45
  57. package/lib/stage0/decoder.js.map +1 -1
  58. package/lib/stage2/stateMachine.d.ts +4 -1
  59. package/lib/stage2/stateMachine.js +7 -1
  60. package/lib/stage2/stateMachine.js.map +1 -1
  61. package/lib/stage3/deterministicRouter.js +3 -3
  62. package/lib/stage3/deterministicRouter.js.map +1 -1
  63. package/lib/stage3/streamSampler.d.ts +9 -2
  64. package/lib/stage3/streamSampler.js +37 -16
  65. package/lib/stage3/streamSampler.js.map +1 -1
  66. package/lib/stage6/history.d.ts +2 -1
  67. package/lib/stage6/history.js +4 -1
  68. package/lib/stage6/history.js.map +1 -1
  69. package/lib/tool/handler.d.ts +9 -0
  70. package/lib/tool/handler.js +33 -3
  71. package/lib/tool/handler.js.map +1 -1
  72. package/lib/tool/schema.js +15 -2
  73. package/lib/tool/schema.js.map +1 -1
  74. package/lib/tool/streamCall.d.ts +11 -0
  75. package/lib/tool/streamCall.js +122 -9
  76. package/lib/tool/streamCall.js.map +1 -1
  77. package/lib/types.d.ts +9 -0
  78. package/locale/en.json +6 -0
  79. package/locale/zh.json +6 -0
  80. package/package.json +17 -2
package/client.js ADDED
@@ -0,0 +1,1156 @@
1
+ /**
2
+ * dsh-pf-stream 的浏览器半:插件详情页的语音转写设置面板 + 启用后的引导卡。
3
+ *
4
+ * 通信:走宿主 webServer 上的同源 HTTP 路由(/dsh-pf-stream/api/*)。
5
+ * 持久 bundle 的浏览器半只有 fetch 可用;iframe 拿不到主题与语言,因此这里
6
+ * 是纯 React 组件 + 宿主 slot。
7
+ *
8
+ * 约束:不能 require 任何 Harness 客户端包(版本会漂移),控件与样式自带,
9
+ * 颜色只用 --dsw-alias-* 主题变量。
10
+ */
11
+
12
+ window.__ModuleLoader__.load({
13
+ id: 'dsh-pf-stream',
14
+ factory(require) {
15
+ const React = require('react');
16
+ const h = React.createElement;
17
+
18
+ const NS = 'dsh-pf-stream';
19
+ const API = '/dsh-pf-stream/api';
20
+
21
+ const ZH = {
22
+ panelTitle: '语音识别与转写',
23
+ panelSubtitle:
24
+ '以下设置用于启用语音识别与转写。未配置时不影响视频播放,但不会生成转写文本;完成配置后,可对含有效音轨的视频进行转写。',
25
+ refresh: '刷新',
26
+ checking: '正在读取状态…',
27
+ ffmpeg: '视频解码',
28
+ ffmpegOk: '已就绪',
29
+ ffmpegMissing: '待安装',
30
+ ffmpegHint: '视频解码需要 ffmpeg 与 ffprobe。可在「高级」中填写可执行文件路径,或将其加入系统 PATH。',
31
+ local: '本地转写',
32
+ localReady: '已就绪',
33
+ localMissing: '待安装',
34
+ localHint: '本地转写音频仅在本机处理。',
35
+ cloud: '云端转写',
36
+ cloudReady: '已配置密钥',
37
+ cloudMissing: '待配置',
38
+ cloudHint: '云端转写只发送音频片段。',
39
+ install: '下载本地模型',
40
+ reinstall: '重新下载本地模型',
41
+ installSize: 'small · 约 190 MB · 仅需下载一次',
42
+ installedHint: '本地模型已就绪。',
43
+ installing: '正在安装',
44
+ cancel: '取消',
45
+ verify: '测试转写',
46
+ verifying: '正在验证…',
47
+ verifyHint: '使用一段测试音频验证引擎是否可用。',
48
+ verifyOk: '验证通过',
49
+ cloudSection: '云端转写密钥',
50
+ preset: '服务商',
51
+ baseUrl: '接口地址',
52
+ modelName: '模型名',
53
+ apiKey: 'API Key',
54
+ keyPlaceholder: '粘贴密钥;保存后仅显示“已配置”',
55
+ save: '保存',
56
+ clear: '清除',
57
+ saved: '已保存',
58
+ cleared: '已清除',
59
+ keyNote: '密钥保存在本机凭据库,配置文件中仅保留引用。',
60
+ getKey: '获取密钥 ↗',
61
+ advanced: '高级',
62
+ engine: '引擎选择',
63
+ engineAuto: '自动(推荐)',
64
+ engineLocal: '仅本地',
65
+ engineCloud: '仅云端',
66
+ engineOff: '不转写',
67
+ engineHint: '自动:云端配置完整时用云端(更快,按量计费);否则用本地模型。',
68
+ language: '语言',
69
+ languageAuto: '自动识别',
70
+ modelSize: '本地模型大小',
71
+ recommended: '推荐',
72
+ threads: 'CPU 线程数(0 为自动)',
73
+ cache: '转写缓存',
74
+ clearCache: '清理转写缓存',
75
+ activeLocal: '本地',
76
+ activeCloud: '云端',
77
+ activeNone: '未启用',
78
+ cacheCleared: '缓存已清理',
79
+ settingsReadonly: '该配置由宿主管理,请在配置文件中修改。',
80
+ badgeReady: '转写可用',
81
+ badgePending: '待完成配置',
82
+ activationReady: '转写引擎已可用。',
83
+ activationTitle: '启用语音转写',
84
+ activationBody: '请选择转写方式:下载本地模型(约 190 MB),或配置云端服务商密钥。',
85
+ activationInstall: '下载并启用本地模型',
86
+ openSettings: '打开设置',
87
+ later: '稍后再说',
88
+ privacy: '本地转写音频仅在本机处理;云端转写只发送音频片段。',
89
+ loadFailed: '读取状态失败',
90
+ installStarted: '下载已开始',
91
+ waitDownload: '下载在后台进行,画面处理照常。',
92
+ };
93
+
94
+ const EN = {
95
+ panelTitle: 'Speech recognition and transcription',
96
+ panelSubtitle:
97
+ 'These settings enable speech recognition and transcription. Without them, video playback is unaffected, but no transcript is generated. Once configured, videos with a usable audio track can be transcribed.',
98
+ refresh: 'Refresh',
99
+ checking: 'Reading status…',
100
+ ffmpeg: 'Video decoding',
101
+ ffmpegOk: 'Ready',
102
+ ffmpegMissing: 'To install',
103
+ ffmpegHint: 'Video decoding requires ffmpeg and ffprobe. Set their paths under Advanced, or add them to the system PATH.',
104
+ local: 'Local transcription',
105
+ localReady: 'Ready',
106
+ localMissing: 'To install',
107
+ localHint: 'Local transcription processes audio on this machine only.',
108
+ cloud: 'Cloud transcription',
109
+ cloudReady: 'Key configured',
110
+ cloudMissing: 'To configure',
111
+ cloudHint: 'Cloud transcription sends only audio slices.',
112
+ install: 'Download local model',
113
+ reinstall: 'Download the local model again',
114
+ installSize: 'small · ~190 MB · one-time download',
115
+ installedHint: 'The local model is ready.',
116
+ installing: 'Installing',
117
+ cancel: 'Cancel',
118
+ verify: 'Test transcription',
119
+ verifying: 'Testing…',
120
+ verifyHint: 'Runs a short test tone through the engine.',
121
+ verifyOk: 'Verified',
122
+ cloudSection: 'Cloud transcription key',
123
+ preset: 'Provider',
124
+ baseUrl: 'Endpoint',
125
+ modelName: 'Model',
126
+ apiKey: 'API key',
127
+ keyPlaceholder: 'Paste a key; only “configured” is shown afterwards',
128
+ save: 'Save',
129
+ clear: 'Clear',
130
+ saved: 'Saved',
131
+ cleared: 'Cleared',
132
+ keyNote: 'The key is stored in the local credential store; the config file holds a reference only.',
133
+ getKey: 'Get a key ↗',
134
+ advanced: 'Advanced',
135
+ engine: 'Engine',
136
+ engineAuto: 'Auto (recommended)',
137
+ engineLocal: 'Local only',
138
+ engineCloud: 'Cloud only',
139
+ engineOff: 'No transcription',
140
+ engineHint: 'Auto: use the cloud when fully configured (faster, billed per use); otherwise the local model.',
141
+ language: 'Language',
142
+ languageAuto: 'Detect automatically',
143
+ modelSize: 'Local model size',
144
+ recommended: 'recommended',
145
+ threads: 'CPU threads (0 = auto)',
146
+ cache: 'Transcript cache',
147
+ clearCache: 'Clear transcript cache',
148
+ activeLocal: 'Local',
149
+ activeCloud: 'Cloud',
150
+ activeNone: 'Off',
151
+ cacheCleared: 'Cache cleared',
152
+ settingsReadonly: 'This config is managed by the host; edit the config file instead.',
153
+ badgeReady: 'Transcription ready',
154
+ badgePending: 'Setup incomplete',
155
+ activationReady: 'The transcription engine is ready.',
156
+ activationTitle: 'Enable speech transcription',
157
+ activationBody: 'Choose a transcription path: download the local model (~190 MB), or configure a cloud provider key.',
158
+ activationInstall: 'Download and enable the local model',
159
+ openSettings: 'Open settings',
160
+ later: 'Later',
161
+ privacy: 'Local transcription processes audio on this machine only; cloud transcription sends only audio slices.',
162
+ loadFailed: 'Failed to read status',
163
+ installStarted: 'Download started',
164
+ waitDownload: 'The download runs in the background; video processing continues.',
165
+ };
166
+
167
+ function detectLang() {
168
+ try {
169
+ return String(navigator.language || 'zh').toLowerCase().startsWith('zh') ? 'zh' : 'en';
170
+ } catch {
171
+ return 'zh';
172
+ }
173
+ }
174
+
175
+ function makeTranslator(locale) {
176
+ const lang = detectLang();
177
+ const own = lang === 'zh' ? ZH : EN;
178
+ let bound;
179
+ try {
180
+ if (locale && typeof locale.bind === 'function') bound = locale.bind(NS);
181
+ } catch {
182
+ bound = undefined;
183
+ }
184
+ return function t(key) {
185
+ if (typeof bound === 'function') {
186
+ try {
187
+ const value = bound(key);
188
+ if (typeof value === 'string' && value !== '') return value;
189
+ } catch {
190
+ // 回退到自带词典
191
+ }
192
+ }
193
+ return own[key] || key;
194
+ };
195
+ }
196
+
197
+ async function call(path, body) {
198
+ const init =
199
+ body === undefined
200
+ ? { headers: { accept: 'application/json' } }
201
+ : {
202
+ method: 'POST',
203
+ headers: { 'content-type': 'application/json', accept: 'application/json' },
204
+ body: JSON.stringify(body),
205
+ };
206
+ const response = await fetch(API + path, init);
207
+ let payload = null;
208
+ try {
209
+ payload = await response.json();
210
+ } catch {
211
+ payload = null;
212
+ }
213
+ if (!response.ok || (payload && payload.ok === false)) {
214
+ const error = new Error(
215
+ (payload && (payload.message || payload.error)) || 'HTTP ' + response.status,
216
+ );
217
+ error.code = payload && payload.error;
218
+ throw error;
219
+ }
220
+ return payload && payload.data !== undefined ? payload.data : payload;
221
+ }
222
+
223
+ function mb(bytes) {
224
+ if (!bytes || bytes <= 0) return '0 MB';
225
+ return (bytes / 1048576).toFixed(bytes >= 104857600 ? 0 : 1) + ' MB';
226
+ }
227
+
228
+ function engineName(id) {
229
+ if (id === 'managed') return 'whisper.cpp';
230
+ if (id === 'whisper_cpp') return 'whisper.cpp';
231
+ if (id === 'whisper_ctranslate2') return 'faster-whisper';
232
+ if (id === 'openai_whisper') return 'openai-whisper';
233
+ return id || '';
234
+ }
235
+
236
+ const PALETTE = {
237
+ text: 'var(--dsw-alias-label-primary, #1f2329)',
238
+ sub: 'var(--dsw-alias-label-secondary, #6b7280)',
239
+ border: 'var(--dsw-alias-border-l1, rgba(127,127,127,.22))',
240
+ strongBorder: 'var(--dsw-alias-border-l2, rgba(127,127,127,.4))',
241
+ card: 'var(--dsw-alias-bg-layer-1, rgba(127,127,127,.06))',
242
+ nested: 'var(--dsw-alias-bg-layer-2, rgba(127,127,127,.1))',
243
+ brand: 'var(--dsw-alias-brand-primary, #2f6fed)',
244
+ ok: 'var(--dsw-alias-state-success-primary, #1a7f37)',
245
+ warn: 'var(--dsw-alias-state-warn-primary, #9a6700)',
246
+ error: 'var(--dsw-alias-state-error-primary, #c62828)',
247
+ idle: 'var(--dsw-alias-state-idle-primary, #8b949e)',
248
+ glass: 'color-mix(in srgb, var(--dsw-alias-bg-layer-2) 58%, transparent)',
249
+ glassStrong: 'color-mix(in srgb, var(--dsw-alias-bg-layer-2) 76%, transparent)',
250
+ hairline: 'color-mix(in srgb, var(--dsw-alias-label-primary) 12%, transparent)',
251
+ blur: 'blur(24px) saturate(140%)',
252
+ };
253
+
254
+ const styles = {
255
+ root: { display: 'flex', flexDirection: 'column', gap: 16, color: PALETTE.text, fontSize: 13, lineHeight: 1.5 },
256
+ headerRow: { display: 'flex', alignItems: 'center', gap: 10, flexWrap: 'wrap' },
257
+ title: { fontSize: 14, fontWeight: 600, margin: 0, letterSpacing: 0.1 },
258
+ subtitle: { color: PALETTE.sub, fontSize: 12, margin: 0, lineHeight: 1.5 },
259
+ cards: { display: 'grid', gap: 12, gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))' },
260
+ card: {
261
+ border: '.5px solid ' + PALETTE.hairline,
262
+ borderRadius: 14,
263
+ background: PALETTE.glass,
264
+ backdropFilter: PALETTE.blur,
265
+ WebkitBackdropFilter: PALETTE.blur,
266
+ boxShadow: 'inset 0 1px 0 color-mix(in srgb, var(--dsw-alias-label-primary) 6%, transparent)',
267
+ padding: '12px 14px',
268
+ display: 'flex',
269
+ flexDirection: 'column',
270
+ gap: 6,
271
+ minWidth: 0,
272
+ },
273
+ cardTop: { display: 'flex', alignItems: 'center', gap: 6, fontSize: 11, color: PALETTE.sub, letterSpacing: 0.2 },
274
+ state: { fontWeight: 600, fontSize: 13, color: PALETTE.text },
275
+ hint: {
276
+ color: PALETTE.sub,
277
+ fontSize: 12,
278
+ lineHeight: 1.45,
279
+ margin: 0,
280
+ display: '-webkit-box',
281
+ WebkitLineClamp: 2,
282
+ WebkitBoxOrient: 'vertical',
283
+ overflow: 'hidden',
284
+ wordBreak: 'break-all',
285
+ },
286
+ dot: { width: 6, height: 6, borderRadius: '50%', display: 'inline-block', flex: 'none' },
287
+ button: {
288
+ appearance: 'none',
289
+ border: '.5px solid ' + PALETTE.hairline,
290
+ background: PALETTE.glassStrong,
291
+ color: PALETTE.text,
292
+ borderRadius: 9,
293
+ height: 30,
294
+ padding: '0 12px',
295
+ fontSize: 12,
296
+ fontWeight: 500,
297
+ cursor: 'pointer',
298
+ lineHeight: 1.2,
299
+ whiteSpace: 'nowrap',
300
+ },
301
+ primary: {
302
+ appearance: 'none',
303
+ border: '.5px solid color-mix(in srgb, var(--dsw-alias-brand-primary) 45%, transparent)',
304
+ background: 'color-mix(in srgb, var(--dsw-alias-brand-primary) 22%, transparent)',
305
+ color: PALETTE.text,
306
+ borderRadius: 9,
307
+ height: 30,
308
+ padding: '0 14px',
309
+ fontSize: 12,
310
+ fontWeight: 600,
311
+ cursor: 'pointer',
312
+ lineHeight: 1.2,
313
+ whiteSpace: 'nowrap',
314
+ },
315
+ danger: {
316
+ appearance: 'none',
317
+ border: '.5px solid ' + PALETTE.hairline,
318
+ background: 'transparent',
319
+ color: PALETTE.error,
320
+ borderRadius: 9,
321
+ height: 30,
322
+ padding: '0 12px',
323
+ fontSize: 12,
324
+ cursor: 'pointer',
325
+ lineHeight: 1.2,
326
+ whiteSpace: 'nowrap',
327
+ },
328
+ row: { display: 'flex', gap: 8, alignItems: 'center', flexWrap: 'wrap' },
329
+ rowEnd: { display: 'flex', gap: 8, alignItems: 'flex-end', flexWrap: 'wrap' },
330
+ grow: { flex: '1 1 160px', minWidth: 0 },
331
+ label: { fontSize: 11, color: PALETTE.sub, display: 'block', marginBottom: 5, letterSpacing: 0.2 },
332
+ input: {
333
+ width: '100%',
334
+ boxSizing: 'border-box',
335
+ border: '.5px solid ' + PALETTE.hairline,
336
+ background: PALETTE.glass,
337
+ color: PALETTE.text,
338
+ borderRadius: 9,
339
+ height: 32,
340
+ padding: '0 10px',
341
+ fontSize: 13,
342
+ outline: 'none',
343
+ },
344
+ selectRoot: { position: 'relative' },
345
+ selectTrigger: {
346
+ width: '100%',
347
+ boxSizing: 'border-box',
348
+ border: '.5px solid ' + PALETTE.hairline,
349
+ background: PALETTE.glass,
350
+ color: PALETTE.text,
351
+ borderRadius: 9,
352
+ height: 32,
353
+ padding: '0 10px',
354
+ fontSize: 13,
355
+ fontFamily: 'inherit',
356
+ display: 'flex',
357
+ alignItems: 'center',
358
+ justifyContent: 'space-between',
359
+ gap: 8,
360
+ cursor: 'pointer',
361
+ textAlign: 'left',
362
+ },
363
+ selectTriggerOpen: {
364
+ borderColor: 'color-mix(in srgb, var(--dsw-alias-brand-primary) 45%, transparent)',
365
+ },
366
+ selectValue: {
367
+ overflow: 'hidden',
368
+ textOverflow: 'ellipsis',
369
+ whiteSpace: 'nowrap',
370
+ },
371
+ chevron: {
372
+ flex: '0 0 auto',
373
+ width: 6,
374
+ height: 6,
375
+ borderRight: '1.5px solid ' + PALETTE.sub,
376
+ borderBottom: '1.5px solid ' + PALETTE.sub,
377
+ transform: 'rotate(45deg) translateY(-2px)',
378
+ transition: 'transform .16s ease',
379
+ },
380
+ chevronOpen: { transform: 'rotate(-135deg) translateY(-2px)' },
381
+ selectMenu: {
382
+ position: 'absolute',
383
+ top: 'calc(100% + 6px)',
384
+ left: 0,
385
+ right: 0,
386
+ zIndex: 40,
387
+ padding: 4,
388
+ borderRadius: 10,
389
+ border: '.5px solid ' + PALETTE.hairline,
390
+ background: 'color-mix(in srgb, var(--dsw-alias-bg-layer-2) 92%, transparent)',
391
+ backdropFilter: PALETTE.blur,
392
+ WebkitBackdropFilter: PALETTE.blur,
393
+ boxShadow: '0 12px 32px rgba(0, 0, 0, .38)',
394
+ maxHeight: 240,
395
+ overflowY: 'auto',
396
+ },
397
+ selectOption: {
398
+ display: 'flex',
399
+ alignItems: 'center',
400
+ justifyContent: 'space-between',
401
+ gap: 8,
402
+ padding: '7px 9px',
403
+ borderRadius: 7,
404
+ fontSize: 13,
405
+ color: PALETTE.text,
406
+ cursor: 'pointer',
407
+ whiteSpace: 'nowrap',
408
+ },
409
+ selectOptionHover: { background: 'color-mix(in srgb, var(--dsw-alias-label-primary) 10%, transparent)' },
410
+ selectOptionActive: { background: 'color-mix(in srgb, var(--dsw-alias-brand-primary) 16%, transparent)' },
411
+ selectCheck: { color: PALETTE.sub, fontSize: 12 },
412
+ section: {
413
+ border: '.5px solid ' + PALETTE.hairline,
414
+ borderRadius: 14,
415
+ padding: '14px 16px',
416
+ display: 'flex',
417
+ flexDirection: 'column',
418
+ gap: 10,
419
+ background: PALETTE.glass,
420
+ backdropFilter: PALETTE.blur,
421
+ WebkitBackdropFilter: PALETTE.blur,
422
+ },
423
+ sectionTitle: { fontSize: 12, fontWeight: 600, margin: 0, color: PALETTE.text },
424
+ progressTrack: {
425
+ height: 6,
426
+ borderRadius: 999,
427
+ background: 'color-mix(in srgb, var(--dsw-alias-label-primary) 10%, transparent)',
428
+ overflow: 'hidden',
429
+ flex: '1 1 auto',
430
+ minWidth: 120,
431
+ },
432
+ progressFill: { height: '100%', background: PALETTE.brand, transition: 'width .3s ease', borderRadius: 999 },
433
+ badge: {
434
+ fontSize: 11,
435
+ color: PALETTE.sub,
436
+ border: '.5px solid ' + PALETTE.hairline,
437
+ borderRadius: 999,
438
+ padding: '2px 9px',
439
+ whiteSpace: 'nowrap',
440
+ background: PALETTE.glass,
441
+ },
442
+ notice: { fontSize: 12, color: PALETTE.ok },
443
+ errorText: { fontSize: 12, color: PALETTE.error, margin: 0 },
444
+ privacy: { fontSize: 11, color: PALETTE.sub, lineHeight: 1.6, margin: 0 },
445
+ details: {
446
+ border: '.5px solid ' + PALETTE.hairline,
447
+ borderRadius: 14,
448
+ padding: '12px 16px',
449
+ background: PALETTE.glass,
450
+ backdropFilter: PALETTE.blur,
451
+ WebkitBackdropFilter: PALETTE.blur,
452
+ },
453
+ summary: { cursor: 'pointer', fontSize: 12, fontWeight: 600, color: PALETTE.text, listStyle: 'revert' },
454
+ grid2: { display: 'grid', gap: 10, gridTemplateColumns: 'repeat(auto-fit, minmax(180px, 1fr))' },
455
+ activation: {
456
+ border: '.5px solid ' + PALETTE.hairline,
457
+ borderRadius: 14,
458
+ padding: '14px 16px',
459
+ display: 'flex',
460
+ flexDirection: 'column',
461
+ gap: 10,
462
+ background: PALETTE.glass,
463
+ backdropFilter: PALETTE.blur,
464
+ WebkitBackdropFilter: PALETTE.blur,
465
+ },
466
+ };
467
+
468
+ function Dot(props) {
469
+ return h('span', {
470
+ style: Object.assign({}, styles.dot, { background: props.color }),
471
+ 'aria-hidden': true,
472
+ });
473
+ }
474
+
475
+ function btnStyle(base, disabled) {
476
+ return Object.assign({}, base, {
477
+ opacity: disabled ? 0.45 : 1,
478
+ cursor: disabled ? 'default' : 'pointer',
479
+ });
480
+ }
481
+
482
+ function StatusCard(props) {
483
+ return h(
484
+ 'div',
485
+ { style: styles.card },
486
+ h('div', { style: styles.cardTop }, h(Dot, { color: props.color }), props.label),
487
+ h('div', { style: styles.state }, props.state),
488
+ props.hint ? h('p', { style: styles.hint, title: props.hint }, props.hint) : null,
489
+ );
490
+ }
491
+
492
+ function Progress(props) {
493
+ const job = props.job;
494
+ const total = job && job.total ? job.total : 0;
495
+ const received = job && job.received ? job.received : 0;
496
+ const percent = total > 0 ? Math.min(100, Math.round((received / total) * 100)) : 0;
497
+ return h(
498
+ 'div',
499
+ { style: styles.section },
500
+ h(
501
+ 'div',
502
+ { style: styles.row },
503
+ h('span', { style: styles.sectionTitle }, props.t('installing')),
504
+ h('div', { style: styles.progressTrack },
505
+ h('div', { style: Object.assign({}, styles.progressFill, { width: percent + '%' }) }),
506
+ ),
507
+ h('span', { style: styles.badge }, total > 0 ? mb(received) + ' / ' + mb(total) : mb(received)),
508
+ ),
509
+ h('p', { style: styles.hint }, (job && job.message) || ''),
510
+ h(
511
+ 'div',
512
+ { style: styles.row },
513
+ h('button', { type: 'button', style: styles.danger, onClick: props.onCancel }, props.t('cancel')),
514
+ h('span', { style: styles.hint }, props.t('waitDownload')),
515
+ ),
516
+ );
517
+ }
518
+
519
+ function SelectField(props) {
520
+ const options = props.options || [];
521
+ const [open, setOpen] = React.useState(false);
522
+ const [hover, setHover] = React.useState(-1);
523
+ const rootRef = React.useRef(null);
524
+ const selectedIndex = options.findIndex(function (item) { return item.value === props.value; });
525
+ const current = selectedIndex >= 0 ? options[selectedIndex] : options[0];
526
+
527
+ React.useEffect(function () {
528
+ if (!open) return undefined;
529
+ function close(event) {
530
+ if (rootRef.current && !rootRef.current.contains(event.target)) setOpen(false);
531
+ }
532
+ document.addEventListener('mousedown', close);
533
+ return function () { document.removeEventListener('mousedown', close); };
534
+ }, [open]);
535
+
536
+ function commit(index) {
537
+ const option = options[index];
538
+ if (!option) return;
539
+ setOpen(false);
540
+ if (option.value !== props.value) props.onChange(option.value);
541
+ }
542
+
543
+ function onTriggerKey(event) {
544
+ if (event.key === 'Escape') { setOpen(false); return; }
545
+ if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
546
+ event.preventDefault();
547
+ if (!open) { setOpen(true); setHover(Math.max(0, selectedIndex)); return; }
548
+ const step = event.key === 'ArrowDown' ? 1 : -1;
549
+ setHover(function (previous) {
550
+ const from = previous < 0 ? selectedIndex : previous;
551
+ return Math.min(options.length - 1, Math.max(0, from + step));
552
+ });
553
+ return;
554
+ }
555
+ if ((event.key === 'Enter' || event.key === ' ') && open) {
556
+ event.preventDefault();
557
+ commit(hover >= 0 ? hover : selectedIndex);
558
+ }
559
+ }
560
+
561
+ return h(
562
+ 'div',
563
+ { style: styles.selectRoot, ref: rootRef },
564
+ h(
565
+ 'button',
566
+ {
567
+ type: 'button',
568
+ style: Object.assign({}, styles.selectTrigger, open ? styles.selectTriggerOpen : null),
569
+ 'aria-haspopup': 'listbox',
570
+ 'aria-expanded': open,
571
+ onClick: function () {
572
+ setOpen(!open);
573
+ if (!open) setHover(Math.max(0, selectedIndex));
574
+ },
575
+ onKeyDown: onTriggerKey,
576
+ },
577
+ h('span', { style: styles.selectValue }, current ? current.label : ''),
578
+ h('span', { style: Object.assign({}, styles.chevron, open ? styles.chevronOpen : null) }),
579
+ ),
580
+ open
581
+ ? h(
582
+ 'div',
583
+ { style: styles.selectMenu, role: 'listbox' },
584
+ options.map(function (item, index) {
585
+ return h(
586
+ 'div',
587
+ {
588
+ key: item.value,
589
+ role: 'option',
590
+ 'aria-selected': item.value === props.value,
591
+ style: Object.assign(
592
+ {},
593
+ styles.selectOption,
594
+ index === hover ? styles.selectOptionHover : null,
595
+ item.value === props.value ? styles.selectOptionActive : null,
596
+ ),
597
+ onMouseEnter: function () { setHover(index); },
598
+ onMouseDown: function (event) { event.preventDefault(); },
599
+ onClick: function () { commit(index); },
600
+ },
601
+ h('span', null, item.label),
602
+ item.value === props.value ? h('span', { style: styles.selectCheck }, '✓') : null,
603
+ );
604
+ }),
605
+ )
606
+ : null,
607
+ );
608
+ }
609
+
610
+ function useStatus() {
611
+ const [status, setStatus] = React.useState(null);
612
+ const [error, setError] = React.useState('');
613
+ const load = React.useCallback(async function () {
614
+ try {
615
+ const data = await call('/status');
616
+ setStatus(data);
617
+ setError('');
618
+ } catch (cause) {
619
+ setError(cause instanceof Error ? cause.message : String(cause));
620
+ }
621
+ }, []);
622
+ React.useEffect(function () {
623
+ let alive = true;
624
+ let timer = null;
625
+ async function tick() {
626
+ if (!alive) return;
627
+ await load();
628
+ if (!alive) return;
629
+ const busy = status && status.install &&
630
+ ['preparing', 'downloading', 'verifying', 'extracting'].indexOf(status.install.phase) >= 0;
631
+ timer = setTimeout(tick, busy ? 1000 : 6000);
632
+ }
633
+ void tick();
634
+ return function () {
635
+ alive = false;
636
+ if (timer !== null) clearTimeout(timer);
637
+ };
638
+ }, [load]);
639
+ return { status, error, reload: load };
640
+ }
641
+
642
+ function Panel(props) {
643
+ const t = props.t;
644
+ const state = useStatus();
645
+ const [notice, setNotice] = React.useState('');
646
+ const [failure, setFailure] = React.useState('');
647
+ const [busy, setBusy] = React.useState('');
648
+ const [keyDraft, setKeyDraft] = React.useState('');
649
+ const [customBase, setCustomBase] = React.useState('');
650
+ const [customModel, setCustomModel] = React.useState('');
651
+ const [openedAdvanced, setOpenedAdvanced] = React.useState(false);
652
+
653
+ const status = state.status;
654
+
655
+ React.useEffect(function () {
656
+ if (!status) return;
657
+ setCustomBase(status.cloud.baseUrl || '');
658
+ setCustomModel(status.cloud.model || '');
659
+ }, [status && status.cloud && status.cloud.preset]);
660
+
661
+ function run(name, action) {
662
+ setBusy(name);
663
+ setFailure('');
664
+ setNotice('');
665
+ return Promise.resolve()
666
+ .then(action)
667
+ .then(function (data) {
668
+ if (typeof data === 'string' && data !== '') setNotice(data);
669
+ return state.reload();
670
+ })
671
+ .catch(function (cause) {
672
+ setFailure(cause instanceof Error ? cause.message : String(cause));
673
+ })
674
+ .then(function () {
675
+ setBusy('');
676
+ });
677
+ }
678
+
679
+ if (!status) {
680
+ return h(
681
+ 'div',
682
+ { style: styles.root },
683
+ h('p', { style: styles.hint }, state.error ? t('loadFailed') + ':' + state.error : t('checking')),
684
+ );
685
+ }
686
+
687
+ const ffmpeg = status.ffmpeg || {};
688
+ const localReady = status.engine && status.engine.ready;
689
+ const cloudReady = status.cloud && status.cloud.keyConfigured && status.cloud.baseUrl && status.cloud.model;
690
+ const ready = ffmpeg.available && (localReady || cloudReady);
691
+ const job = status.install;
692
+ const installing =
693
+ job && ['preparing', 'downloading', 'verifying', 'extracting'].indexOf(job.phase) >= 0;
694
+ const preset = status.cloud.preset;
695
+ const custom = status.cloud.editable;
696
+ const activeKind = status.active
697
+ ? status.active.kind
698
+ : status.mode === 'local'
699
+ ? 'local'
700
+ : status.mode === 'cloud'
701
+ ? 'cloud'
702
+ : cloudReady
703
+ ? 'cloud'
704
+ : localReady
705
+ ? 'local'
706
+ : 'none';
707
+ const activeLabel =
708
+ activeKind === 'local' ? t('activeLocal') : activeKind === 'cloud' ? t('activeCloud') : t('activeNone');
709
+
710
+ const engineModes = [
711
+ ['auto', t('engineAuto')],
712
+ ['local', t('engineLocal')],
713
+ ['cloud', t('engineCloud')],
714
+ ['off', t('engineOff')],
715
+ ];
716
+
717
+ return h(
718
+ 'div',
719
+ { style: styles.root },
720
+ h(
721
+ 'div',
722
+ { style: styles.headerRow },
723
+ h('h3', { style: styles.title }, t('panelTitle')),
724
+ h('span', { style: styles.badge }, ready ? t('badgeReady') + ' · ' + activeLabel : t('badgePending')),
725
+ h('span', { style: { flex: '1 1 auto' } }),
726
+ h(
727
+ 'button',
728
+ { type: 'button', style: styles.button, onClick: function () { void state.reload(); } },
729
+ t('refresh'),
730
+ ),
731
+ ),
732
+ h('p', { style: styles.subtitle }, t('panelSubtitle')),
733
+
734
+ h(
735
+ 'div',
736
+ { style: styles.cards },
737
+ h(StatusCard, {
738
+ label: t('ffmpeg'),
739
+ state: ffmpeg.available ? t('ffmpegOk') : t('ffmpegMissing'),
740
+ color: ffmpeg.available ? PALETTE.ok : PALETTE.error,
741
+ hint: ffmpeg.available ? ffmpeg.ffmpeg : t('ffmpegHint'),
742
+ }),
743
+ h(StatusCard, {
744
+ label: t('local'),
745
+ state: localReady ? t('localReady') + ' · ' + engineName(status.engine.engine) : t('localMissing'),
746
+ color: localReady ? PALETTE.ok : PALETTE.idle,
747
+ hint: localReady
748
+ ? status.engine.modelPath || t('localHint')
749
+ : status.engine.reason || t('localHint'),
750
+ }),
751
+ h(StatusCard, {
752
+ label: t('cloud'),
753
+ state: cloudReady ? t('cloudReady') : t('cloudMissing'),
754
+ color: cloudReady ? PALETTE.ok : PALETTE.idle,
755
+ hint: (status.cloud.label || '') + (cloudReady ? ' · ' + status.cloud.model : ' · ' + t('cloudHint')),
756
+ }),
757
+ ),
758
+
759
+ installing
760
+ ? h(Progress, {
761
+ t: t,
762
+ job: job,
763
+ onCancel: function () {
764
+ void run('cancel', function () {
765
+ return call('/install/cancel', {});
766
+ });
767
+ },
768
+ })
769
+ : h(
770
+ 'div',
771
+ { style: styles.row },
772
+ h(
773
+ 'button',
774
+ {
775
+ type: 'button',
776
+ style: btnStyle(localReady ? styles.button : styles.primary, busy !== ''),
777
+ disabled: busy !== '',
778
+ onClick: function () {
779
+ void run('install', function () {
780
+ return call('/install', { target: 'all' }).then(function () {
781
+ return t('installStarted');
782
+ });
783
+ });
784
+ },
785
+ },
786
+ busy === 'install' ? t('installing') + '…' : localReady ? t('reinstall') : t('install'),
787
+ ),
788
+ h('span', { style: styles.hint }, localReady ? t('installedHint') : t('installSize')),
789
+ ),
790
+
791
+ h(
792
+ 'div',
793
+ { style: styles.row },
794
+ h(
795
+ 'button',
796
+ {
797
+ type: 'button',
798
+ style: btnStyle(styles.button, busy !== '' || (!localReady && !cloudReady)),
799
+ onClick: function () {
800
+ void run('test', function () {
801
+ return call('/test', {}).then(function (result) {
802
+ return (result.ok ? t('verifyOk') : result.message) +
803
+ ' · ' + (result.engine || '') + ' · ' + (result.latencyMs || 0) + 'ms';
804
+ });
805
+ });
806
+ },
807
+ },
808
+ busy === 'test' ? t('verifying') : t('verify'),
809
+ ),
810
+ h('span', { style: styles.hint }, t('verifyHint')),
811
+ ),
812
+
813
+ notice ? h('p', { style: styles.notice }, notice) : null,
814
+ failure ? h('p', { style: styles.errorText }, failure) : null,
815
+
816
+ h(
817
+ 'div',
818
+ { style: styles.section },
819
+ h('h4', { style: styles.sectionTitle }, t('cloudSection')),
820
+ h(
821
+ 'div',
822
+ { style: styles.row },
823
+ h('div', { style: styles.grow },
824
+ h('label', { style: styles.label }, t('preset')),
825
+ h(SelectField, {
826
+ value: preset,
827
+ options: (status.presets || []).map(function (item) {
828
+ return { value: item.id, label: item.label };
829
+ }),
830
+ onChange: function (value) {
831
+ void run('preset', function () {
832
+ return call('/config', { field: 'preset', value: value });
833
+ });
834
+ },
835
+ }),
836
+ ),
837
+ custom
838
+ ? h('div', { style: styles.grow },
839
+ h('label', { style: styles.label }, t('baseUrl')),
840
+ h('input', {
841
+ style: styles.input,
842
+ value: customBase,
843
+ placeholder: 'https://api.example.com',
844
+ onChange: function (event) { setCustomBase(event.target.value); },
845
+ onBlur: function () {
846
+ void run('baseUrl', function () {
847
+ return call('/config', { field: 'baseUrl', value: customBase });
848
+ });
849
+ },
850
+ }),
851
+ )
852
+ : null,
853
+ custom
854
+ ? h('div', { style: styles.grow },
855
+ h('label', { style: styles.label }, t('modelName')),
856
+ h('input', {
857
+ style: styles.input,
858
+ value: customModel,
859
+ placeholder: 'whisper-1',
860
+ onChange: function (event) { setCustomModel(event.target.value); },
861
+ onBlur: function () {
862
+ void run('model', function () {
863
+ return call('/config', { field: 'model', value: customModel });
864
+ });
865
+ },
866
+ }),
867
+ )
868
+ : null,
869
+ ),
870
+ h(
871
+ 'div',
872
+ { style: styles.rowEnd },
873
+ h('div', { style: styles.grow },
874
+ h('label', { style: styles.label }, t('apiKey')),
875
+ h('input', {
876
+ style: styles.input,
877
+ type: 'password',
878
+ value: keyDraft,
879
+ placeholder: t('keyPlaceholder'),
880
+ autoComplete: 'off',
881
+ onChange: function (event) { setKeyDraft(event.target.value); },
882
+ }),
883
+ ),
884
+ h(
885
+ 'button',
886
+ {
887
+ type: 'button',
888
+ style: Object.assign({}, styles.primary, {
889
+ opacity: busy !== '' || keyDraft.trim() === '' ? 0.45 : 1,
890
+ cursor: busy !== '' || keyDraft.trim() === '' ? 'default' : 'pointer',
891
+ }),
892
+ disabled: busy !== '' || keyDraft.trim() === '',
893
+ onClick: function () {
894
+ void run('key', function () {
895
+ return call('/key', { preset: preset, key: keyDraft }).then(function () {
896
+ setKeyDraft('');
897
+ return t('saved');
898
+ });
899
+ });
900
+ },
901
+ },
902
+ t('save'),
903
+ ),
904
+ h(
905
+ 'button',
906
+ {
907
+ type: 'button',
908
+ style: styles.button,
909
+ disabled: busy !== '',
910
+ onClick: function () {
911
+ void run('key', function () {
912
+ return call('/key', { preset: preset, key: '' }).then(function () {
913
+ setKeyDraft('');
914
+ return t('cleared');
915
+ });
916
+ });
917
+ },
918
+ },
919
+ t('clear'),
920
+ ),
921
+ status.cloud.keyUrl
922
+ ? h('a', {
923
+ href: status.cloud.keyUrl,
924
+ target: '_blank',
925
+ rel: 'noreferrer',
926
+ style: { fontSize: 12, color: PALETTE.brand, textDecoration: 'none', paddingBottom: 8 },
927
+ }, t('getKey'))
928
+ : null,
929
+ ),
930
+ h('p', { style: styles.hint }, t('keyNote')),
931
+ ),
932
+
933
+ h(
934
+ 'details',
935
+ {
936
+ style: styles.details,
937
+ open: openedAdvanced,
938
+ onToggle: function (event) { setOpenedAdvanced(event.target.open); },
939
+ },
940
+ h('summary', { style: styles.summary }, t('advanced')),
941
+ h('p', { style: Object.assign({}, styles.hint, { margin: '6px 0 0' }) }, t('engineHint')),
942
+ h(
943
+ 'div',
944
+ { style: Object.assign({}, styles.grid2, { marginTop: 10 }) },
945
+ h('div', null,
946
+ h('label', { style: styles.label }, t('engine')),
947
+ h(SelectField, {
948
+ value: (status.config && status.config.engine) || status.mode || 'auto',
949
+ options: engineModes.map(function (item) {
950
+ return { value: item[0], label: item[1] };
951
+ }),
952
+ onChange: function (value) {
953
+ void run('engine', function () {
954
+ return call('/config', { field: 'engine', value: value });
955
+ });
956
+ },
957
+ }),
958
+ ),
959
+ h('div', null,
960
+ h('label', { style: styles.label }, t('language')),
961
+ h('input', {
962
+ style: styles.input,
963
+ defaultValue: (status.config && status.config.language) || '',
964
+ placeholder: t('languageAuto'),
965
+ onBlur: function (event) {
966
+ const value = event.target.value;
967
+ void run('language', function () {
968
+ return call('/config', { field: 'language', value: value });
969
+ });
970
+ },
971
+ }),
972
+ ),
973
+ h('div', null,
974
+ h('label', { style: styles.label }, t('modelSize')),
975
+ h(SelectField, {
976
+ value: (status.config && status.config.modelId) || status.modelId,
977
+ options: [['base-q5_1', 'base · 60 MB'], ['small-q5_1', 'small · 190 MB'], ['medium-q5_0', 'medium · 540 MB']].map(
978
+ function (item) {
979
+ const label = item[1] + (item[0] === 'small-q5_1' ? ' · ' + t('recommended') : '');
980
+ return { value: item[0], label: label };
981
+ },
982
+ ),
983
+ onChange: function (value) {
984
+ void run('modelId', function () {
985
+ return call('/config', { field: 'modelId', value: value });
986
+ });
987
+ },
988
+ }),
989
+ ),
990
+ h('div', null,
991
+ h('label', { style: styles.label }, t('threads')),
992
+ h('input', {
993
+ style: styles.input,
994
+ type: 'number',
995
+ min: 0,
996
+ max: 32,
997
+ defaultValue: (status.config && status.config.threads) || 0,
998
+ onBlur: function (event) {
999
+ const value = Number(event.target.value || 0);
1000
+ void run('threads', function () {
1001
+ return call('/config', { field: 'threads', value: value });
1002
+ });
1003
+ },
1004
+ }),
1005
+ ),
1006
+ h('div', null,
1007
+ h('label', { style: styles.label }, t('cache')),
1008
+ h('div', { style: styles.hint }, mb(status.cacheBytes) + ' · ' + status.cacheDir),
1009
+ h(
1010
+ 'button',
1011
+ {
1012
+ type: 'button',
1013
+ style: Object.assign({}, styles.button, { marginTop: 6 }),
1014
+ disabled: busy !== '',
1015
+ onClick: function () {
1016
+ void run('clearCache', function () {
1017
+ return call('/cache/clear', {}).then(function () { return t('cacheCleared'); });
1018
+ });
1019
+ },
1020
+ },
1021
+ t('clearCache'),
1022
+ ),
1023
+ ),
1024
+ ),
1025
+ status.configWritable ? null : h('p', { style: styles.hint }, t('settingsReadonly')),
1026
+ ),
1027
+
1028
+ h('p', { style: styles.privacy }, t('privacy')),
1029
+ );
1030
+ }
1031
+
1032
+ function Activation(props) {
1033
+ const t = props.t || makeTranslator(undefined);
1034
+ const state = useStatus();
1035
+ const [busy, setBusy] = React.useState(false);
1036
+ const [failure, setFailure] = React.useState('');
1037
+ const status = state.status;
1038
+ const job = status && status.install;
1039
+ const installing =
1040
+ job && ['preparing', 'downloading', 'verifying', 'extracting'].indexOf(job.phase) >= 0;
1041
+ const ready = status && (status.ready || (status.engine && status.engine.ready));
1042
+
1043
+ function install() {
1044
+ setBusy(true);
1045
+ setFailure('');
1046
+ call('/install', { target: 'all' })
1047
+ .then(function () { return state.reload(); })
1048
+ .catch(function (cause) {
1049
+ setFailure(cause instanceof Error ? cause.message : String(cause));
1050
+ })
1051
+ .then(function () { setBusy(false); });
1052
+ }
1053
+
1054
+ return h(
1055
+ 'div',
1056
+ { style: styles.activation },
1057
+ h('strong', { style: { fontSize: 14 } }, t('activationTitle')),
1058
+ h('p', { style: styles.hint }, ready ? t('activationReady') : t('activationBody')),
1059
+ installing
1060
+ ? h(Progress, {
1061
+ t: t,
1062
+ job: job,
1063
+ onCancel: function () { void call('/install/cancel', {}).then(state.reload); },
1064
+ })
1065
+ : h(
1066
+ 'div',
1067
+ { style: styles.row },
1068
+ ready
1069
+ ? null
1070
+ : h(
1071
+ 'button',
1072
+ { type: 'button', style: styles.primary, disabled: busy, onClick: install },
1073
+ busy ? t('installing') + '…' : t('activationInstall'),
1074
+ ),
1075
+ h(
1076
+ 'button',
1077
+ { type: 'button', style: styles.button, onClick: props.onOpenDetails },
1078
+ t('openSettings'),
1079
+ ),
1080
+ h(
1081
+ 'button',
1082
+ { type: 'button', style: styles.button, onClick: props.onDismiss },
1083
+ t('later'),
1084
+ ),
1085
+ ),
1086
+ failure ? h('p', { style: styles.errorText }, failure) : null,
1087
+ );
1088
+ }
1089
+
1090
+ function safeApply(ctx, run) {
1091
+ try {
1092
+ run();
1093
+ } catch (error) {
1094
+ try {
1095
+ console.error('[dsh-pf-stream] client apply failed', error);
1096
+ } catch {
1097
+ // 控制台不可用时保持安静
1098
+ }
1099
+ }
1100
+ }
1101
+
1102
+ return {
1103
+ inject: ['slots'],
1104
+ apply(ctx) {
1105
+ const locale = typeof ctx.get === 'function' ? ctx.get('locale') : undefined;
1106
+ try {
1107
+ if (locale && typeof locale.register === 'function') {
1108
+ locale.register(NS, { zh: ZH, en: EN });
1109
+ }
1110
+ } catch {
1111
+ // 没有 locale 服务时用自带词典
1112
+ }
1113
+ const t = makeTranslator(locale);
1114
+
1115
+ safeApply(ctx, function () {
1116
+ ctx.slots.inject('plugins.bundle.config', function () {
1117
+ return ctx.slots.register(
1118
+ {
1119
+ name: 'plugins.bundle.config',
1120
+ key: 'dsh-pf-stream',
1121
+ locale: NS,
1122
+ inject: function () { return { t: t }; },
1123
+ },
1124
+ function (ownerProps) {
1125
+ if (!ownerProps || ownerProps.view === 'summary') return null;
1126
+ return h(Panel, { t: t });
1127
+ },
1128
+ );
1129
+ });
1130
+ });
1131
+
1132
+ safeApply(ctx, function () {
1133
+ ctx.slots.inject('plugins.bundle.activation', function () {
1134
+ return ctx.slots.register(
1135
+ {
1136
+ name: 'plugins.bundle.activation',
1137
+ key: 'dsh-pf-stream',
1138
+ locale: NS,
1139
+ inject: function () { return { t: t }; },
1140
+ },
1141
+ function (ownerProps) {
1142
+ const props = ownerProps || {};
1143
+ return h(Activation, {
1144
+ t: t,
1145
+ onDismiss: typeof props.onDismiss === 'function' ? props.onDismiss : function () {},
1146
+ onOpenDetails:
1147
+ typeof props.onOpenDetails === 'function' ? props.onOpenDetails : function () {},
1148
+ });
1149
+ },
1150
+ );
1151
+ });
1152
+ });
1153
+ },
1154
+ };
1155
+ },
1156
+ });