openspeechapi 0.1.0__py3-none-any.whl

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 (118) hide show
  1. openspeech/__init__.py +75 -0
  2. openspeech/__main__.py +5 -0
  3. openspeech/cli.py +413 -0
  4. openspeech/client/__init__.py +4 -0
  5. openspeech/client/client.py +145 -0
  6. openspeech/config.py +212 -0
  7. openspeech/core/__init__.py +0 -0
  8. openspeech/core/base.py +75 -0
  9. openspeech/core/enums.py +39 -0
  10. openspeech/core/models.py +61 -0
  11. openspeech/core/registry.py +37 -0
  12. openspeech/core/settings.py +8 -0
  13. openspeech/demo.py +675 -0
  14. openspeech/dispatch/__init__.py +0 -0
  15. openspeech/dispatch/context.py +34 -0
  16. openspeech/dispatch/dispatcher.py +661 -0
  17. openspeech/dispatch/executors/__init__.py +0 -0
  18. openspeech/dispatch/executors/base.py +34 -0
  19. openspeech/dispatch/executors/in_process.py +66 -0
  20. openspeech/dispatch/executors/remote.py +64 -0
  21. openspeech/dispatch/executors/subprocess_exec.py +446 -0
  22. openspeech/dispatch/fanout.py +95 -0
  23. openspeech/dispatch/filters.py +73 -0
  24. openspeech/dispatch/lifecycle.py +178 -0
  25. openspeech/dispatch/watcher.py +82 -0
  26. openspeech/engine_catalog.py +236 -0
  27. openspeech/engine_registry.yaml +347 -0
  28. openspeech/exceptions.py +51 -0
  29. openspeech/factory.py +325 -0
  30. openspeech/local_engines/__init__.py +12 -0
  31. openspeech/local_engines/aim_resolver.py +91 -0
  32. openspeech/local_engines/backends/__init__.py +1 -0
  33. openspeech/local_engines/backends/docker_backend.py +490 -0
  34. openspeech/local_engines/backends/native_backend.py +902 -0
  35. openspeech/local_engines/base.py +30 -0
  36. openspeech/local_engines/engines/__init__.py +1 -0
  37. openspeech/local_engines/engines/faster_whisper.py +36 -0
  38. openspeech/local_engines/engines/fish_speech.py +33 -0
  39. openspeech/local_engines/engines/sherpa_onnx.py +56 -0
  40. openspeech/local_engines/engines/whisper.py +41 -0
  41. openspeech/local_engines/engines/whisperlivekit.py +60 -0
  42. openspeech/local_engines/manager.py +208 -0
  43. openspeech/local_engines/models.py +50 -0
  44. openspeech/local_engines/progress.py +69 -0
  45. openspeech/local_engines/registry.py +19 -0
  46. openspeech/local_engines/task_store.py +52 -0
  47. openspeech/local_engines/tasks.py +71 -0
  48. openspeech/logging_config.py +607 -0
  49. openspeech/observe/__init__.py +0 -0
  50. openspeech/observe/base.py +79 -0
  51. openspeech/observe/debug.py +44 -0
  52. openspeech/observe/latency.py +19 -0
  53. openspeech/observe/metrics.py +47 -0
  54. openspeech/observe/tracing.py +44 -0
  55. openspeech/observe/usage.py +27 -0
  56. openspeech/providers/__init__.py +0 -0
  57. openspeech/providers/_template.py +101 -0
  58. openspeech/providers/stt/__init__.py +0 -0
  59. openspeech/providers/stt/alibaba.py +86 -0
  60. openspeech/providers/stt/assemblyai.py +135 -0
  61. openspeech/providers/stt/azure_speech.py +99 -0
  62. openspeech/providers/stt/baidu.py +135 -0
  63. openspeech/providers/stt/deepgram.py +311 -0
  64. openspeech/providers/stt/elevenlabs.py +385 -0
  65. openspeech/providers/stt/faster_whisper.py +211 -0
  66. openspeech/providers/stt/google_cloud.py +106 -0
  67. openspeech/providers/stt/iflytek.py +427 -0
  68. openspeech/providers/stt/macos_speech.py +226 -0
  69. openspeech/providers/stt/openai.py +84 -0
  70. openspeech/providers/stt/sherpa_onnx.py +353 -0
  71. openspeech/providers/stt/tencent.py +212 -0
  72. openspeech/providers/stt/volcengine.py +107 -0
  73. openspeech/providers/stt/whisper.py +153 -0
  74. openspeech/providers/stt/whisperlivekit.py +530 -0
  75. openspeech/providers/stt/windows_speech.py +249 -0
  76. openspeech/providers/tts/__init__.py +0 -0
  77. openspeech/providers/tts/alibaba.py +95 -0
  78. openspeech/providers/tts/azure_speech.py +123 -0
  79. openspeech/providers/tts/baidu.py +143 -0
  80. openspeech/providers/tts/coqui.py +64 -0
  81. openspeech/providers/tts/cosyvoice.py +90 -0
  82. openspeech/providers/tts/deepgram.py +174 -0
  83. openspeech/providers/tts/elevenlabs.py +311 -0
  84. openspeech/providers/tts/fish_speech.py +158 -0
  85. openspeech/providers/tts/google_cloud.py +107 -0
  86. openspeech/providers/tts/iflytek.py +209 -0
  87. openspeech/providers/tts/macos_say.py +251 -0
  88. openspeech/providers/tts/minimax.py +122 -0
  89. openspeech/providers/tts/openai.py +104 -0
  90. openspeech/providers/tts/piper.py +104 -0
  91. openspeech/providers/tts/tencent.py +189 -0
  92. openspeech/providers/tts/volcengine.py +117 -0
  93. openspeech/providers/tts/windows_sapi.py +234 -0
  94. openspeech/server/__init__.py +1 -0
  95. openspeech/server/app.py +72 -0
  96. openspeech/server/auth.py +42 -0
  97. openspeech/server/middleware.py +75 -0
  98. openspeech/server/routes/__init__.py +1 -0
  99. openspeech/server/routes/management.py +848 -0
  100. openspeech/server/routes/stt.py +121 -0
  101. openspeech/server/routes/tts.py +159 -0
  102. openspeech/server/routes/webui.py +29 -0
  103. openspeech/server/webui/app.js +2649 -0
  104. openspeech/server/webui/index.html +216 -0
  105. openspeech/server/webui/styles.css +617 -0
  106. openspeech/server/ws/__init__.py +1 -0
  107. openspeech/server/ws/stt_stream.py +263 -0
  108. openspeech/server/ws/tts_stream.py +207 -0
  109. openspeech/telemetry/__init__.py +21 -0
  110. openspeech/telemetry/perf.py +307 -0
  111. openspeech/utils/__init__.py +5 -0
  112. openspeech/utils/audio_converter.py +406 -0
  113. openspeech/utils/audio_playback.py +156 -0
  114. openspeech/vendor_registry.yaml +74 -0
  115. openspeechapi-0.1.0.dist-info/METADATA +101 -0
  116. openspeechapi-0.1.0.dist-info/RECORD +118 -0
  117. openspeechapi-0.1.0.dist-info/WHEEL +4 -0
  118. openspeechapi-0.1.0.dist-info/entry_points.txt +3 -0
@@ -0,0 +1,2649 @@
1
+ const $ = (id) => document.getElementById(id);
2
+
3
+ // ---- Lab Interaction Log ----
4
+ const _labLogMax = 200; // max lines kept
5
+ function labLog(level, msg) {
6
+ const el = $("lab-log");
7
+ if (!el) return;
8
+ const ts = new Date().toLocaleTimeString("en-GB", { hour12: false, hour: "2-digit", minute: "2-digit", second: "2-digit", fractionalSecondDigits: 3 });
9
+ const cls = level === "error" ? "log-err" : level === "warn" ? "log-warn" : level === "data" ? "log-data" : "log-info";
10
+ const line = document.createElement("span");
11
+ line.innerHTML = `<span class="log-ts">[${ts}]</span> <span class="${cls}">${_escHtml(msg)}</span>\n`;
12
+ el.appendChild(line);
13
+ // Trim old lines
14
+ while (el.childElementCount > _labLogMax) el.removeChild(el.firstElementChild);
15
+ el.scrollTop = el.scrollHeight;
16
+ }
17
+ function labLogV(level, msg) {
18
+ const cb = $("lab-log-verbose");
19
+ if (cb && cb.checked) labLog(level, msg);
20
+ }
21
+ function labLogClear() {
22
+ const el = $("lab-log");
23
+ if (el) el.innerHTML = "";
24
+ }
25
+ function _escHtml(s) {
26
+ return String(s).replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
27
+ }
28
+
29
+ // Shared group ordering for engines/config/dashboard
30
+ const ENGINE_GROUPS = [
31
+ { label: 'STT — Cloud', filter: e => e.type === 'stt' && e.category === 'cloud' },
32
+ { label: 'STT — Local / Native', filter: e => e.type === 'stt' && (e.category === 'local' || e.category === 'native') },
33
+ { label: 'TTS — Cloud', filter: e => e.type === 'tts' && e.category === 'cloud' },
34
+ { label: 'TTS — Local / Native', filter: e => e.type === 'tts' && (e.category === 'local' || e.category === 'native') },
35
+ ];
36
+
37
+ const state = {
38
+ engines: [],
39
+ eventSource: null,
40
+ ttsTimer: null,
41
+ sttLive: null,
42
+ sttLiveText: "",
43
+ sttLiveMetricsTimer: null,
44
+ // Last TTS result for download
45
+ ttsAudioData: null, // Uint8Array of raw audio bytes
46
+ ttsAudioFormat: null, // "mp3", "wav", etc.
47
+ ttsSampleRate: 16000,
48
+ ttsChannels: 1,
49
+ };
50
+
51
+ function execModeDesc(mode) {
52
+ const m = String(mode || "").toLowerCase();
53
+ if (m === "subprocess") return "Worker subprocess + IPC";
54
+ if (m === "local") return "Local engine service + HTTP(S)";
55
+ if (m === "remote") return "Cloud/remote API over network";
56
+ if (m === "in_process") return "In-process model execution";
57
+ return "Unknown";
58
+ }
59
+
60
+ function nowLabel() {
61
+ const d = new Date();
62
+ return d.toLocaleTimeString();
63
+ }
64
+
65
+ async function fetchJson(url, init = {}) {
66
+ const res = await fetch(url, init);
67
+ if (!res.ok) {
68
+ let detail = `HTTP ${res.status}`;
69
+ const raw = await res.text();
70
+ if (raw) {
71
+ try {
72
+ const data = JSON.parse(raw);
73
+ detail = data.detail || JSON.stringify(data);
74
+ } catch (_) {
75
+ detail = raw;
76
+ }
77
+ }
78
+ throw new Error(detail || `HTTP ${res.status}`);
79
+ }
80
+ return res.json();
81
+ }
82
+
83
+ function setText(id, text) {
84
+ $(id).textContent = String(text);
85
+ }
86
+
87
+ function setOutput(text) {
88
+ const out = $("engine-output-detail") || $("engine-output");
89
+ if (out) out.textContent = `[${nowLabel()}] ${text}\n` + out.textContent;
90
+ }
91
+
92
+ function stopTTSTimer() {
93
+ if (state.ttsTimer) {
94
+ clearInterval(state.ttsTimer);
95
+ state.ttsTimer = null;
96
+ }
97
+ }
98
+
99
+ function sttMode() {
100
+ return $("stt-mode").value;
101
+ }
102
+
103
+ function updateSTTModeUI() {
104
+ const mode = sttMode();
105
+ const filePanel = $("stt-file-panel");
106
+ const livePanel = $("stt-live-panel");
107
+ const fileInput = $("stt-file");
108
+ const metricsEl = $("stt-live-metrics");
109
+ if (mode === "live") {
110
+ filePanel.classList.add("hidden");
111
+ livePanel.classList.remove("hidden");
112
+ if (metricsEl) metricsEl.style.display = "";
113
+ fileInput.required = false;
114
+ } else {
115
+ if (state.sttLive) {
116
+ stopLiveSTT().catch(() => {});
117
+ }
118
+ filePanel.classList.remove("hidden");
119
+ livePanel.classList.add("hidden");
120
+ if (metricsEl) metricsEl.style.display = "none";
121
+ fileInput.required = true;
122
+ }
123
+ }
124
+
125
+ function isLocalEngine(p) {
126
+ return ["subprocess", "local", "in_process"].includes(p.exec_mode) && p.exec_mode !== "remote";
127
+ }
128
+
129
+ function providerDisplayName(p) {
130
+ const info = p.display_info || {};
131
+ const parts = [];
132
+ if (info.model || info.model_size || info.model_name) parts.push(info.model || info.model_size || info.model_name);
133
+ if (info.voice || info.voice_id || info.voice_name || info.voice_type) parts.push(info.voice || info.voice_id || info.voice_name || info.voice_type);
134
+ if (info.language || info.engine_type) parts.push(info.language || info.engine_type);
135
+ if (info.region) parts.push(info.region);
136
+ return parts.length ? parts.join(' / ') : '';
137
+ }
138
+
139
+ function renderProviderRow(p) {
140
+ const detail = providerDisplayName(p);
141
+ const healthDot = p.healthy ? '<span class="dot dot-green"></span>' : '<span class="dot dot-red"></span>';
142
+ const local = isLocalEngine(p);
143
+ let actions = '';
144
+ if (local) {
145
+ actions = `
146
+ <button class="btn btn-xs" onclick="engineAction('${p.name}','start')" title="Start">Start</button>
147
+ <button class="btn btn-xs" onclick="engineAction('${p.name}','stop')" title="Stop">Stop</button>
148
+ `;
149
+ }
150
+ const label = p.display_name || p.name;
151
+ return `<div class="provider-row">
152
+ <div class="provider-info">
153
+ ${healthDot}
154
+ <strong>${label}</strong>
155
+ <span class="provider-engine">${p.name}</span>
156
+ ${detail ? `<span class="provider-detail">${detail}</span>` : ''}
157
+ </div>
158
+ <div class="provider-actions">${actions}</div>
159
+ </div>`;
160
+ }
161
+
162
+ function renderEngines(engines) {
163
+ const container = $("provider-groups");
164
+ container.innerHTML = "";
165
+
166
+ const groups = [
167
+ { label: "STT — Cloud", filter: p => p.type === "stt" && !isLocalEngine(p) },
168
+ { label: "STT — Local / Native", filter: p => p.type === "stt" && isLocalEngine(p) },
169
+ { label: "TTS — Cloud", filter: p => p.type === "tts" && !isLocalEngine(p) },
170
+ { label: "TTS — Local / Native", filter: p => p.type === "tts" && isLocalEngine(p) },
171
+ ];
172
+
173
+ for (const g of groups) {
174
+ const items = engines.filter(g.filter);
175
+ if (items.length === 0) continue;
176
+ const section = document.createElement("div");
177
+ section.className = "provider-group";
178
+ section.innerHTML = `<h3 class="provider-group-title">${g.label} <span class="provider-count">${items.filter(p=>p.healthy).length}/${items.length}</span></h3>`;
179
+ for (const p of items) {
180
+ section.innerHTML += renderProviderRow(p);
181
+ }
182
+ container.appendChild(section);
183
+ }
184
+
185
+ const sttSelect = $("stt-provider");
186
+ const ttsSelect = $("tts-provider");
187
+ const prevStt = sttSelect.value;
188
+ const prevTts = ttsSelect.value;
189
+ sttSelect.innerHTML = "";
190
+ ttsSelect.innerHTML = "";
191
+
192
+ const sttEngines = engines.filter((p) => p.type === "stt");
193
+ const ttsEngines = engines.filter((p) => p.type === "tts");
194
+
195
+ for (const p of sttEngines) {
196
+ const option = document.createElement("option");
197
+ option.value = p.name;
198
+ const label = p.display_name || p.name;
199
+ option.textContent = p.healthy ? label : `${label} (unhealthy)`;
200
+ option.disabled = !p.healthy;
201
+ sttSelect.appendChild(option);
202
+ }
203
+ for (const p of ttsEngines) {
204
+ const option = document.createElement("option");
205
+ option.value = p.name;
206
+ const label = p.display_name || p.name;
207
+ option.textContent = p.healthy ? label : `${label} (unhealthy)`;
208
+ option.disabled = !p.healthy;
209
+ ttsSelect.appendChild(option);
210
+ }
211
+
212
+ const pickDefault = (select, list, prev) => {
213
+ if (prev && list.some((p) => p.name === prev && p.healthy)) {
214
+ select.value = prev;
215
+ return;
216
+ }
217
+ const firstHealthy = list.find((p) => p.healthy);
218
+ if (firstHealthy) {
219
+ select.value = firstHealthy.name;
220
+ return;
221
+ }
222
+ if (list.length > 0) {
223
+ select.value = list[0].name;
224
+ }
225
+ };
226
+ pickDefault(sttSelect, sttEngines, prevStt);
227
+ pickDefault(ttsSelect, ttsEngines, prevTts);
228
+ loadTTSVoices(ttsSelect.value);
229
+ loadTTSFieldOptions(ttsSelect.value);
230
+ loadSTTFieldOptions(sttSelect.value);
231
+ }
232
+
233
+ // Backward compatibility alias
234
+ function renderProviders(providers) { return renderEngines(providers); }
235
+
236
+ async function loadTTSVoices(provider) {
237
+ const voiceLabel = $("tts-voice-label");
238
+ const voiceSelect = $("tts-voice");
239
+ voiceSelect.innerHTML = "";
240
+ const pushVoiceOption = (value, label, title = "") => {
241
+ if (!value) return;
242
+ const existing = Array.from(voiceSelect.options).some((o) => o.value === value);
243
+ if (existing) return;
244
+ const opt = document.createElement("option");
245
+ opt.value = value;
246
+ opt.textContent = label || value;
247
+ if (title) opt.title = title;
248
+ voiceSelect.appendChild(opt);
249
+ };
250
+ if (!provider) {
251
+ voiceLabel.classList.add("hidden");
252
+ return;
253
+ }
254
+ const engineInfo = (state.engines || []).find((e) => e.name === provider) || {};
255
+ const configuredVoice =
256
+ (engineInfo.display_info && (
257
+ engineInfo.display_info.voice_id ||
258
+ engineInfo.display_info.voice ||
259
+ engineInfo.display_info.voice_name ||
260
+ engineInfo.display_info.voice_type
261
+ )) || "";
262
+ const providerKey = engineInfo.provider || provider;
263
+ const ensureTemplateCache = async () => {
264
+ if (providerTemplates.length > 0) return;
265
+ try {
266
+ const res = await fetch('/v1/admin/provider-templates');
267
+ providerTemplates = (await res.json()).templates || [];
268
+ } catch (_) {}
269
+ };
270
+ const addFallbackVoices = async () => {
271
+ if (configuredVoice) {
272
+ pushVoiceOption(String(configuredVoice), `Configured (${configuredVoice})`);
273
+ }
274
+ await ensureTemplateCache();
275
+ const tpl = providerTemplates.find((t) => t.provider === providerKey);
276
+ if (tpl && tpl.defaults) {
277
+ const defaultVoice = tpl.defaults.voice_id || tpl.defaults.voice || "";
278
+ if (defaultVoice) {
279
+ pushVoiceOption(String(defaultVoice), `Default (${defaultVoice})`);
280
+ }
281
+ }
282
+ if (voiceSelect.options.length > 0) {
283
+ voiceLabel.classList.remove("hidden");
284
+ } else {
285
+ voiceLabel.classList.add("hidden");
286
+ }
287
+ };
288
+ try {
289
+ const data = await fetchJson(`/v1/tts/${provider}/voices`);
290
+ const voices = data.voices || [];
291
+ if (voices.length === 0) {
292
+ await addFallbackVoices();
293
+ return;
294
+ }
295
+ voiceLabel.classList.remove("hidden");
296
+ const groups = {};
297
+ for (const v of voices) {
298
+ const g = v.group || "";
299
+ if (!groups[g]) groups[g] = [];
300
+ groups[g].push(v);
301
+ }
302
+ const sortedKeys = Object.keys(groups).sort((a, b) => {
303
+ if (a === "中文") return -1;
304
+ if (b === "中文") return 1;
305
+ if (a === "English") return -1;
306
+ if (b === "English") return 1;
307
+ return a.localeCompare(b);
308
+ });
309
+ for (const key of sortedKeys) {
310
+ const optgroup = document.createElement("optgroup");
311
+ optgroup.label = key || "Default";
312
+ for (const v of groups[key]) {
313
+ const opt = document.createElement("option");
314
+ opt.value = v.id || v.name;
315
+ opt.textContent = v.name || v.id;
316
+ if (v.description) opt.title = v.description;
317
+ optgroup.appendChild(opt);
318
+ }
319
+ voiceSelect.appendChild(optgroup);
320
+ }
321
+ } catch (_) {
322
+ await addFallbackVoices();
323
+ }
324
+ }
325
+
326
+ function maybeAutoAdjustTTSTransportByModel() {
327
+ const providerAlias = $("tts-provider").value;
328
+ const modelSel = $("tts-model");
329
+ const transportSel = $("tts-transport");
330
+ if (!providerAlias || !modelSel || !transportSel) return;
331
+ const engineInfo = (state.engines || []).find((e) => e.name === providerAlias) || {};
332
+ const providerKey = (engineInfo.provider || providerAlias || "").toLowerCase();
333
+ if (providerKey !== "elevenlabs") return;
334
+ const model = String(modelSel.value || "").trim();
335
+ if (!model) return;
336
+
337
+ const values = new Set(Array.from(transportSel.options).map((o) => String(o.value)));
338
+ if (model === "eleven_v3") {
339
+ if (values.has("http")) transportSel.value = "http";
340
+ } else if (model.startsWith("eleven_")) {
341
+ if (values.has("ws")) transportSel.value = "ws";
342
+ }
343
+ }
344
+
345
+ async function loadTTSFieldOptions(engineAlias) {
346
+ const fields = {
347
+ model: $("tts-model"),
348
+ stream_transport: $("tts-transport"),
349
+ };
350
+ const keyAliases = {
351
+ model_id: "model",
352
+ model: "model",
353
+ stream_transport: "stream_transport",
354
+ };
355
+ for (const sel of Object.values(fields)) {
356
+ if (!sel) continue;
357
+ sel.innerHTML = '<option value="">(auto)</option>';
358
+ }
359
+ if (!engineAlias) return;
360
+ try {
361
+ if (providerTemplates.length === 0) {
362
+ const res = await fetch('/v1/admin/provider-templates');
363
+ providerTemplates = (await res.json()).templates || [];
364
+ }
365
+ const engineInfo = (state.engines || []).find(e => e.name === engineAlias);
366
+ const providerKey = (engineInfo && engineInfo.provider) || engineAlias;
367
+ const configuredModel = String((engineInfo && engineInfo.display_info && engineInfo.display_info.model) || "");
368
+ const configuredTransport = String((engineInfo && engineInfo.display_info && engineInfo.display_info.stream_transport) || "");
369
+ const tpl = providerTemplates.find(t => t.provider === providerKey);
370
+ if (!tpl || !tpl.field_options) return;
371
+
372
+ for (const [key, opts] of Object.entries(tpl.field_options)) {
373
+ const mapped = keyAliases[key] || key;
374
+ const sel = fields[mapped];
375
+ if (!sel || !opts || opts.length === 0) continue;
376
+ const existing = new Set(Array.from(sel.options).map(o => String(o.value)));
377
+ for (const o of opts) {
378
+ const v = String(o);
379
+ if (existing.has(v)) continue;
380
+ const opt = document.createElement("option");
381
+ opt.value = v;
382
+ opt.textContent = v;
383
+ sel.appendChild(opt);
384
+ existing.add(v);
385
+ }
386
+ }
387
+ if (configuredModel && fields.model) {
388
+ const has = Array.from(fields.model.options).some((o) => o.value === configuredModel);
389
+ if (has) fields.model.value = configuredModel;
390
+ }
391
+ if (configuredTransport && fields.stream_transport) {
392
+ const has = Array.from(fields.stream_transport.options).some((o) => o.value === configuredTransport);
393
+ if (has) fields.stream_transport.value = configuredTransport;
394
+ }
395
+ maybeAutoAdjustTTSTransportByModel();
396
+ } catch (_) {}
397
+ }
398
+
399
+ async function loadSTTFieldOptions(engineAlias) {
400
+ const fields = { language: $("stt-language"), model: $("stt-model"), device: $("stt-device") };
401
+ // Normalize provider-specific option keys to Lab's generic STT controls.
402
+ // Example: elevenlabs-stt exposes language_code/model_id, while Lab uses language/model.
403
+ const keyAliases = {
404
+ language: "language",
405
+ language_code: "language",
406
+ model: "model",
407
+ model_id: "model",
408
+ model_name: "model",
409
+ device: "device",
410
+ compute_type: "device",
411
+ };
412
+ // Reset all to just (auto)
413
+ for (const sel of Object.values(fields)) {
414
+ sel.innerHTML = '<option value="">(auto)</option>';
415
+ }
416
+ if (!engineAlias) return;
417
+ try {
418
+ // Ensure providerTemplates are loaded
419
+ if (providerTemplates.length === 0) {
420
+ const res = await fetch('/v1/admin/provider-templates');
421
+ providerTemplates = (await res.json()).templates || [];
422
+ }
423
+ // Look up provider key from cached engine list (set by fetchEngineStatus)
424
+ const engineInfo = (state.engines || []).find(e => e.name === engineAlias);
425
+ const providerKey = (engineInfo && engineInfo.provider) || engineAlias;
426
+ const tpl = providerTemplates.find(t => t.provider === providerKey);
427
+ if (!tpl || !tpl.field_options) return;
428
+ for (const [key, opts] of Object.entries(tpl.field_options)) {
429
+ const normalizedKey = keyAliases[key] || key;
430
+ const sel = fields[normalizedKey];
431
+ if (!sel || !opts || opts.length === 0) continue;
432
+ const existing = new Set(Array.from(sel.options).map(o => String(o.value)));
433
+ for (const o of opts) {
434
+ const v = String(o);
435
+ if (existing.has(v)) continue;
436
+ const opt = document.createElement('option');
437
+ opt.value = v;
438
+ opt.textContent = v;
439
+ sel.appendChild(opt);
440
+ existing.add(v);
441
+ }
442
+ }
443
+ } catch (_) {}
444
+ }
445
+
446
+ function renderTasks(tasks) {
447
+ const body = $("tasks-table");
448
+ if (!body) return;
449
+ body.innerHTML = "";
450
+ for (const t of tasks) {
451
+ const tr = document.createElement("tr");
452
+ tr.innerHTML = `<td>${t.task_id.slice(0, 12)}</td><td>${t.action}</td><td>${t.status}</td><td>${t.phase}</td><td>${t.message}</td>`;
453
+ tr.addEventListener("click", () => {
454
+ $("task-id-input").value = t.task_id;
455
+ });
456
+ body.appendChild(tr);
457
+ }
458
+ }
459
+
460
+ async function refreshDashboard() {
461
+ $("dashboard-status").textContent = "syncing";
462
+ const [enginesData, healthData, tasksData] = await Promise.all([
463
+ fetchJson("/v1/engines"),
464
+ fetchJson("/v1/health"),
465
+ fetchJson("/v1/engines/tasks?limit=20"),
466
+ ]);
467
+
468
+ const engines = enginesData.engines || [];
469
+ state.engines = engines;
470
+ renderEngines(engines);
471
+
472
+ const healthMap = healthData.engines || {};
473
+ const healthyCount = Object.values(healthMap).filter(Boolean).length;
474
+ const runningTasks = (tasksData.tasks || []).filter((t) => t.status === "running").length;
475
+
476
+ setText("stat-providers", engines.length);
477
+ setText("stat-healthy", `${healthyCount}/${engines.length}`);
478
+ setText("stat-running-tasks", runningTasks);
479
+ setText("stat-updated-at", nowLabel());
480
+ renderTasks(tasksData.tasks || []);
481
+
482
+ $("dashboard-status").textContent = "ready";
483
+ }
484
+
485
+ async function runSTT(e) {
486
+ e.preventDefault();
487
+ if (sttMode() !== "file") return;
488
+ const file = $("stt-file").files[0];
489
+ const provider = $("stt-provider").value;
490
+ if (!file || !provider) return;
491
+ const language = $("stt-language").value.trim();
492
+ const model = $("stt-model").value.trim();
493
+ const device = $("stt-device").value.trim();
494
+ const beamSizeRaw = $("stt-beam-size").value.trim();
495
+
496
+ const form = new FormData();
497
+ form.append("provider", provider);
498
+ form.append("audio", file, file.name);
499
+ if (language) form.append("language", language);
500
+ if (model) form.append("model", model);
501
+ if (device) form.append("device", device);
502
+ if (beamSizeRaw) form.append("beam_size", beamSizeRaw);
503
+ setText("stt-stream-text", "");
504
+ setText("stt-result", "Running STT...");
505
+
506
+ const sttT0 = performance.now();
507
+ labLog("info", `[STT] Start file transcription — provider=${provider}, file=${file.name} (${(file.size/1024).toFixed(1)} KB)`);
508
+ labLogV("data", `[STT] language=${language || "(auto)"}, model=${model || "(auto)"}, device=${device || "(auto)"}, beam_size=${beamSizeRaw || "(default)"}`);
509
+ labLogV("data", `[STT] POST /v1/stt/transcribe`);
510
+
511
+ try {
512
+ const data = await fetchJson("/v1/stt/transcribe", { method: "POST", body: form });
513
+ const elapsed = Math.round(performance.now() - sttT0);
514
+ setText("stt-stream-text", String(data.text || ""));
515
+ setText("stt-result", JSON.stringify(data, null, 2));
516
+ labLog("info", `[STT] Completed in ${elapsed}ms — ${(data.text || "").length} chars, lang=${data.language || "?"}`);
517
+ labLogV("data", `[STT] confidence=${data.confidence ?? "N/A"}, duration=${data.duration_ms ?? "N/A"}ms`);
518
+ labLogV("data", `[STT] text: "${(data.text || "").slice(0, 200)}${(data.text || "").length > 200 ? "..." : ""}"`);
519
+ if (data.words && data.words.length > 0) {
520
+ labLogV("data", `[STT] words: ${data.words.length} items`);
521
+ }
522
+ } catch (err) {
523
+ const elapsed = Math.round(performance.now() - sttT0);
524
+ setText("stt-result", `Error: ${err.message}`);
525
+ labLog("error", `[STT] Failed after ${elapsed}ms — ${err.message}`);
526
+ }
527
+ }
528
+
529
+ function streamTextMerge(prev, incoming) {
530
+ const p = (prev || "").trim();
531
+ const n = (incoming || "").trim();
532
+ if (!n) return p;
533
+ if (!p) return n;
534
+ if (n === p) return p;
535
+ if (n.startsWith(p)) return n;
536
+ if (p.startsWith(n)) return p;
537
+ if (p.includes(n)) return p;
538
+ return `${p} ${n}`.replace(/\s+/g, " ").trim();
539
+ }
540
+
541
+ function streamTextSnapshot(prev, incoming) {
542
+ const p = (prev || "").trim();
543
+ const n = (incoming || "").trim();
544
+ if (!n) return p;
545
+ if (!p) return n;
546
+ if (n === p) return p;
547
+ // For snapshot-style streaming, incoming text is the latest full hypothesis.
548
+ // Replacing avoids duplicate growth when decoder revises earlier tokens.
549
+ return n;
550
+ }
551
+
552
+ function setSTTLiveStatus(text) {
553
+ $("stt-live-status").textContent = text;
554
+ }
555
+
556
+ function stopSTTLiveMetricsTimer() {
557
+ if (state.sttLiveMetricsTimer) {
558
+ clearInterval(state.sttLiveMetricsTimer);
559
+ state.sttLiveMetricsTimer = null;
560
+ }
561
+ }
562
+
563
+ function formatRate(bytesPerSec) {
564
+ const v = Number(bytesPerSec || 0);
565
+ if (v <= 0) return "0 B/s";
566
+ if (v >= 1024 * 1024) return `${(v / (1024 * 1024)).toFixed(2)} MB/s`;
567
+ if (v >= 1024) return `${(v / 1024).toFixed(1)} KB/s`;
568
+ return `${Math.round(v)} B/s`;
569
+ }
570
+
571
+ function renderLiveMetrics(ctx, extras = {}) {
572
+ const now = performance.now();
573
+ const startedAt = Number(ctx?.startedAt || now);
574
+ const elapsedMs = Math.max(0, Math.round(now - startedAt));
575
+ const sentBytes = Number(ctx?.sentBytes || 0);
576
+ const sentChunks = Number(ctx?.sentChunks || 0);
577
+ const bps = elapsedMs > 0 ? (sentBytes * 1000) / elapsedMs : 0;
578
+ const firstTokenLatencyMs = ctx?.firstTokenAt ? Math.max(0, Math.round(ctx.firstTokenAt - startedAt)) : null;
579
+
580
+ setText("stt-live-metrics", JSON.stringify({
581
+ provider: ctx?.provider || null,
582
+ transport: ctx?.transport || null,
583
+ status: ctx?.status || null,
584
+ elapsed_ms: elapsedMs,
585
+ sent_bytes: sentBytes,
586
+ sent_chunks: sentChunks,
587
+ upload_rate: formatRate(bps),
588
+ first_token_latency_ms: firstTokenLatencyMs,
589
+ ...extras,
590
+ }, null, 2));
591
+ }
592
+
593
+ function startSTTLiveMetricsTimer(ctx, extrasFactory = () => ({})) {
594
+ stopSTTLiveMetricsTimer();
595
+ state.sttLiveMetricsTimer = setInterval(() => {
596
+ if (state.sttLive !== ctx) {
597
+ stopSTTLiveMetricsTimer();
598
+ return;
599
+ }
600
+ renderLiveMetrics(ctx, extrasFactory());
601
+ }, 250);
602
+ }
603
+
604
+ function wsUrl(path, params = {}) {
605
+ const proto = window.location.protocol === "https:" ? "wss:" : "ws:";
606
+ const u = new URL(`${proto}//${window.location.host}${path}`);
607
+ Object.entries(params).forEach(([k, v]) => {
608
+ if (v != null && String(v).trim() !== "") u.searchParams.set(k, String(v));
609
+ });
610
+ return u.toString();
611
+ }
612
+
613
+ function downsampleFloat32(input, inputRate, outputRate) {
614
+ if (!input || input.length === 0) return new Float32Array(0);
615
+ if (outputRate >= inputRate) return input;
616
+ const ratio = inputRate / outputRate;
617
+ const outLen = Math.max(1, Math.round(input.length / ratio));
618
+ const out = new Float32Array(outLen);
619
+ let inOffset = 0;
620
+ for (let i = 0; i < outLen; i += 1) {
621
+ const nextInOffset = Math.min(input.length, Math.round((i + 1) * ratio));
622
+ let acc = 0;
623
+ let count = 0;
624
+ for (let j = inOffset; j < nextInOffset; j += 1) {
625
+ acc += input[j];
626
+ count += 1;
627
+ }
628
+ out[i] = count > 0 ? acc / count : 0;
629
+ inOffset = nextInOffset;
630
+ }
631
+ return out;
632
+ }
633
+
634
+ function float32ToPCM16Buffer(input) {
635
+ const ab = new ArrayBuffer(input.length * 2);
636
+ const view = new DataView(ab);
637
+ for (let i = 0; i < input.length; i += 1) {
638
+ let s = Math.max(-1, Math.min(1, input[i]));
639
+ s = s < 0 ? s * 0x8000 : s * 0x7fff;
640
+ view.setInt16(i * 2, s | 0, true);
641
+ }
642
+ return ab;
643
+ }
644
+
645
+ function pickRecorderMimeType() {
646
+ const candidates = [
647
+ "audio/webm;codecs=opus",
648
+ "audio/webm",
649
+ "audio/mp4",
650
+ ];
651
+ for (const t of candidates) {
652
+ if (window.MediaRecorder && MediaRecorder.isTypeSupported && MediaRecorder.isTypeSupported(t)) {
653
+ return t;
654
+ }
655
+ }
656
+ return "";
657
+ }
658
+
659
+ async function transcribeLiveSnapshot(ctx, phase = "streaming") {
660
+ if (!ctx || ctx.transcribing) return;
661
+ if (!ctx.chunks || ctx.chunks.length === 0) return;
662
+ ctx.transcribing = true;
663
+ const elapsedMs = Math.max(0, Math.round(performance.now() - ctx.startedAt));
664
+ try {
665
+ const blob = new Blob(ctx.chunks, { type: ctx.mimeType || "audio/webm" });
666
+ if (blob.size === 0) return;
667
+ labLogV("data", `[STT] HTTP snapshot (${phase}) — ${ctx.chunks.length} chunks, ${(blob.size/1024).toFixed(1)} KB`);
668
+ const form = new FormData();
669
+ form.append("provider", ctx.provider);
670
+ form.append("audio", blob, "live.webm");
671
+ if (ctx.language) form.append("language", ctx.language);
672
+ if (ctx.model) form.append("model", ctx.model);
673
+ if (ctx.device) form.append("device", ctx.device);
674
+ if (ctx.beamSizeRaw) form.append("beam_size", ctx.beamSizeRaw);
675
+ const data = await fetchJson("/v1/stt/transcribe", { method: "POST", body: form });
676
+ ctx.lastDurationMs = data.duration_ms ?? null;
677
+ ctx.lastLanguage = data.language ?? null;
678
+ const text = String((data && data.text) || "").trim();
679
+ if (text) {
680
+ if (!ctx.firstTokenAt) {
681
+ ctx.firstTokenAt = performance.now();
682
+ labLog("info", `[STT] First token received — latency=${Math.round(ctx.firstTokenAt - ctx.startedAt)}ms`);
683
+ }
684
+ state.sttLiveText = streamTextSnapshot(state.sttLiveText, text);
685
+ setText("stt-stream-text", state.sttLiveText);
686
+ labLogV("data", `[STT] Snapshot result: "${text.slice(0, 100)}${text.length > 100 ? "..." : ""}" (lang=${data.language ?? "?"})`);
687
+ }
688
+ setText("stt-result", JSON.stringify({
689
+ provider: ctx.provider,
690
+ mode: "live",
691
+ status: phase,
692
+ elapsed_ms: elapsedMs,
693
+ chunks: ctx.chunks.length,
694
+ last_duration_ms: data.duration_ms ?? null,
695
+ language: data.language ?? null,
696
+ }, null, 2));
697
+ } catch (err) {
698
+ setSTTLiveStatus("error");
699
+ setText("stt-result", `Error: ${err.message}`);
700
+ } finally {
701
+ ctx.transcribing = false;
702
+ }
703
+ }
704
+
705
+ function cleanupLiveContext(ctx) {
706
+ if (!ctx) return;
707
+ try {
708
+ if (ctx.noTokenFallbackTimer) {
709
+ clearTimeout(ctx.noTokenFallbackTimer);
710
+ ctx.noTokenFallbackTimer = null;
711
+ }
712
+ } catch (_) {}
713
+ try {
714
+ if (ctx.stream) ctx.stream.getTracks().forEach((t) => t.stop());
715
+ } catch (_) {}
716
+ try {
717
+ if (ctx.processor) ctx.processor.disconnect();
718
+ } catch (_) {}
719
+ try {
720
+ if (ctx.source) ctx.source.disconnect();
721
+ } catch (_) {}
722
+ try {
723
+ if (ctx.gain) ctx.gain.disconnect();
724
+ } catch (_) {}
725
+ try {
726
+ if (ctx.audioContext && ctx.audioContext.state !== "closed") {
727
+ ctx.audioContext.close().catch(() => {});
728
+ }
729
+ } catch (_) {}
730
+ try {
731
+ if (ctx.ws && (ctx.ws.readyState === WebSocket.OPEN || ctx.ws.readyState === WebSocket.CONNECTING)) {
732
+ ctx.ws.close();
733
+ }
734
+ } catch (_) {}
735
+ }
736
+
737
+ async function startLiveSTTFallback(provider, language, model, device, beamSizeRaw) {
738
+ if (!window.MediaRecorder) {
739
+ throw new Error("MediaRecorder is not supported in this browser");
740
+ }
741
+ let stream;
742
+ stream = await navigator.mediaDevices.getUserMedia({
743
+ audio: {
744
+ channelCount: 1,
745
+ echoCancellation: true,
746
+ noiseSuppression: true,
747
+ },
748
+ });
749
+ const mimeType = pickRecorderMimeType();
750
+ const recorder = mimeType
751
+ ? new MediaRecorder(stream, { mimeType })
752
+ : new MediaRecorder(stream);
753
+ const startedAt = performance.now();
754
+ const ctx = {
755
+ provider,
756
+ language,
757
+ model,
758
+ device,
759
+ beamSizeRaw,
760
+ stream,
761
+ recorder,
762
+ mimeType,
763
+ chunks: [],
764
+ transcribing: false,
765
+ startedAt,
766
+ transport: "http-chunk",
767
+ sentBytes: 0,
768
+ sentChunks: 0,
769
+ firstTokenAt: null,
770
+ status: "recording",
771
+ };
772
+ state.sttLive = ctx;
773
+ startSTTLiveMetricsTimer(ctx, () => ({
774
+ mode: "live",
775
+ chunks_buffered: ctx.chunks.length,
776
+ text_chars: state.sttLiveText.length,
777
+ last_duration_ms: ctx.lastDurationMs ?? null,
778
+ language: ctx.lastLanguage ?? ctx.language ?? null,
779
+ }));
780
+
781
+ recorder.onstart = () => {
782
+ setSTTLiveStatus("recording");
783
+ setText("stt-result", JSON.stringify({
784
+ provider,
785
+ mode: "live",
786
+ status: "recording",
787
+ transport: "http-chunk",
788
+ }, null, 2));
789
+ };
790
+
791
+ recorder.ondataavailable = async (event) => {
792
+ if (!event.data || event.data.size <= 0) return;
793
+ if (state.sttLive !== ctx) return;
794
+ ctx.sentBytes += Number(event.data.size || 0);
795
+ ctx.sentChunks += 1;
796
+ ctx.chunks.push(event.data);
797
+ await transcribeLiveSnapshot(ctx, "streaming");
798
+ };
799
+
800
+ recorder.onerror = (event) => {
801
+ const err = event && event.error ? event.error.message || String(event.error) : "recorder error";
802
+ setSTTLiveStatus("error");
803
+ setText("stt-result", `Error: ${err}`);
804
+ };
805
+
806
+ recorder.onstop = async () => {
807
+ if (state.sttLive === ctx) {
808
+ ctx.status = "closing";
809
+ await transcribeLiveSnapshot(ctx, "closed");
810
+ const elapsedMs = Math.max(0, Math.round(performance.now() - startedAt));
811
+ setText("stt-result", JSON.stringify({
812
+ provider,
813
+ mode: "live",
814
+ status: "closed",
815
+ transport: "http-chunk",
816
+ elapsed_ms: elapsedMs,
817
+ text: state.sttLiveText,
818
+ }, null, 2));
819
+ setSTTLiveStatus("stopped");
820
+ ctx.status = "stopped";
821
+ renderLiveMetrics(ctx, {
822
+ mode: "live",
823
+ chunks_buffered: ctx.chunks.length,
824
+ text_chars: state.sttLiveText.length,
825
+ last_duration_ms: ctx.lastDurationMs ?? null,
826
+ language: ctx.lastLanguage ?? ctx.language ?? null,
827
+ });
828
+ stopSTTLiveMetricsTimer();
829
+ state.sttLive = null;
830
+ }
831
+ cleanupLiveContext(ctx);
832
+ };
833
+
834
+ recorder.start(1200);
835
+ }
836
+
837
+ async function startLiveSTTWebSocket(provider, language, model, device, beamSizeRaw) {
838
+ const targetRate = 16000;
839
+ const startedAt = performance.now();
840
+
841
+ // Create WebSocket immediately — handshake runs in parallel with getUserMedia
842
+ const ws = new WebSocket(
843
+ wsUrl("/v1/stt/stream", { provider, language: language || "", sample_rate: targetRate }),
844
+ );
845
+ ws.binaryType = "arraybuffer";
846
+
847
+ // Buffer early messages (e.g. "meta") that arrive before onmessage handler is set
848
+ const earlyMessages = [];
849
+ ws.onmessage = (event) => { earlyMessages.push(event); };
850
+
851
+ const wsOpenPromise = new Promise((resolve, reject) => {
852
+ let timer = window.setTimeout(() => reject(new Error("WebSocket connect timeout")), 6000);
853
+ ws.onopen = () => { clearTimeout(timer); timer = 0; resolve(); };
854
+ ws.onerror = () => {
855
+ if (timer) { clearTimeout(timer); timer = 0; reject(new Error("WebSocket connection failed")); }
856
+ };
857
+ ws.onclose = () => {
858
+ if (timer) { clearTimeout(timer); timer = 0; reject(new Error("WebSocket closed during setup")); }
859
+ };
860
+ });
861
+
862
+ // Run getUserMedia and WebSocket handshake in parallel to overlap
863
+ // mic permission prompt with server-side WS setup + provider eager-start
864
+ const micPromise = navigator.mediaDevices.getUserMedia({
865
+ audio: { channelCount: 1, echoCancellation: true, noiseSuppression: true },
866
+ });
867
+ let stream;
868
+ try {
869
+ [stream] = await Promise.all([micPromise, wsOpenPromise]);
870
+ } catch (err) {
871
+ // Clean up mic stream if getUserMedia already resolved
872
+ micPromise.then(s => s.getTracks().forEach(t => t.stop())).catch(() => {});
873
+ try { ws.close(); } catch (_) {}
874
+ throw err;
875
+ }
876
+
877
+ const audioContext = new (window.AudioContext || window.webkitAudioContext)();
878
+ await audioContext.resume();
879
+ const source = audioContext.createMediaStreamSource(stream);
880
+ // bufferSize=2048 → ~42ms per frame at 48kHz, ~1280 bytes after
881
+ // downsampling to 16kHz PCM16 — matches iFlytek's recommended 40ms frame.
882
+ const processor = audioContext.createScriptProcessor(2048, 1, 1);
883
+ const gain = audioContext.createGain();
884
+ gain.gain.value = 0.0;
885
+
886
+ setSTTLiveStatus("recording");
887
+ labLogV("data", `[STT] WS opened — audioContext.sampleRate=${audioContext.sampleRate}, targetRate=${targetRate}`);
888
+ setText("stt-result", JSON.stringify({
889
+ provider,
890
+ mode: "live",
891
+ status: "recording",
892
+ transport: "ws",
893
+ audio_context_rate: audioContext.sampleRate,
894
+ target_rate: targetRate,
895
+ }, null, 2));
896
+
897
+ const ctx = {
898
+ provider,
899
+ language,
900
+ stream,
901
+ audioContext,
902
+ source,
903
+ processor,
904
+ gain,
905
+ ws,
906
+ startedAt,
907
+ transport: "ws",
908
+ stopping: false,
909
+ sentBytes: 0,
910
+ sentChunks: 0,
911
+ firstTokenAt: null,
912
+ status: "recording",
913
+ noTokenFallbackTimer: null,
914
+ isBatchMode: false,
915
+ closedPromiseResolve: null,
916
+ };
917
+ state.sttLive = ctx;
918
+ startSTTLiveMetricsTimer(ctx, () => ({
919
+ mode: "live",
920
+ text_chars: state.sttLiveText.length,
921
+ last_event: ctx.lastEventType || null,
922
+ ws_ready_state: Number(ctx.ws?.readyState ?? -1),
923
+ }));
924
+
925
+ processor.onaudioprocess = (event) => {
926
+ if (state.sttLive !== ctx) return;
927
+ if (ws.readyState !== WebSocket.OPEN) return;
928
+ const input = event.inputBuffer.getChannelData(0);
929
+ const mono = downsampleFloat32(input, audioContext.sampleRate, targetRate);
930
+ if (!mono || mono.length === 0) return;
931
+ const pcm = float32ToPCM16Buffer(mono);
932
+ ws.send(pcm);
933
+ ctx.sentBytes += Number(pcm.byteLength || 0);
934
+ ctx.sentChunks += 1;
935
+ if (ctx.sentChunks % 50 === 0) {
936
+ labLogV("data", `[STT] Sent ${ctx.sentChunks} chunks, ${(ctx.sentBytes/1024).toFixed(1)} KB`);
937
+ }
938
+ };
939
+
940
+ source.connect(processor);
941
+ processor.connect(gain);
942
+ gain.connect(audioContext.destination);
943
+
944
+ // Only set fallback timer for streaming providers; batch providers
945
+ // are expected to return results only after "stop" — no partials during recording.
946
+ ctx.noTokenFallbackTimer = window.setTimeout(async () => {
947
+ if (state.sttLive !== ctx) return;
948
+ if (ctx.firstTokenAt) return;
949
+ if ((ctx.sentChunks || 0) <= 0) return;
950
+ // Batch providers never send partials — don't fall back
951
+ if (ctx.isBatchMode) return;
952
+ ctx.stopping = true;
953
+ setSTTLiveStatus("fallback");
954
+ setText("stt-result", "WS has no transcript yet, falling back to chunk HTTP...");
955
+ try {
956
+ if (ctx.ws && ctx.ws.readyState === WebSocket.OPEN) {
957
+ ctx.ws.send(JSON.stringify({ type: "stop" }));
958
+ }
959
+ } catch (_) {}
960
+ try {
961
+ if (ctx.ws) ctx.ws.close();
962
+ } catch (_) {}
963
+ cleanupLiveContext(ctx);
964
+ if (state.sttLive === ctx) state.sttLive = null;
965
+ try {
966
+ await startLiveSTTFallback(provider, language, model, device, beamSizeRaw);
967
+ } catch (err) {
968
+ setSTTLiveStatus("error");
969
+ setText("stt-result", `Fallback failed: ${err && err.message ? err.message : err}`);
970
+ }
971
+ }, 7000);
972
+
973
+ const handleMessage = (event) => {
974
+ if (state.sttLive !== ctx) return;
975
+ let msg;
976
+ try {
977
+ msg = JSON.parse(String(event.data || "{}"));
978
+ } catch (_) {
979
+ return;
980
+ }
981
+ if (msg.type === "meta") {
982
+ ctx.isBatchMode = !msg.streaming;
983
+ labLogV("data", `[STT] Meta received — streaming=${msg.streaming}, batch_mode=${ctx.isBatchMode}`);
984
+ return;
985
+ }
986
+ if (msg.type === "error") {
987
+ ctx.lastEventType = "error";
988
+ ctx.status = "error";
989
+ setSTTLiveStatus("error");
990
+ setText("stt-result", `Error: ${msg.detail || "stream error"}`);
991
+ labLog("error", `[STT] Stream error: ${msg.detail || "unknown"}`);
992
+ return;
993
+ }
994
+ if (msg.type === "partial" || msg.type === "final") {
995
+ ctx.lastEventType = msg.type;
996
+ if (!ctx.firstTokenAt) {
997
+ ctx.firstTokenAt = performance.now();
998
+ labLog("info", `[STT] First token received — latency=${Math.round(ctx.firstTokenAt - startedAt)}ms`);
999
+ }
1000
+ if (ctx.noTokenFallbackTimer) {
1001
+ clearTimeout(ctx.noTokenFallbackTimer);
1002
+ ctx.noTokenFallbackTimer = null;
1003
+ }
1004
+ const text = String(msg.text || "").trim();
1005
+ if (text) {
1006
+ state.sttLiveText = streamTextSnapshot(state.sttLiveText, text);
1007
+ setText("stt-stream-text", state.sttLiveText);
1008
+ if (msg.type === "final") {
1009
+ labLog("data", `[STT] Final: "${text.slice(0, 120)}${text.length > 120 ? "..." : ""}"`);
1010
+ } else {
1011
+ labLogV("data", `[STT] Partial: "${text.slice(0, 80)}${text.length > 80 ? "..." : ""}" (conf=${msg.confidence ?? "N/A"})`);
1012
+ }
1013
+ }
1014
+ const elapsedMs = Math.max(0, Math.round(performance.now() - startedAt));
1015
+ setText("stt-result", JSON.stringify({
1016
+ provider,
1017
+ mode: "live",
1018
+ status: msg.type === "final" ? "final" : "streaming",
1019
+ transport: "ws",
1020
+ elapsed_ms: elapsedMs,
1021
+ language: msg.language ?? language ?? null,
1022
+ confidence: msg.confidence ?? null,
1023
+ }, null, 2));
1024
+
1025
+ // When we receive a "final" result (e.g. iFlytek VAD triggered),
1026
+ // auto-stop recording: release mic & audio resources immediately,
1027
+ // then let the WS "closed" message handle the rest naturally.
1028
+ if (msg.type === "final" && state.sttLive === ctx) {
1029
+ labLog("info", `[STT] VAD end-of-speech detected — auto-stopping recording`);
1030
+ ctx.stopping = true;
1031
+ // Stop mic & audio pipeline (but keep WS open for "closed" msg)
1032
+ try { if (ctx.stream) ctx.stream.getTracks().forEach((t) => t.stop()); } catch (_) {}
1033
+ try { if (ctx.processor) ctx.processor.disconnect(); } catch (_) {}
1034
+ try { if (ctx.source) ctx.source.disconnect(); } catch (_) {}
1035
+ try { if (ctx.gain) ctx.gain.disconnect(); } catch (_) {}
1036
+ try {
1037
+ if (ctx.audioContext && ctx.audioContext.state !== "closed") {
1038
+ ctx.audioContext.close().catch(() => {});
1039
+ }
1040
+ } catch (_) {}
1041
+ setSTTLiveStatus("stopped");
1042
+ }
1043
+
1044
+ return;
1045
+ }
1046
+ if (msg.type === "closed") {
1047
+ ctx.lastEventType = "closed";
1048
+ const elapsedMs = Math.max(0, Math.round(performance.now() - startedAt));
1049
+ labLog("info", `[STT] Session closed — elapsed=${elapsedMs}ms, result=${state.sttLiveText.length} chars`);
1050
+ setSTTLiveStatus("stopped");
1051
+ ctx.status = "stopped";
1052
+ setText("stt-result", JSON.stringify({
1053
+ provider,
1054
+ mode: "live",
1055
+ status: "closed",
1056
+ transport: "ws",
1057
+ elapsed_ms: elapsedMs,
1058
+ text: state.sttLiveText,
1059
+ }, null, 2));
1060
+ if (state.sttLive === ctx) {
1061
+ renderLiveMetrics(ctx, {
1062
+ mode: "live",
1063
+ text_chars: state.sttLiveText.length,
1064
+ last_event: ctx.lastEventType || null,
1065
+ ws_ready_state: Number(ctx.ws?.readyState ?? -1),
1066
+ });
1067
+ stopSTTLiveMetricsTimer();
1068
+ state.sttLive = null;
1069
+ }
1070
+ if (ctx.closedPromiseResolve) {
1071
+ ctx.closedPromiseResolve();
1072
+ ctx.closedPromiseResolve = null;
1073
+ }
1074
+ cleanupLiveContext(ctx);
1075
+ }
1076
+ };
1077
+ ws.onmessage = handleMessage;
1078
+ // Replay any messages that arrived before the handler was set (e.g. "meta")
1079
+ for (const ev of earlyMessages) handleMessage(ev);
1080
+
1081
+ ws.onerror = () => {
1082
+ if (state.sttLive !== ctx) return;
1083
+ ctx.lastEventType = "error";
1084
+ ctx.status = "error";
1085
+ if (ctx.noTokenFallbackTimer) {
1086
+ clearTimeout(ctx.noTokenFallbackTimer);
1087
+ ctx.noTokenFallbackTimer = null;
1088
+ }
1089
+ setSTTLiveStatus("error");
1090
+ setText("stt-result", "Error: live WS stream failed");
1091
+ };
1092
+
1093
+ ws.onclose = () => {
1094
+ // Resolve the stop promise in case WS closed without a "closed" message
1095
+ if (ctx.closedPromiseResolve) {
1096
+ ctx.closedPromiseResolve();
1097
+ ctx.closedPromiseResolve = null;
1098
+ }
1099
+ if (state.sttLive !== ctx) return;
1100
+ if (!ctx.stopping) {
1101
+ if (ctx.noTokenFallbackTimer) {
1102
+ clearTimeout(ctx.noTokenFallbackTimer);
1103
+ ctx.noTokenFallbackTimer = null;
1104
+ }
1105
+ ctx.lastEventType = "closed";
1106
+ ctx.status = "stopped";
1107
+ setSTTLiveStatus("stopped");
1108
+ const elapsedMs = Math.max(0, Math.round(performance.now() - startedAt));
1109
+ setText("stt-result", JSON.stringify({
1110
+ provider,
1111
+ mode: "live",
1112
+ status: "closed",
1113
+ transport: "ws",
1114
+ elapsed_ms: elapsedMs,
1115
+ text: state.sttLiveText,
1116
+ }, null, 2));
1117
+ renderLiveMetrics(ctx, {
1118
+ mode: "live",
1119
+ text_chars: state.sttLiveText.length,
1120
+ last_event: ctx.lastEventType || null,
1121
+ ws_ready_state: Number(ctx.ws?.readyState ?? -1),
1122
+ });
1123
+ stopSTTLiveMetricsTimer();
1124
+ state.sttLive = null;
1125
+ cleanupLiveContext(ctx);
1126
+ }
1127
+ };
1128
+ }
1129
+
1130
+ async function startLiveSTT() {
1131
+ await stopLiveSTT();
1132
+ const provider = $("stt-provider").value;
1133
+ if (!provider) {
1134
+ setText("stt-result", "Error: provider is required");
1135
+ return;
1136
+ }
1137
+ const language = $("stt-language").value.trim();
1138
+ const model = $("stt-model").value.trim();
1139
+ const device = $("stt-device").value.trim();
1140
+ const beamSizeRaw = $("stt-beam-size").value.trim();
1141
+ state.sttLiveText = "";
1142
+ setText("stt-stream-text", "");
1143
+ setText("stt-result", "Starting live STT...");
1144
+ setText("stt-live-metrics", JSON.stringify({ status: "initializing" }, null, 2));
1145
+ setSTTLiveStatus("initializing");
1146
+ labLog("info", `[STT] Start live recording — provider=${provider}, lang=${language || "auto"}`);
1147
+ labLogV("data", `[STT] model=${model || "(auto)"}, device=${device || "(auto)"}, beam_size=${beamSizeRaw || "(default)"}`);
1148
+ try {
1149
+ labLogV("data", `[STT] Connecting WebSocket /v1/stt/stream ...`);
1150
+ await startLiveSTTWebSocket(provider, language, model, device, beamSizeRaw);
1151
+ labLog("info", `[STT] WebSocket connected — transport=ws`);
1152
+ } catch (err) {
1153
+ labLog("warn", `[STT] WebSocket failed: ${err.message || err}, falling back to HTTP chunk`);
1154
+ cleanupLiveContext(state.sttLive);
1155
+ state.sttLive = null;
1156
+ setSTTLiveStatus("fallback");
1157
+ setText("stt-result", `WS init failed, fallback to chunk HTTP: ${err.message || err}`);
1158
+ await startLiveSTTFallback(provider, language, model, device, beamSizeRaw);
1159
+ labLog("info", `[STT] Fallback recording started — transport=http-chunk`);
1160
+ }
1161
+ }
1162
+
1163
+ async function stopLiveSTT() {
1164
+ const ctx = state.sttLive;
1165
+ if (!ctx) return;
1166
+ labLog("info", `[STT] Stopping live recording...`);
1167
+ labLogV("data", `[STT] transport=${ctx.transport || "?"}, sent=${ctx.sentChunks} chunks / ${(ctx.sentBytes/1024).toFixed(1)} KB, text=${state.sttLiveText.length} chars`);
1168
+ setSTTLiveStatus("stopping");
1169
+ ctx.status = "stopping";
1170
+ if (ctx.ws) {
1171
+ ctx.stopping = true;
1172
+ try {
1173
+ if (ctx.ws.readyState === WebSocket.OPEN) {
1174
+ ctx.ws.send(JSON.stringify({ type: "stop" }));
1175
+ }
1176
+ } catch (_) {}
1177
+
1178
+ // Wait for backend to send "final" + "closed" before closing WS.
1179
+ // This is critical for batch providers (e.g. native_stt) which only
1180
+ // transcribe after receiving "stop".
1181
+ if (ctx.ws.readyState === WebSocket.OPEN || ctx.ws.readyState === WebSocket.CONNECTING) {
1182
+ if (ctx.isBatchMode) {
1183
+ setSTTLiveStatus("transcribing");
1184
+ setText("stt-result", JSON.stringify({
1185
+ provider: ctx.provider,
1186
+ mode: "live",
1187
+ status: "transcribing",
1188
+ transport: "ws",
1189
+ info: "Waiting for batch recognition result...",
1190
+ }, null, 2));
1191
+ }
1192
+ try {
1193
+ await Promise.race([
1194
+ new Promise((resolve) => { ctx.closedPromiseResolve = resolve; }),
1195
+ new Promise((resolve) => setTimeout(resolve, 20000)), // 20s safety timeout
1196
+ ]);
1197
+ } catch (_) {}
1198
+ }
1199
+
1200
+ try {
1201
+ if (ctx.ws.readyState !== WebSocket.CLOSED) ctx.ws.close();
1202
+ } catch (_) {}
1203
+ // Only clean up if the "closed" handler didn't already do it
1204
+ if (state.sttLive === ctx) {
1205
+ cleanupLiveContext(ctx);
1206
+ renderLiveMetrics(ctx, {
1207
+ mode: "live",
1208
+ text_chars: state.sttLiveText.length,
1209
+ last_event: ctx.lastEventType || null,
1210
+ ws_ready_state: Number(ctx.ws?.readyState ?? -1),
1211
+ });
1212
+ stopSTTLiveMetricsTimer();
1213
+ state.sttLive = null;
1214
+ }
1215
+ setSTTLiveStatus("stopped");
1216
+ return;
1217
+ }
1218
+ try {
1219
+ if (ctx.recorder && ctx.recorder.state !== "inactive") {
1220
+ try {
1221
+ ctx.recorder.requestData();
1222
+ } catch (_) {}
1223
+ ctx.recorder.stop();
1224
+ return;
1225
+ }
1226
+ } catch (_) {}
1227
+ cleanupLiveContext(ctx);
1228
+ renderLiveMetrics(ctx, {
1229
+ mode: "live",
1230
+ chunks_buffered: ctx.chunks ? ctx.chunks.length : 0,
1231
+ text_chars: state.sttLiveText.length,
1232
+ last_duration_ms: ctx.lastDurationMs ?? null,
1233
+ language: ctx.lastLanguage ?? ctx.language ?? null,
1234
+ });
1235
+ stopSTTLiveMetricsTimer();
1236
+ state.sttLive = null;
1237
+ setSTTLiveStatus("stopped");
1238
+ }
1239
+
1240
+ async function runTTS(e) {
1241
+ e.preventDefault();
1242
+ const provider = $("tts-provider").value;
1243
+ const text = $("tts-text").value.trim();
1244
+ const model = $("tts-model").value.trim();
1245
+ const streamTransport = $("tts-transport").value.trim();
1246
+ if (!provider || !text) return;
1247
+
1248
+ stopTTSTimer();
1249
+ hideTTSDownload();
1250
+ const startedAt = performance.now();
1251
+ const audioChunks = [];
1252
+ let receivedBytes = 0;
1253
+ let meta = {};
1254
+ let firstChunkAt = 0;
1255
+
1256
+ labLog("info", `[TTS] Start synthesis — provider=${provider}, text=${text.length} chars, voice=${$("tts-voice").value || "(default)"}`);
1257
+ labLogV("data", `[TTS] speed=${parseFloat($("tts-speed").value) || 1.0}, model=${model || "(auto)"}, transport=${streamTransport || "(auto)"}`);
1258
+ labLogV("data", `[TTS] text="${text.slice(0, 80)}${text.length > 80 ? "..." : ""}"`);
1259
+
1260
+ const renderStreaming = () => {
1261
+ const elapsed = Math.max(0, Math.round(performance.now() - startedAt));
1262
+ setText("tts-result", JSON.stringify({
1263
+ provider,
1264
+ status: "streaming",
1265
+ received_bytes: receivedBytes,
1266
+ chunks: audioChunks.length,
1267
+ elapsed_ms: elapsed,
1268
+ first_chunk_ms: firstChunkAt ? Math.round(firstChunkAt - startedAt) : null,
1269
+ text_chars: text.length,
1270
+ }, null, 2));
1271
+ };
1272
+ renderStreaming();
1273
+ // Show download panel immediately in streaming mode, but keep button disabled
1274
+ // until we have a complete audio buffer.
1275
+ showTTSDownload(meta.audio_format || "streaming", false);
1276
+ state.ttsTimer = setInterval(renderStreaming, 120);
1277
+
1278
+ const wsProto = location.protocol === "https:" ? "wss:" : "ws:";
1279
+ const wsUrl = `${wsProto}//${location.host}/v1/tts/stream`;
1280
+
1281
+ // Streaming playback state
1282
+ let streamPlayer = null;
1283
+
1284
+ try {
1285
+ await new Promise((resolve, reject) => {
1286
+ const ws = new WebSocket(wsUrl);
1287
+ ws.binaryType = "arraybuffer";
1288
+
1289
+ ws.onopen = () => {
1290
+ labLog("info", `[TTS] WebSocket connected, sending request...`);
1291
+ const payload = {
1292
+ provider,
1293
+ text,
1294
+ voice: $("tts-voice").value || null,
1295
+ speed: parseFloat($("tts-speed").value) || 1.0,
1296
+ model: model || null,
1297
+ stream_transport: streamTransport || null,
1298
+ };
1299
+ labLogV("data", `[TTS] WS send: ${JSON.stringify(payload).slice(0, 200)}`);
1300
+ ws.send(JSON.stringify(payload));
1301
+ };
1302
+
1303
+ ws.onmessage = (event) => {
1304
+ if (event.data instanceof ArrayBuffer) {
1305
+ if (!firstChunkAt) {
1306
+ firstChunkAt = performance.now();
1307
+ labLog("info", `[TTS] First audio chunk — latency=${Math.round(firstChunkAt - startedAt)}ms, size=${event.data.byteLength}B`);
1308
+ }
1309
+ audioChunks.push(event.data);
1310
+ receivedBytes += event.data.byteLength;
1311
+ if (audioChunks.length % 20 === 0) {
1312
+ labLogV("data", `[TTS] Received ${audioChunks.length} chunks, ${(receivedBytes/1024).toFixed(1)} KB`);
1313
+ }
1314
+
1315
+ // Feed chunk to streaming player
1316
+ if (streamPlayer) {
1317
+ streamPlayer.feed(new Uint8Array(event.data));
1318
+ }
1319
+ } else {
1320
+ try {
1321
+ const msg = JSON.parse(event.data);
1322
+ if (msg.type === "meta") {
1323
+ meta = msg;
1324
+ labLog("info", `[TTS] Stream meta received — format=${msg.audio_format || "?"}, rate=${msg.sample_rate || "?"}, streaming=${msg.streaming}`);
1325
+ // Initialize streaming player based on audio format
1326
+ const audioFormat = msg.audio_format || "wav";
1327
+ streamPlayer = createStreamPlayer(audioFormat);
1328
+ streamPlayer.start();
1329
+ } else if (msg.type === "done") {
1330
+ meta = { ...meta, ...msg };
1331
+ if (streamPlayer) streamPlayer.end();
1332
+ ws.close();
1333
+ resolve();
1334
+ } else if (msg.type === "error") {
1335
+ labLog("error", `[TTS] Stream error: ${msg.detail || "unknown"}`);
1336
+ ws.close();
1337
+ reject(new Error(msg.detail || "TTS streaming error"));
1338
+ }
1339
+ } catch (_) {}
1340
+ }
1341
+ };
1342
+
1343
+ ws.onerror = () => {
1344
+ labLogV("error", `[TTS] WS onerror event`);
1345
+ reject(new Error("WebSocket connection failed"));
1346
+ };
1347
+
1348
+ ws.onclose = (ev) => {
1349
+ labLogV("data", `[TTS] WS closed — code=${ev.code}, reason=${ev.reason || "(none)"}`);
1350
+ if (ev.code !== 1000 && ev.code !== 1005 && audioChunks.length === 0) {
1351
+ reject(new Error(`WebSocket closed: ${ev.reason || ev.code}`));
1352
+ } else {
1353
+ resolve();
1354
+ }
1355
+ };
1356
+ });
1357
+
1358
+ const totalSize = audioChunks.reduce((s, c) => s + c.byteLength, 0);
1359
+ const clientElapsedMs = Math.max(0, Math.round(performance.now() - startedAt));
1360
+
1361
+ // Combine all chunks into a single buffer
1362
+ const combined = new Uint8Array(totalSize);
1363
+ let offset = 0;
1364
+ for (const chunk of audioChunks) {
1365
+ combined.set(new Uint8Array(chunk), offset);
1366
+ offset += chunk.byteLength;
1367
+ }
1368
+
1369
+ // If streamPlayer was not used (no meta message), fallback to full-buffer playback
1370
+ if (!streamPlayer) {
1371
+ const blob = new Blob([combined], { type: "audio/wav" });
1372
+ const audio = $("tts-audio");
1373
+ audio.src = URL.createObjectURL(blob);
1374
+ audio.play().catch(() => {});
1375
+ }
1376
+
1377
+ // Store audio data for download
1378
+ const audioFormat = meta.audio_format || "wav";
1379
+ state.ttsAudioData = combined;
1380
+ state.ttsAudioFormat = audioFormat;
1381
+ state.ttsSampleRate = meta.sample_rate || 16000;
1382
+ state.ttsChannels = meta.channels || 1;
1383
+ showTTSDownload(audioFormat, true);
1384
+
1385
+ setText("tts-result", JSON.stringify({
1386
+ provider,
1387
+ status: "done",
1388
+ mode: meta.streaming ? "streaming" : "batch",
1389
+ format: audioFormat,
1390
+ bytes: totalSize,
1391
+ chunks: audioChunks.length,
1392
+ sample_rate: meta.sample_rate || null,
1393
+ duration_ms: meta.duration_ms || 0,
1394
+ elapsed_ms: clientElapsedMs,
1395
+ first_chunk_ms: firstChunkAt ? Math.round(firstChunkAt - startedAt) : null,
1396
+ }, null, 2));
1397
+ labLog("info", `[TTS] Completed in ${clientElapsedMs}ms — ${totalSize} bytes, ${audioChunks.length} chunks, format=${audioFormat}`);
1398
+ labLogV("data", `[TTS] mode=${meta.streaming ? "streaming" : "batch"}, sample_rate=${meta.sample_rate || "?"}, duration=${meta.duration_ms || "?"}ms, first_chunk=${firstChunkAt ? Math.round(firstChunkAt - startedAt) + "ms" : "N/A"}`);
1399
+ } catch (err) {
1400
+ if (streamPlayer) streamPlayer.destroy();
1401
+ const clientElapsedMs = Math.max(0, Math.round(performance.now() - startedAt));
1402
+ setText("tts-result", JSON.stringify({
1403
+ provider,
1404
+ status: "error",
1405
+ elapsed_ms: clientElapsedMs,
1406
+ error: String(err && err.message ? err.message : err),
1407
+ }, null, 2));
1408
+ labLog("error", `[TTS] Failed after ${clientElapsedMs}ms — ${err && err.message ? err.message : err}`);
1409
+ } finally {
1410
+ stopTTSTimer();
1411
+ }
1412
+ }
1413
+
1414
+ // --- TTS Download ---
1415
+
1416
+ function showTTSDownload(originalFormat, ready = true) {
1417
+ const area = $("tts-download-area");
1418
+ const select = $("tts-download-format");
1419
+ const btn = $("tts-download-btn");
1420
+ const status = $("tts-download-status");
1421
+ if (!area || !select) return;
1422
+
1423
+ // Update the "Original" option label
1424
+ const origOpt = select.querySelector('option[value="__original__"]');
1425
+ if (origOpt) origOpt.textContent = "Original (" + (originalFormat || "raw").toUpperCase() + ")";
1426
+
1427
+ // Load available formats from server
1428
+ fetchJson("/v1/tts/formats").then(data => {
1429
+ const formats = data.formats || [];
1430
+ // Enable/disable options based on ffmpeg availability
1431
+ for (const opt of select.options) {
1432
+ if (opt.value === "__original__") continue;
1433
+ const fmt = formats.find(f => f.id === opt.value);
1434
+ if (fmt) {
1435
+ opt.disabled = !fmt.available;
1436
+ opt.textContent = fmt.label + (fmt.available ? "" : " (needs ffmpeg)");
1437
+ }
1438
+ }
1439
+ }).catch(() => {});
1440
+
1441
+ area.classList.remove("hidden");
1442
+ if (btn) btn.disabled = !ready;
1443
+ if (status) {
1444
+ status.textContent = ready ? "" : "Available after synthesis completes";
1445
+ }
1446
+ }
1447
+
1448
+ function hideTTSDownload() {
1449
+ const area = $("tts-download-area");
1450
+ const btn = $("tts-download-btn");
1451
+ const status = $("tts-download-status");
1452
+ if (area) area.classList.add("hidden");
1453
+ if (btn) btn.disabled = false;
1454
+ if (status) status.textContent = "";
1455
+ state.ttsAudioData = null;
1456
+ state.ttsAudioFormat = null;
1457
+ }
1458
+
1459
+ window.downloadTTSAudio = async function(event) {
1460
+ if (event && typeof event.preventDefault === "function") event.preventDefault();
1461
+ if (event && typeof event.stopPropagation === "function") event.stopPropagation();
1462
+ if (!state.ttsAudioData) return;
1463
+
1464
+ const select = $("tts-download-format");
1465
+ const statusEl = $("tts-download-status");
1466
+ const targetFormat = select.value;
1467
+
1468
+ // Direct download if original format or same format
1469
+ if (targetFormat === "__original__" || targetFormat === state.ttsAudioFormat) {
1470
+ const ext = { mp3: ".mp3", wav: ".wav", ogg: ".ogg", flac: ".flac", opus: ".opus" };
1471
+ const mimes = { mp3: "audio/mpeg", wav: "audio/wav", ogg: "audio/ogg", flac: "audio/flac", opus: "audio/opus" };
1472
+ const fmt = state.ttsAudioFormat || "wav";
1473
+ const blob = new Blob([state.ttsAudioData], { type: mimes[fmt] || "application/octet-stream" });
1474
+ triggerDownload(blob, "speech" + (ext[fmt] || ".bin"));
1475
+ statusEl.textContent = "Downloaded";
1476
+ return;
1477
+ }
1478
+
1479
+ // Convert via server
1480
+ statusEl.textContent = "Converting...";
1481
+ try {
1482
+ const formData = new FormData();
1483
+ const srcFmt = state.ttsAudioFormat || "wav";
1484
+ const mimes = { mp3: "audio/mpeg", wav: "audio/wav", ogg: "audio/ogg", flac: "audio/flac" };
1485
+ const blob = new Blob([state.ttsAudioData], { type: mimes[srcFmt] || "application/octet-stream" });
1486
+ formData.append("audio", blob, "audio." + srcFmt);
1487
+ formData.append("source_format", srcFmt);
1488
+ formData.append("target_format", targetFormat);
1489
+ formData.append("sample_rate", String(state.ttsSampleRate));
1490
+ formData.append("channels", String(state.ttsChannels));
1491
+
1492
+ const resp = await fetch("/v1/tts/convert", { method: "POST", body: formData });
1493
+ if (!resp.ok) {
1494
+ const err = await resp.json().catch(() => ({ detail: resp.statusText }));
1495
+ throw new Error(err.detail || "Conversion failed");
1496
+ }
1497
+ const resultBlob = await resp.blob();
1498
+ const ext = { wav: ".wav", mp3: ".mp3", ogg: ".ogg", flac: ".flac", opus: ".opus" };
1499
+ triggerDownload(resultBlob, "speech" + (ext[targetFormat] || ".bin"));
1500
+ statusEl.textContent = "Downloaded (" + targetFormat.toUpperCase() + ")";
1501
+ } catch (e) {
1502
+ statusEl.textContent = "Error: " + e.message;
1503
+ }
1504
+ };
1505
+
1506
+ function triggerDownload(blob, filename) {
1507
+ const url = URL.createObjectURL(blob);
1508
+ const a = document.createElement("a");
1509
+ a.href = url;
1510
+ a.download = filename;
1511
+ document.body.appendChild(a);
1512
+ a.click();
1513
+ document.body.removeChild(a);
1514
+ setTimeout(() => URL.revokeObjectURL(url), 5000);
1515
+ }
1516
+
1517
+ /**
1518
+ * Create a streaming audio player that plays chunks as they arrive.
1519
+ * Strategy:
1520
+ * 1. MSE (MediaSource Extensions) for MP3 — true chunk-by-chunk playback
1521
+ * 2. Progressive Blob — grow a Blob URL as chunks arrive, good enough for MP3
1522
+ * 3. Full-buffer fallback for WAV/PCM
1523
+ */
1524
+ function createStreamPlayer(audioFormat) {
1525
+ const mseType = audioFormat === "mp3" ? 'audio/mpeg' : 'audio/wav';
1526
+ const mseSupported = window.MediaSource && MediaSource.isTypeSupported(mseType);
1527
+ console.log('[StreamPlayer] format=' + audioFormat + ' MSE(' + mseType + ')=' + mseSupported);
1528
+
1529
+ if (mseSupported) {
1530
+ return createMSEPlayer(mseType);
1531
+ }
1532
+ // Fallback: progressive blob for MP3, full-buffer for others
1533
+ if (audioFormat === "mp3") {
1534
+ return createProgressiveBlobPlayer("audio/mpeg");
1535
+ }
1536
+ return createFullBufferPlayer();
1537
+ }
1538
+
1539
+ /** MSE-based streaming player — true low-latency chunk playback */
1540
+ function createMSEPlayer(mimeType) {
1541
+ const audio = $("tts-audio");
1542
+ let mediaSource = null;
1543
+ let sourceBuffer = null;
1544
+ let queue = [];
1545
+ let ended = false;
1546
+ let sourceOpen = false;
1547
+ let chunkIdx = 0;
1548
+
1549
+ function appendNext() {
1550
+ if (!sourceBuffer || sourceBuffer.updating || queue.length === 0) return;
1551
+ const chunk = queue.shift();
1552
+ try {
1553
+ sourceBuffer.appendBuffer(chunk);
1554
+ console.log('[MSE] appended chunk #' + (chunkIdx++) + ', ' + chunk.byteLength + ' bytes, buffered=' + (sourceBuffer.buffered.length > 0 ? sourceBuffer.buffered.end(0).toFixed(2) + 's' : '0s'));
1555
+ } catch (e) {
1556
+ console.warn("[MSE] appendBuffer error:", e);
1557
+ }
1558
+ }
1559
+
1560
+ return {
1561
+ start() {
1562
+ mediaSource = new MediaSource();
1563
+ audio.src = URL.createObjectURL(mediaSource);
1564
+
1565
+ mediaSource.addEventListener('sourceopen', () => {
1566
+ sourceOpen = true;
1567
+ console.log('[MSE] sourceopen fired');
1568
+ try {
1569
+ sourceBuffer = mediaSource.addSourceBuffer(mimeType);
1570
+ sourceBuffer.addEventListener('updateend', () => {
1571
+ if (queue.length > 0) {
1572
+ appendNext();
1573
+ } else if (ended && mediaSource.readyState === 'open') {
1574
+ try { mediaSource.endOfStream(); console.log('[MSE] endOfStream called'); } catch (_) {}
1575
+ }
1576
+ });
1577
+ // Flush any queued data that arrived before sourceopen
1578
+ if (queue.length > 0) appendNext();
1579
+ } catch (e) {
1580
+ console.error("[MSE] addSourceBuffer error:", e);
1581
+ }
1582
+ });
1583
+
1584
+ audio.play().catch(e => console.log('[MSE] play() rejected:', e.message));
1585
+ },
1586
+ feed(data) {
1587
+ const buf = (data.buffer && data.buffer instanceof ArrayBuffer) ? data.buffer : data;
1588
+ queue.push(buf);
1589
+ if (sourceOpen && sourceBuffer && !sourceBuffer.updating) {
1590
+ appendNext();
1591
+ }
1592
+ },
1593
+ end() {
1594
+ ended = true;
1595
+ console.log('[MSE] end() called, queue=' + queue.length + ', updating=' + (sourceBuffer ? sourceBuffer.updating : 'N/A'));
1596
+ if (sourceBuffer && !sourceBuffer.updating && queue.length === 0 && mediaSource.readyState === 'open') {
1597
+ try { mediaSource.endOfStream(); } catch (_) {}
1598
+ }
1599
+ },
1600
+ destroy() {
1601
+ try {
1602
+ if (mediaSource && mediaSource.readyState === 'open') mediaSource.endOfStream();
1603
+ } catch (_) {}
1604
+ },
1605
+ };
1606
+ }
1607
+
1608
+ /**
1609
+ * Progressive Blob player for MP3 — rebuilds Blob URL on each chunk.
1610
+ * Less efficient than MSE but works universally.
1611
+ * Starts playback after the first chunk arrives.
1612
+ */
1613
+ function createProgressiveBlobPlayer(mimeType) {
1614
+ const audio = $("tts-audio");
1615
+ const chunks = [];
1616
+ let playing = false;
1617
+
1618
+ return {
1619
+ start() {},
1620
+ feed(data) {
1621
+ chunks.push(new Uint8Array(data));
1622
+ console.log('[ProgressiveBlob] chunk #' + chunks.length + ', ' + data.byteLength + ' bytes');
1623
+ // Start playback after the first chunk
1624
+ if (!playing && chunks.length >= 1) {
1625
+ playing = true;
1626
+ const blob = new Blob(chunks, { type: mimeType });
1627
+ audio.src = URL.createObjectURL(blob);
1628
+ audio.play().catch(e => console.log('[ProgressiveBlob] play rejected:', e.message));
1629
+ }
1630
+ },
1631
+ end() {
1632
+ // Rebuild final Blob with all chunks for full playback + seeking
1633
+ console.log('[ProgressiveBlob] end(), total chunks=' + chunks.length);
1634
+ const blob = new Blob(chunks, { type: mimeType });
1635
+ const url = URL.createObjectURL(blob);
1636
+ const currentTime = audio.currentTime;
1637
+ audio.src = url;
1638
+ audio.currentTime = currentTime;
1639
+ if (audio.paused) audio.play().catch(() => {});
1640
+ },
1641
+ destroy() {},
1642
+ };
1643
+ }
1644
+
1645
+ /** Full-buffer player — collects all chunks, plays once at the end */
1646
+ function createFullBufferPlayer() {
1647
+ const chunks = [];
1648
+ const audio = $("tts-audio");
1649
+
1650
+ return {
1651
+ start() {},
1652
+ feed(data) {
1653
+ chunks.push(new Uint8Array(data));
1654
+ },
1655
+ end() {
1656
+ const totalSize = chunks.reduce((s, c) => s + c.byteLength, 0);
1657
+ const combined = new Uint8Array(totalSize);
1658
+ let offset = 0;
1659
+ for (const c of chunks) { combined.set(c, offset); offset += c.byteLength; }
1660
+ const blob = new Blob([combined], { type: "audio/wav" });
1661
+ audio.src = URL.createObjectURL(blob);
1662
+ audio.play().catch(() => {});
1663
+ },
1664
+ destroy() {},
1665
+ };
1666
+ }
1667
+
1668
+ function enginePayload(action) {
1669
+ const dockerImage = $("engine-docker-image").value.trim();
1670
+ const modelRepo = $("engine-model-repo").value.trim();
1671
+ const pullOnStart = Boolean($("engine-pull-on-start").checked);
1672
+ const simulateDownload = Boolean($("engine-simulate-download").checked);
1673
+ const installDir = $("engine-install-dir").value.trim() || "~/AI/services";
1674
+ const options = {};
1675
+ if (dockerImage) {
1676
+ options.docker_image = dockerImage;
1677
+ }
1678
+ if (modelRepo) {
1679
+ options.native_model_repo = modelRepo;
1680
+ }
1681
+ options.native_simulate_download = simulateDownload;
1682
+ options.pull_on_start = pullOnStart;
1683
+ return {
1684
+ engine: ($("engine-name") || {}).value?.trim() || "fish-speech",
1685
+ action,
1686
+ runtime: ($("engine-runtime") || {}).value || "native",
1687
+ api_url: ($("engine-api-url") || {}).value?.trim() || "http://127.0.0.1:8080",
1688
+ install_dir: installDir,
1689
+ options,
1690
+ };
1691
+ }
1692
+
1693
+ function subscribeTask(taskId) {
1694
+ if (!taskId) return;
1695
+ if (state.eventSource) {
1696
+ state.eventSource.close();
1697
+ }
1698
+ if ($("task-id-input")) $("task-id-input").value = taskId;
1699
+ const es = new EventSource(`/v1/engines/tasks/${taskId}/events`);
1700
+ state.eventSource = es;
1701
+
1702
+ es.addEventListener("snapshot", (ev) => {
1703
+ const task = JSON.parse(ev.data);
1704
+ ($("engine-state")||{}).textContent = task.status;
1705
+ setOutput(`${task.action} ${task.phase} (${task.status}) :: ${task.message}`);
1706
+ refreshTasksOnly();
1707
+ });
1708
+
1709
+ es.addEventListener("progress", (ev) => {
1710
+ const p = JSON.parse(ev.data);
1711
+ const percent = p.progress == null ? "--" : `${Number(p.progress).toFixed(1)}%`;
1712
+ const eta = p.eta_seconds == null ? "--:--" : formatEta(p.eta_seconds);
1713
+ setOutput(`${p.action} ${p.phase} ${percent} ETA ${eta} :: ${p.message}`);
1714
+ });
1715
+
1716
+ es.addEventListener("done", (ev) => {
1717
+ const task = JSON.parse(ev.data);
1718
+ setOutput(`DONE ${task.status}: ${task.message}`);
1719
+ ($("engine-state")||{}).textContent = task.status;
1720
+ es.close();
1721
+ state.eventSource = null;
1722
+ refreshTasksOnly();
1723
+ });
1724
+
1725
+ es.onerror = () => {
1726
+ setOutput("SSE disconnected.");
1727
+ es.close();
1728
+ if (state.eventSource === es) state.eventSource = null;
1729
+ };
1730
+ }
1731
+
1732
+ async function triggerEngineAction(action) {
1733
+ try {
1734
+ const data = await fetchJson("/v1/engines/actions", {
1735
+ method: "POST",
1736
+ headers: { "Content-Type": "application/json" },
1737
+ body: JSON.stringify(enginePayload(action)),
1738
+ });
1739
+ const task = data.task;
1740
+ setOutput(`Task created: ${task.task_id} (${action})`);
1741
+ subscribeTask(task.task_id);
1742
+ } catch (err) {
1743
+ setOutput(`Action failed: ${err.message}`);
1744
+ }
1745
+ }
1746
+
1747
+ // Quick engine action from dashboard (uses provider alias to guess engine name)
1748
+ window.engineAction = async function(providerAlias, action) {
1749
+ const engineMap = {
1750
+ "tts": "fish-speech", "stt_fw": "faster-whisper", "stt_whisper": "whisper",
1751
+ "stt_wlk": "whisperlivekit", "stt_sherpa": "sherpa-onnx",
1752
+ };
1753
+ const engine = engineMap[providerAlias] || providerAlias;
1754
+ addLog(engine, action, 'running', `${action}ing...`);
1755
+ try {
1756
+ const data = await fetchJson("/v1/engines/actions", {
1757
+ method: "POST",
1758
+ headers: { "Content-Type": "application/json" },
1759
+ body: JSON.stringify({
1760
+ engine: engine, action: action, runtime: "native",
1761
+ api_url: "http://127.0.0.1:8080", install_dir: "~/AI/services",
1762
+ }),
1763
+ });
1764
+ addLog(engine, action, 'ok', `Task: ${data.task.task_id}`);
1765
+ setTimeout(() => { refreshDashboard(); loadCatalog(); }, 3000);
1766
+ } catch (err) {
1767
+ addLog(engine, action, 'failed', err.message);
1768
+ }
1769
+ };
1770
+
1771
+ async function refreshTasksOnly() {
1772
+ try {
1773
+ const tasksData = await fetchJson("/v1/engines/tasks?limit=20");
1774
+ renderTasks(tasksData.tasks || []);
1775
+ } catch (err) {
1776
+ setOutput(`Task refresh failed: ${err.message}`);
1777
+ }
1778
+ }
1779
+
1780
+ async function fetchEngineStatus() {
1781
+ try {
1782
+ const params = new URLSearchParams({
1783
+ engine: $("engine-name").value.trim() || "fish-speech",
1784
+ runtime: $("engine-runtime").value,
1785
+ api_url: $("engine-api-url").value.trim() || "http://127.0.0.1:8080",
1786
+ install_dir: $("engine-install-dir").value.trim() || "~/AI/services",
1787
+ });
1788
+ const data = await fetchJson(`/v1/engines/status?${params.toString()}`);
1789
+ ($("engine-state")||{}).textContent = data.running ? (data.healthy ? "healthy" : "degraded") : "stopped";
1790
+ setOutput(`Status: running=${data.running}, healthy=${data.healthy}, detail=${data.detail}`);
1791
+ } catch (err) {
1792
+ setOutput(`Status failed: ${err.message}`);
1793
+ }
1794
+ }
1795
+
1796
+ async function fetchEngineLogs() {
1797
+ try {
1798
+ const params = new URLSearchParams({
1799
+ engine: $("engine-name").value.trim() || "fish-speech",
1800
+ runtime: $("engine-runtime").value,
1801
+ api_url: $("engine-api-url").value.trim() || "http://127.0.0.1:8080",
1802
+ install_dir: $("engine-install-dir").value.trim() || "~/AI/services",
1803
+ lines: "120",
1804
+ });
1805
+ const data = await fetchJson(`/v1/engines/logs?${params.toString()}`);
1806
+ setOutput(`Logs:\n${data.logs || "(empty)"}`);
1807
+ } catch (err) {
1808
+ setOutput(`Logs failed: ${err.message}`);
1809
+ }
1810
+ }
1811
+
1812
+ async function followTaskFromInput() {
1813
+ const taskId = $("task-id-input").value.trim();
1814
+ if (!taskId) return;
1815
+ subscribeTask(taskId);
1816
+ }
1817
+
1818
+ async function cancelTaskFromInput() {
1819
+ const taskId = $("task-id-input").value.trim();
1820
+ if (!taskId) return;
1821
+ try {
1822
+ const data = await fetchJson(`/v1/engines/tasks/${taskId}/cancel`, { method: "POST" });
1823
+ setOutput(`Cancel requested: ${data.task.task_id}`);
1824
+ subscribeTask(taskId);
1825
+ } catch (err) {
1826
+ setOutput(`Cancel failed: ${err.message}`);
1827
+ }
1828
+ }
1829
+
1830
+ function bindEvents() {
1831
+ $("refresh-all").addEventListener("click", () => {
1832
+ refreshDashboard().catch((err) => setOutput(`Refresh failed: ${err.message}`));
1833
+ });
1834
+
1835
+ $("stt-form").addEventListener("submit", runSTT);
1836
+ $("stt-mode").addEventListener("change", updateSTTModeUI);
1837
+ $("stt-live-start").addEventListener("click", (e) => {
1838
+ e.preventDefault();
1839
+ startLiveSTT().catch((err) => {
1840
+ setSTTLiveStatus("error");
1841
+ setText("stt-result", `Error: ${err.message}`);
1842
+ });
1843
+ });
1844
+ $("stt-live-stop").addEventListener("click", (e) => {
1845
+ e.preventDefault();
1846
+ stopLiveSTT().catch((err) => {
1847
+ setSTTLiveStatus("error");
1848
+ setText("stt-result", `Error: ${err.message}`);
1849
+ });
1850
+ });
1851
+ $("stt-provider").addEventListener("change", () => {
1852
+ loadSTTFieldOptions($("stt-provider").value);
1853
+ });
1854
+ $("tts-provider").addEventListener("change", () => {
1855
+ loadTTSVoices($("tts-provider").value);
1856
+ loadTTSFieldOptions($("tts-provider").value);
1857
+ });
1858
+ $("tts-model").addEventListener("change", () => {
1859
+ maybeAutoAdjustTTSTransportByModel();
1860
+ });
1861
+ $("tts-form").addEventListener("submit", runTTS);
1862
+
1863
+ document.querySelectorAll("button[data-action]").forEach((btn) => {
1864
+ btn.addEventListener("click", (e) => {
1865
+ e.preventDefault();
1866
+ const action = btn.dataset.action;
1867
+ triggerEngineAction(action);
1868
+ });
1869
+ });
1870
+
1871
+ if ($("engine-status-btn")) $("engine-status-btn").addEventListener("click", (e) => {
1872
+ e.preventDefault();
1873
+ fetchEngineStatus();
1874
+ });
1875
+ if ($("engine-logs-btn")) $("engine-logs-btn").addEventListener("click", (e) => {
1876
+ e.preventDefault();
1877
+ fetchEngineLogs();
1878
+ });
1879
+
1880
+ if ($("task-follow-btn")) $("task-follow-btn").addEventListener("click", (e) => {
1881
+ e.preventDefault();
1882
+ followTaskFromInput();
1883
+ });
1884
+ if ($("task-cancel-btn")) $("task-cancel-btn").addEventListener("click", (e) => {
1885
+ e.preventDefault();
1886
+ cancelTaskFromInput();
1887
+ });
1888
+ }
1889
+
1890
+ function formatEta(sec) {
1891
+ const v = Math.max(0, Number(sec || 0));
1892
+ const h = Math.floor(v / 3600);
1893
+ const m = Math.floor((v % 3600) / 60);
1894
+ const s = Math.floor(v % 60);
1895
+ if (h > 0) return `${String(h).padStart(2, "0")}:${String(m).padStart(2, "0")}:${String(s).padStart(2, "0")}`;
1896
+ return `${String(m).padStart(2, "0")}:${String(s).padStart(2, "0")}`;
1897
+ }
1898
+
1899
+ // --- Engine Catalog ---
1900
+ async function loadCatalog() {
1901
+ try {
1902
+ const [catalogData, healthData] = await Promise.all([
1903
+ fetchJson('/v1/engine-catalog'),
1904
+ fetchJson('/v1/health'),
1905
+ ]);
1906
+ renderCatalog(catalogData.engines || [], healthData.engines || {});
1907
+ } catch (e) {
1908
+ const el = document.getElementById('catalog-list');
1909
+ if (el) el.innerHTML = '<p>Failed to load catalog: ' + e.message + '</p>';
1910
+ }
1911
+ }
1912
+
1913
+ function renderCatalog(engines, healthMap) {
1914
+ const container = document.getElementById('catalog-list');
1915
+ container.innerHTML = '';
1916
+
1917
+ // Batch action toolbar
1918
+ const toolbar = document.createElement('div');
1919
+ toolbar.className = 'catalog-toolbar';
1920
+ toolbar.innerHTML =
1921
+ '<label class="catalog-select-all"><input type="checkbox" id="catalog-select-all-cb"> Select all compatible</label>' +
1922
+ '<button class="btn btn-xs btn-install" id="catalog-install-selected" disabled>Install Selected</button>' +
1923
+ '<button class="btn btn-xs btn-install" id="catalog-install-all">Install All Compatible</button>';
1924
+ container.appendChild(toolbar);
1925
+
1926
+ const groups = ENGINE_GROUPS;
1927
+
1928
+ for (const g of groups) {
1929
+ const items = engines.filter(g.filter);
1930
+ if (items.length === 0) continue;
1931
+
1932
+ const section = document.createElement('div');
1933
+ section.className = 'catalog-group';
1934
+ section.innerHTML = '<h3 class="provider-group-title">' + g.label + '</h3>';
1935
+
1936
+ for (const e of items) {
1937
+ // Determine health from actual health API (by alias)
1938
+ const healthy = e.installed && healthMap[e.default_alias];
1939
+ const isLocal = e.category === 'local' || e.category === 'native';
1940
+ const compatible = e.compatible !== false;
1941
+
1942
+ let statusClass, statusLabel;
1943
+ if (!compatible) {
1944
+ statusClass = 'dot-gray';
1945
+ statusLabel = 'Not compatible';
1946
+ } else if (!e.installed) {
1947
+ statusClass = 'dot-gray';
1948
+ statusLabel = 'Not installed';
1949
+ } else if (healthy) {
1950
+ statusClass = 'dot-green';
1951
+ statusLabel = 'Healthy';
1952
+ } else {
1953
+ statusClass = 'dot-yellow';
1954
+ statusLabel = 'Installed (not configured)';
1955
+ }
1956
+
1957
+ // Build action buttons based on state
1958
+ let actions = '';
1959
+ if (!compatible) {
1960
+ actions = '<span class="badge-incompatible">Not compatible</span>';
1961
+ } else if (!e.installed) {
1962
+ actions = '<button class="btn btn-xs btn-install" onclick="catalogInstall(\'' + e.name + '\')">Install</button>';
1963
+ } else {
1964
+ if (isLocal) {
1965
+ actions += '<button class="btn btn-xs" onclick="engineAction(\'' + e.default_alias + '\',\'start\')">Start</button>';
1966
+ actions += '<button class="btn btn-xs" onclick="engineAction(\'' + e.default_alias + '\',\'stop\')">Stop</button>';
1967
+ }
1968
+ actions += '<button class="btn btn-xs btn-danger" onclick="catalogUninstall(\'' + e.name + '\')">Uninstall</button>';
1969
+ }
1970
+
1971
+ // Checkbox for batch select (only for compatible, not-installed engines)
1972
+ const canSelect = compatible && !e.installed;
1973
+ const checkbox = canSelect
1974
+ ? '<input type="checkbox" class="catalog-cb" data-engine="' + e.name + '">'
1975
+ : '<input type="checkbox" class="catalog-cb" disabled>';
1976
+
1977
+ const row = document.createElement('div');
1978
+ row.className = 'provider-row' + (!compatible ? ' incompatible' : '');
1979
+ const displayName = e.display_name || e.name;
1980
+ const nameLink = e.installed
1981
+ ? '<a href="#" class="engine-name-link" data-alias="' + e.default_alias + '">' + displayName + '</a>'
1982
+ : displayName;
1983
+ row.innerHTML =
1984
+ '<div class="provider-info">' +
1985
+ checkbox +
1986
+ '<span class="dot ' + statusClass + '" title="' + statusLabel + '"></span>' +
1987
+ '<strong>' + nameLink + '</strong>' +
1988
+ '<span class="provider-engine">' + e.name + '</span>' +
1989
+ '<span class="provider-detail">' + e.description + '</span>' +
1990
+ '</div>' +
1991
+ '<div class="provider-actions">' + actions + '</div>';
1992
+ // Click engine name → jump to Config
1993
+ const link = row.querySelector('.engine-name-link');
1994
+ if (link) {
1995
+ link.addEventListener('click', (ev) => {
1996
+ ev.preventDefault();
1997
+ navigateToConfig(link.dataset.alias);
1998
+ });
1999
+ }
2000
+ section.appendChild(row);
2001
+ }
2002
+ container.appendChild(section);
2003
+ }
2004
+
2005
+ // Wire up batch controls
2006
+ const selectAllCb = document.getElementById('catalog-select-all-cb');
2007
+ const installSelectedBtn = document.getElementById('catalog-install-selected');
2008
+ const installAllBtn = document.getElementById('catalog-install-all');
2009
+
2010
+ function updateInstallSelectedBtn() {
2011
+ const checked = container.querySelectorAll('.catalog-cb:checked');
2012
+ installSelectedBtn.disabled = checked.length === 0;
2013
+ installSelectedBtn.textContent = checked.length > 0
2014
+ ? 'Install Selected (' + checked.length + ')'
2015
+ : 'Install Selected';
2016
+ }
2017
+
2018
+ container.addEventListener('change', function(ev) {
2019
+ if (ev.target.classList.contains('catalog-cb')) {
2020
+ updateInstallSelectedBtn();
2021
+ // Update select-all state
2022
+ const allCbs = container.querySelectorAll('.catalog-cb:not(:disabled)');
2023
+ const checkedCbs = container.querySelectorAll('.catalog-cb:checked');
2024
+ selectAllCb.checked = allCbs.length > 0 && allCbs.length === checkedCbs.length;
2025
+ selectAllCb.indeterminate = checkedCbs.length > 0 && checkedCbs.length < allCbs.length;
2026
+ }
2027
+ });
2028
+
2029
+ if (selectAllCb) {
2030
+ selectAllCb.addEventListener('change', function() {
2031
+ const cbs = container.querySelectorAll('.catalog-cb:not(:disabled)');
2032
+ cbs.forEach(cb => { cb.checked = selectAllCb.checked; });
2033
+ updateInstallSelectedBtn();
2034
+ });
2035
+ }
2036
+
2037
+ if (installSelectedBtn) {
2038
+ installSelectedBtn.addEventListener('click', async function() {
2039
+ const checked = container.querySelectorAll('.catalog-cb:checked');
2040
+ const names = Array.from(checked).map(cb => cb.dataset.engine);
2041
+ if (names.length === 0) return;
2042
+ await catalogBatchInstall(names);
2043
+ });
2044
+ }
2045
+
2046
+ if (installAllBtn) {
2047
+ installAllBtn.addEventListener('click', async function() {
2048
+ if (!confirm('Install all compatible engines?')) return;
2049
+ await catalogBatchInstall(['all']);
2050
+ });
2051
+ }
2052
+ }
2053
+
2054
+ window.catalogInstall = async function(name) {
2055
+ try {
2056
+ addLog(name, 'install', 'running', 'Installing...');
2057
+ const data = await fetchJson('/v1/engine-catalog/' + name + '/install', { method: 'POST' });
2058
+ addLog(name, 'install', 'ok', 'Installed as ' + (data.alias || name));
2059
+ await loadCatalog();
2060
+ await refreshDashboard();
2061
+ } catch (e) {
2062
+ addLog(name, 'install', 'failed', e.message);
2063
+ alert('Install failed: ' + e.message);
2064
+ }
2065
+ };
2066
+
2067
+ window.catalogUninstall = async function(name) {
2068
+ if (!confirm('Uninstall ' + name + '? This will remove it from the config.')) return;
2069
+ try {
2070
+ addLog(name, 'uninstall', 'running', 'Uninstalling...');
2071
+ const data = await fetchJson('/v1/engine-catalog/' + name + '/uninstall', { method: 'POST' });
2072
+ addLog(name, 'uninstall', 'ok', 'Removed: ' + (data.removed || []).join(', '));
2073
+ await loadCatalog();
2074
+ await refreshDashboard();
2075
+ } catch (e) {
2076
+ addLog(name, 'uninstall', 'failed', e.message);
2077
+ alert('Uninstall failed: ' + e.message);
2078
+ }
2079
+ };
2080
+
2081
+ window.catalogBatchInstall = async function(names) {
2082
+ try {
2083
+ addLog('batch', 'install', 'running', 'Installing ' + (names[0] === 'all' ? 'all compatible engines' : names.length + ' engines') + '...');
2084
+ const data = await fetchJson('/v1/engine-catalog/batch-install', {
2085
+ method: 'POST',
2086
+ headers: { 'Content-Type': 'application/json' },
2087
+ body: JSON.stringify({ engines: names }),
2088
+ });
2089
+ const installed = (data.results || []).map(r => r.alias).join(', ');
2090
+ addLog('batch', 'install', 'ok', 'Installed: ' + (installed || 'none'));
2091
+ await loadCatalog();
2092
+ await refreshDashboard();
2093
+ } catch (e) {
2094
+ addLog('batch', 'install', 'failed', e.message);
2095
+ alert('Batch install failed: ' + e.message);
2096
+ }
2097
+ };
2098
+
2099
+ // Navigate from Engines → Config tab with alias selected
2100
+ function navigateToConfig(alias) {
2101
+ // Switch to config tab
2102
+ document.querySelectorAll('.tab-btn').forEach(b => b.classList.remove('active'));
2103
+ document.querySelectorAll('.tab-content').forEach(c => c.classList.remove('active'));
2104
+ document.querySelector('.tab-btn[data-tab="config"]').classList.add('active');
2105
+ document.getElementById('tab-config').classList.add('active');
2106
+ // Load config and select the alias
2107
+ selectedConfigAlias = alias;
2108
+ loadConfig();
2109
+ }
2110
+
2111
+ // --- Logs ---
2112
+ const activityLogs = [];
2113
+
2114
+ function addLog(engine, action, status, message) {
2115
+ activityLogs.unshift({
2116
+ time: new Date().toLocaleTimeString(),
2117
+ engine, action, status, message,
2118
+ });
2119
+ if (activityLogs.length > 200) activityLogs.length = 200;
2120
+ renderLogs();
2121
+ }
2122
+
2123
+ function renderLogs() {
2124
+ const tbody = document.getElementById('logs-table');
2125
+ if (!tbody) return;
2126
+ tbody.innerHTML = '';
2127
+ for (const log of activityLogs) {
2128
+ const statusCls = log.status === 'ok' ? 'color:var(--accent)' :
2129
+ log.status === 'failed' ? 'color:var(--danger)' : '';
2130
+ const tr = document.createElement('tr');
2131
+ tr.innerHTML = '<td>' + log.time + '</td><td>' + log.engine + '</td><td>' + log.action +
2132
+ '</td><td style="' + statusCls + '">' + log.status + '</td><td>' + log.message + '</td>';
2133
+ tbody.appendChild(tr);
2134
+ }
2135
+ }
2136
+
2137
+ function loadLogs() {
2138
+ renderLogs();
2139
+ // Also load engine tasks
2140
+ fetchJson('/v1/engines/tasks?limit=50').then(data => {
2141
+ const detail = document.getElementById('logs-detail');
2142
+ if (detail && data.tasks && data.tasks.length) {
2143
+ detail.textContent = data.tasks.map(t =>
2144
+ `[${t.status}] ${t.engine || ''} ${t.action} — ${t.message || ''}`
2145
+ ).join('\n');
2146
+ }
2147
+ }).catch(() => {});
2148
+ }
2149
+
2150
+ // --- Config page (split layout) ---
2151
+ let configData = {};
2152
+ let configProvidersData = {}; // Shared credential providers
2153
+ let configHealthData = {};
2154
+ let providerTemplates = [];
2155
+ let vendorTemplates = {};
2156
+ let selectedConfigAlias = null;
2157
+ let selectedProviderName = null; // Currently selected provider in sidebar
2158
+
2159
+ async function loadConfig() {
2160
+ try {
2161
+ const [cfgRes, tplRes, healthRes, vendorRes, provRes] = await Promise.all([
2162
+ fetch('/v1/admin/config'),
2163
+ fetch('/v1/admin/provider-templates'),
2164
+ fetch('/v1/health'),
2165
+ fetch('/v1/admin/vendor-templates'),
2166
+ fetch('/v1/admin/providers'),
2167
+ ]);
2168
+ const cfgJson = await cfgRes.json();
2169
+ configData = cfgJson.engines || {};
2170
+ configProvidersData = cfgJson.providers || {};
2171
+ providerTemplates = (await tplRes.json()).templates || [];
2172
+ configHealthData = (await healthRes.json()).engines || {};
2173
+ vendorTemplates = (await vendorRes.json()).vendors || {};
2174
+ // Merge providers from dedicated endpoint (may have more detail)
2175
+ const provJson = await provRes.json();
2176
+ if (provJson.providers) {
2177
+ configProvidersData = { ...configProvidersData, ...provJson.providers };
2178
+ }
2179
+ renderConfigSidebar();
2180
+ if (selectedProviderName && configProvidersData[selectedProviderName]) {
2181
+ renderProviderDetail(selectedProviderName);
2182
+ } else if (selectedConfigAlias && configData[selectedConfigAlias]) {
2183
+ renderConfigDetail(selectedConfigAlias);
2184
+ } else {
2185
+ document.getElementById('config-detail-content').innerHTML =
2186
+ '<p class="config-placeholder">Select a vendor or engine from the left to edit its settings.</p>';
2187
+ }
2188
+ } catch (e) {
2189
+ showConfigStatus('Failed to load config: ' + e.message, true);
2190
+ }
2191
+ }
2192
+
2193
+ function configGroupLabel(alias, cfg) {
2194
+ const ptype = cfg.type || 'unknown';
2195
+ const isCloud = cfg.category === 'cloud';
2196
+ if (ptype === 'stt') return isCloud ? 'STT — Cloud' : 'STT — Local / Native';
2197
+ if (ptype === 'tts') return isCloud ? 'TTS — Cloud' : 'TTS — Local / Native';
2198
+ return 'Other';
2199
+ }
2200
+
2201
+ function renderConfigSidebar() {
2202
+ const sidebar = document.getElementById('config-sidebar-list');
2203
+ sidebar.innerHTML = '';
2204
+
2205
+ // --- Providers (shared credentials) section ---
2206
+ const providerNames = Object.keys(configProvidersData);
2207
+ if (providerNames.length > 0) {
2208
+ const provSection = document.createElement('div');
2209
+ provSection.className = 'config-sidebar-group';
2210
+ provSection.innerHTML = '<h4>Vendors</h4>';
2211
+ for (const name of providerNames) {
2212
+ const vendorTpl = vendorTemplates[name] || {};
2213
+ const displayLabel = vendorTpl.display_name || name;
2214
+ // Count engines using this provider
2215
+ const usedBy = Object.entries(configData)
2216
+ .filter(([_, cfg]) => cfg.provider === name)
2217
+ .map(([alias]) => alias);
2218
+ const item = document.createElement('div');
2219
+ item.className = 'config-sidebar-item' + (name === selectedProviderName ? ' active' : '');
2220
+ item.innerHTML = `<span class="dot dot-blue"></span>${displayLabel}` +
2221
+ (usedBy.length ? ` <span class="provider-count">(${usedBy.length})</span>` : '');
2222
+ item.addEventListener('click', () => {
2223
+ selectedProviderName = name;
2224
+ selectedConfigAlias = null;
2225
+ renderConfigSidebar();
2226
+ renderProviderDetail(name);
2227
+ });
2228
+ provSection.appendChild(item);
2229
+ }
2230
+ sidebar.appendChild(provSection);
2231
+ }
2232
+
2233
+ // --- Engine groups ---
2234
+ const groups = {};
2235
+ const groupOrder = ['STT — Cloud', 'STT — Local / Native', 'TTS — Cloud', 'TTS — Local / Native', 'Other'];
2236
+ for (const [alias, cfg] of Object.entries(configData)) {
2237
+ const g = configGroupLabel(alias, cfg);
2238
+ if (!groups[g]) groups[g] = [];
2239
+ groups[g].push({ alias, cfg });
2240
+ }
2241
+ for (const gName of groupOrder) {
2242
+ const items = groups[gName] || [];
2243
+ const section = document.createElement('div');
2244
+ section.className = 'config-sidebar-group';
2245
+ section.innerHTML = `<h4>${gName}</h4>`;
2246
+ for (const { alias, cfg } of items) {
2247
+ const healthy = configHealthData[alias] || false;
2248
+ const displayLabel = cfg.display_name || alias;
2249
+ const item = document.createElement('div');
2250
+ item.className = 'config-sidebar-item' + (alias === selectedConfigAlias ? ' active' : '');
2251
+ item.innerHTML = `<span class="dot ${healthy ? 'dot-green' : 'dot-red'}"></span>${displayLabel}`;
2252
+ item.addEventListener('click', () => {
2253
+ selectedConfigAlias = alias;
2254
+ selectedProviderName = null;
2255
+ renderConfigSidebar();
2256
+ renderConfigDetail(alias);
2257
+ });
2258
+ section.appendChild(item);
2259
+ }
2260
+ sidebar.appendChild(section);
2261
+ }
2262
+ }
2263
+
2264
+ function renderProviderDetail(name) {
2265
+ const creds = configProvidersData[name] || {};
2266
+ const vendorTpl = vendorTemplates[name] || {};
2267
+ const displayLabel = vendorTpl.display_name || name;
2268
+ const sharedFields = vendorTpl.shared_fields || {};
2269
+
2270
+ // Find engines using this provider
2271
+ const usedBy = Object.entries(configData)
2272
+ .filter(([_, cfg]) => cfg.provider === name)
2273
+ .map(([alias]) => alias);
2274
+
2275
+ const detail = document.getElementById('config-detail-content');
2276
+ let fieldsHtml = '';
2277
+
2278
+ // Render fields from vendor template
2279
+ const allKeys = new Set([...Object.keys(sharedFields), ...Object.keys(creds)]);
2280
+ for (const k of allKeys) {
2281
+ const fieldDef = sharedFields[k] || {};
2282
+ const isSecret = fieldDef.secret || k.includes('key') || k.includes('secret') || k.includes('token');
2283
+ const val = creds[k] !== undefined ? creds[k] : (fieldDef.default || '');
2284
+ const desc = fieldDef.description || k;
2285
+ const required = fieldDef.required ? ' *' : '';
2286
+ fieldsHtml += `<label>${desc}${required}
2287
+ <input data-provider="${name}" data-field="${k}" value="${val}"
2288
+ type="${isSecret ? 'password' : 'text'}"
2289
+ placeholder="${fieldDef.default || ''}"
2290
+ autocomplete="off" />
2291
+ </label>`;
2292
+ }
2293
+
2294
+ detail.innerHTML = `
2295
+ <div class="config-detail-head">
2296
+ <h3>${displayLabel} <span class="badge provider">Vendor</span></h3>
2297
+ <button class="btn btn-danger btn-xs" onclick="removeConfigProvider('${name}')">Remove</button>
2298
+ </div>
2299
+ <div class="config-fields">
2300
+ ${fieldsHtml}
2301
+ </div>
2302
+ ${usedBy.length ? `<div class="provider-used-by"><strong>Used by:</strong> ${usedBy.join(', ')}</div>` : ''}
2303
+ <div style="margin-top:12px">
2304
+ <button class="btn" onclick="saveProviderCredentials('${name}')">Save Vendor</button>
2305
+ </div>
2306
+ `;
2307
+ }
2308
+
2309
+ window.saveProviderCredentials = async function(name) {
2310
+ const fields = document.querySelectorAll(`[data-provider="${name}"][data-field]`);
2311
+ const creds = {};
2312
+ fields.forEach(el => {
2313
+ const val = el.value;
2314
+ // Preserve masked values
2315
+ if (val.includes('****')) {
2316
+ const oldVal = configProvidersData[name] ? configProvidersData[name][el.dataset.field] : val;
2317
+ creds[el.dataset.field] = oldVal || val;
2318
+ } else {
2319
+ creds[el.dataset.field] = val;
2320
+ }
2321
+ });
2322
+
2323
+ try {
2324
+ // Get raw config to resolve masked values
2325
+ const rawRes = await fetch('/v1/admin/config/raw');
2326
+ const rawConfig = await rawRes.json();
2327
+ const rawProviders = rawConfig.providers || {};
2328
+ const rawCreds = rawProviders[name] || {};
2329
+
2330
+ // Replace masked values with raw ones
2331
+ for (const [k, v] of Object.entries(creds)) {
2332
+ if (typeof v === 'string' && v.includes('****') && rawCreds[k]) {
2333
+ creds[k] = rawCreds[k];
2334
+ }
2335
+ }
2336
+
2337
+ // Update providers section
2338
+ const allProviders = { ...rawProviders, [name]: creds };
2339
+ const res = await fetch('/v1/admin/providers', {
2340
+ method: 'PUT',
2341
+ headers: { 'Content-Type': 'application/json' },
2342
+ body: JSON.stringify({ providers: allProviders }),
2343
+ });
2344
+ const data = await res.json();
2345
+ if (res.ok) {
2346
+ showConfigStatus('Vendor saved!', false);
2347
+ await loadConfig();
2348
+ } else {
2349
+ showConfigStatus('Save failed: ' + (data.detail || 'unknown error'), true);
2350
+ }
2351
+ } catch (e) {
2352
+ showConfigStatus('Save error: ' + e.message, true);
2353
+ }
2354
+ };
2355
+
2356
+ window.removeConfigProvider = function(name) {
2357
+ delete configProvidersData[name];
2358
+ if (selectedProviderName === name) {
2359
+ selectedProviderName = null;
2360
+ document.getElementById('config-detail-content').innerHTML =
2361
+ '<p class="config-placeholder">Vendor removed. Save & Apply to persist.</p>';
2362
+ }
2363
+ renderConfigSidebar();
2364
+ };
2365
+
2366
+ function renderSettingField(alias, k, v, fieldOptions) {
2367
+ const rawKey = k.replace(/^default_/, '');
2368
+ const label = 'Default ' + rawKey.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase());
2369
+ const opts = fieldOptions[k];
2370
+ if (opts && opts.length > 0) {
2371
+ // Render as select with auto option + provider options
2372
+ const currentVal = String(v);
2373
+ const autoSelected = !currentVal ? 'selected' : '';
2374
+ const hasCustom = currentVal && !opts.map(String).includes(currentVal);
2375
+ return `<label>${label}
2376
+ <select data-alias="${alias}" data-setting="${k}">
2377
+ <option value="" ${autoSelected}>(auto)</option>
2378
+ ${hasCustom ? `<option value="${currentVal}" selected>${currentVal} (custom)</option>` : ''}
2379
+ ${opts.map(o => `<option value="${o}" ${String(o) === currentVal ? 'selected' : ''}>${o}</option>`).join('')}
2380
+ </select>
2381
+ </label>`;
2382
+ }
2383
+ // Render as input
2384
+ const isSecret = k.includes('key') || k.includes('secret') || k.includes('token');
2385
+ return `<label>${label}
2386
+ <input data-alias="${alias}" data-setting="${k}" value="${v}"
2387
+ type="${typeof v === 'number' ? 'number' : 'text'}"
2388
+ ${isSecret ? 'autocomplete="off"' : ''} />
2389
+ </label>`;
2390
+ }
2391
+
2392
+ function renderConfigDetail(alias) {
2393
+ const cfg = configData[alias];
2394
+ if (!cfg) return;
2395
+ const tpl = providerTemplates.find(t => t.provider === (cfg.provider_key || cfg.provider));
2396
+ const ptype = cfg.type || (tpl ? tpl.type : 'unknown');
2397
+ const displayLabel = cfg.display_name || (tpl && tpl.display_name) || alias;
2398
+ const fieldOptions = (tpl && tpl.field_options) ? tpl.field_options : {};
2399
+ const execModes = cfg.category === 'cloud'
2400
+ ? ['remote']
2401
+ : ['in_process', 'local', 'subprocess'];
2402
+ const detail = document.getElementById('config-detail-content');
2403
+ detail.innerHTML = `
2404
+ <div class="config-detail-head">
2405
+ <h3>${displayLabel} <span class="badge ${ptype}">${ptype} / ${alias}</span></h3>
2406
+ <button class="btn btn-danger btn-xs" onclick="removeProvider('${alias}')">Remove</button>
2407
+ </div>
2408
+ <div class="config-fields">
2409
+ <label>exec_mode
2410
+ <select data-alias="${alias}" data-key="exec_mode">
2411
+ ${execModes.map(m =>
2412
+ `<option value="${m}" ${cfg.exec_mode === m ? 'selected' : ''}>${m}</option>`
2413
+ ).join('')}
2414
+ </select>
2415
+ </label>
2416
+ ${Object.entries(cfg.settings || {}).map(([k, v]) =>
2417
+ renderSettingField(alias, k, v, fieldOptions)
2418
+ ).join('')}
2419
+ </div>
2420
+ `;
2421
+
2422
+ // For TTS engines: load voice list from API and upgrade default_voice to select
2423
+ if (ptype === 'tts') {
2424
+ _loadConfigVoiceOptions(alias, cfg);
2425
+ }
2426
+ }
2427
+
2428
+ async function _loadConfigVoiceOptions(alias, cfg) {
2429
+ const voiceKey = cfg.settings && 'default_voice' in cfg.settings ? 'default_voice'
2430
+ : cfg.settings && 'voice' in cfg.settings ? 'voice' : null;
2431
+ if (!voiceKey) return;
2432
+ // Check if already rendered as select (has field_options)
2433
+ const existing = document.querySelector(`select[data-alias="${alias}"][data-setting="${voiceKey}"]`);
2434
+ if (existing) return;
2435
+ try {
2436
+ const data = await fetchJson(`/v1/tts/${alias}/voices`);
2437
+ const voices = data.voices || [];
2438
+ if (voices.length === 0) return;
2439
+ const input = document.querySelector(`input[data-alias="${alias}"][data-setting="${voiceKey}"]`);
2440
+ if (!input) return;
2441
+ const currentVal = input.value;
2442
+ const label = input.closest('label');
2443
+ const select = document.createElement('select');
2444
+ select.dataset.alias = alias;
2445
+ select.dataset.setting = voiceKey;
2446
+ // Add (auto) option
2447
+ const autoOpt = document.createElement('option');
2448
+ autoOpt.value = '';
2449
+ autoOpt.textContent = '(auto)';
2450
+ if (!currentVal) autoOpt.selected = true;
2451
+ select.appendChild(autoOpt);
2452
+ // Group voices
2453
+ const groups = {};
2454
+ for (const v of voices) {
2455
+ const g = v.group || '';
2456
+ if (!groups[g]) groups[g] = [];
2457
+ groups[g].push(v);
2458
+ }
2459
+ for (const [gName, gVoices] of Object.entries(groups)) {
2460
+ const optgroup = document.createElement('optgroup');
2461
+ optgroup.label = gName || 'Default';
2462
+ for (const v of gVoices) {
2463
+ const opt = document.createElement('option');
2464
+ opt.value = v.name;
2465
+ opt.textContent = v.name;
2466
+ if (v.name === currentVal) opt.selected = true;
2467
+ optgroup.appendChild(opt);
2468
+ }
2469
+ select.appendChild(optgroup);
2470
+ }
2471
+ input.replaceWith(select);
2472
+ } catch (_) {}
2473
+ }
2474
+
2475
+ function collectConfigForAlias(alias) {
2476
+ const cfg = configData[alias];
2477
+ if (!cfg) return null;
2478
+ const entry = { provider: cfg.provider, exec_mode: cfg.exec_mode, settings: { ...cfg.settings } };
2479
+ const modeSelect = document.querySelector(`select[data-alias="${alias}"][data-key="exec_mode"]`);
2480
+ if (modeSelect) entry.exec_mode = modeSelect.value;
2481
+ // Collect from both <input> and <select> elements with data-setting
2482
+ document.querySelectorAll(`[data-alias="${alias}"][data-setting]`).forEach(el => {
2483
+ const key = el.dataset.setting;
2484
+ let val = el.value;
2485
+ if (typeof cfg.settings[key] === 'number') val = Number(val);
2486
+ if (typeof cfg.settings[key] === 'boolean') val = val === 'true';
2487
+ entry.settings[key] = val;
2488
+ });
2489
+ return entry;
2490
+ }
2491
+
2492
+ function collectConfig() {
2493
+ // Collect currently-editing alias from DOM, rest from configData
2494
+ const engines = {};
2495
+ for (const [alias, cfg] of Object.entries(configData)) {
2496
+ if (alias === selectedConfigAlias) {
2497
+ const edited = collectConfigForAlias(alias);
2498
+ if (edited) { engines[alias] = edited; continue; }
2499
+ }
2500
+ engines[alias] = { provider: cfg.provider, exec_mode: cfg.exec_mode, settings: { ...cfg.settings } };
2501
+ }
2502
+ return engines;
2503
+ }
2504
+
2505
+ window.removeProvider = function(alias) {
2506
+ delete configData[alias];
2507
+ if (selectedConfigAlias === alias) {
2508
+ selectedConfigAlias = null;
2509
+ document.getElementById('config-detail-content').innerHTML =
2510
+ '<p class="config-placeholder">Engine removed. Select another or Save & Apply.</p>';
2511
+ }
2512
+ renderConfigSidebar();
2513
+ };
2514
+
2515
+ function showConfigStatus(msg, isError) {
2516
+ const el = document.getElementById('config-status');
2517
+ el.style.display = 'block';
2518
+ el.textContent = msg;
2519
+ el.style.color = isError ? 'var(--danger)' : 'var(--accent)';
2520
+ setTimeout(() => { el.style.display = 'none'; }, 5000);
2521
+ }
2522
+
2523
+ window.closeAddDialog = function() {
2524
+ document.getElementById('config-add-dialog').classList.remove('visible');
2525
+ };
2526
+
2527
+ window.confirmAddProvider = function() {
2528
+ const vendorName = document.getElementById('config-add-template').value;
2529
+ if (!vendorName) { alert('Please select a vendor'); return; }
2530
+ if (configProvidersData[vendorName]) { alert('Vendor already configured'); return; }
2531
+ // Initialize empty credentials from vendor template
2532
+ const vendorTpl = vendorTemplates[vendorName] || {};
2533
+ const creds = {};
2534
+ for (const [k, def] of Object.entries(vendorTpl.shared_fields || {})) {
2535
+ creds[k] = def.default || '';
2536
+ }
2537
+ configProvidersData[vendorName] = creds;
2538
+ selectedProviderName = vendorName;
2539
+ selectedConfigAlias = null;
2540
+ renderConfigSidebar();
2541
+ renderProviderDetail(vendorName);
2542
+ closeAddDialog();
2543
+ };
2544
+
2545
+ function bindConfigEvents() {
2546
+ document.getElementById('config-save-btn').addEventListener('click', async () => {
2547
+ try {
2548
+ const rawRes = await fetch('/v1/admin/config/raw');
2549
+ const rawConfig = await rawRes.json();
2550
+ const rawEngines = rawConfig.engines || rawConfig.providers || {};
2551
+ const rawProviders = rawConfig.providers || {};
2552
+
2553
+ const updates = collectConfig();
2554
+ for (const [alias, entry] of Object.entries(updates)) {
2555
+ const rawEntry = rawEngines[alias];
2556
+ if (!rawEntry) continue;
2557
+ const rawSettings = rawEntry.settings || {};
2558
+ for (const [k, v] of Object.entries(entry.settings)) {
2559
+ if (typeof v === 'string' && v.includes('****')) {
2560
+ entry.settings[k] = rawSettings[k] || v;
2561
+ }
2562
+ }
2563
+ }
2564
+ const providersResolved = JSON.parse(JSON.stringify(configProvidersData || {}));
2565
+ for (const [pname, creds] of Object.entries(providersResolved)) {
2566
+ const rawCreds = rawProviders[pname] || {};
2567
+ if (!creds || typeof creds !== 'object') continue;
2568
+ for (const [k, v] of Object.entries(creds)) {
2569
+ if (typeof v === 'string' && v.includes('****')) {
2570
+ providersResolved[pname][k] = rawCreds[k] || v;
2571
+ }
2572
+ }
2573
+ }
2574
+
2575
+ const res = await fetch('/v1/admin/config', {
2576
+ method: 'PUT',
2577
+ headers: { 'Content-Type': 'application/json' },
2578
+ body: JSON.stringify({ engines: updates, providers: providersResolved }),
2579
+ });
2580
+ const data = await res.json();
2581
+ if (res.ok) {
2582
+ const r = data.reload || {};
2583
+ showConfigStatus(
2584
+ `Saved! Added: ${(r.added||[]).length}, Updated: ${(r.updated||[]).length}, Removed: ${(r.removed||[]).length}`,
2585
+ false
2586
+ );
2587
+ await loadConfig();
2588
+ } else {
2589
+ showConfigStatus('Save failed: ' + (data.detail || 'unknown error'), true);
2590
+ }
2591
+ } catch (e) {
2592
+ showConfigStatus('Save error: ' + e.message, true);
2593
+ }
2594
+ });
2595
+
2596
+ document.getElementById('config-add-btn').addEventListener('click', () => {
2597
+ const dialog = document.getElementById('config-add-dialog');
2598
+ const select = document.getElementById('config-add-template');
2599
+ // Show vendors not yet configured
2600
+ const availableVendors = Object.entries(vendorTemplates)
2601
+ .filter(([name]) => !configProvidersData[name]);
2602
+ if (availableVendors.length === 0) {
2603
+ alert('All vendors are already configured.');
2604
+ return;
2605
+ }
2606
+ select.innerHTML = availableVendors.map(([name, tpl]) =>
2607
+ `<option value="${name}">${tpl.display_name || name}</option>`
2608
+ ).join('');
2609
+ dialog.classList.add('visible');
2610
+ });
2611
+ }
2612
+
2613
+ async function init() {
2614
+ // --- Tab switching ---
2615
+ document.querySelectorAll('.tab-btn').forEach(btn => {
2616
+ btn.addEventListener('click', () => {
2617
+ document.querySelectorAll('.tab-btn').forEach(b => b.classList.remove('active'));
2618
+ document.querySelectorAll('.tab-content').forEach(c => c.classList.remove('active'));
2619
+ btn.classList.add('active');
2620
+ const tab = document.getElementById('tab-' + btn.dataset.tab);
2621
+ if (tab) tab.classList.add('active');
2622
+ if (btn.dataset.tab === 'config') loadConfig();
2623
+ if (btn.dataset.tab === 'engines') loadCatalog();
2624
+ if (btn.dataset.tab === 'dashboard') refreshDashboard();
2625
+ if (btn.dataset.tab === 'logs') loadLogs();
2626
+ });
2627
+ });
2628
+
2629
+ bindEvents();
2630
+ bindConfigEvents();
2631
+ const catalogRefreshBtn = document.getElementById('catalog-refresh');
2632
+ if (catalogRefreshBtn) catalogRefreshBtn.addEventListener('click', loadCatalog);
2633
+ const logsRefreshBtn = document.getElementById('logs-refresh');
2634
+ if (logsRefreshBtn) logsRefreshBtn.addEventListener('click', loadLogs);
2635
+ const logsClearBtn = document.getElementById('logs-clear');
2636
+ if (logsClearBtn) logsClearBtn.addEventListener('click', () => { activityLogs.length = 0; renderLogs(); $('logs-detail').textContent = ''; });
2637
+ const labLogClearBtn = document.getElementById('lab-log-clear');
2638
+ if (labLogClearBtn) labLogClearBtn.addEventListener('click', labLogClear);
2639
+ window.addEventListener("beforeunload", () => {
2640
+ if (state.sttLive) {
2641
+ stopLiveSTT().catch(() => {});
2642
+ }
2643
+ });
2644
+ updateSTTModeUI();
2645
+ await refreshDashboard();
2646
+ await fetchEngineStatus();
2647
+ }
2648
+
2649
+ init().catch((err) => setOutput(`Init failed: ${err.message}`));