muxmind-ai 2.1.1

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.
@@ -0,0 +1,1306 @@
1
+ 'use strict';
2
+
3
+ /* ==========================================================================
4
+ MUXMIND AI โ€” CLIENT APPLICATION LOGIC
5
+ ========================================================================== */
6
+
7
+ const STORAGE_KEY = 'muxmind_vault_v1';
8
+ const TOKEN_KEY = 'muxmind_session_token';
9
+ const THEME_KEY = 'muxmind_theme';
10
+ const HISTORY_KEY = 'muxmind_chat_history_v1';
11
+ const MAX_HISTORY_ITEMS = 200;
12
+
13
+ let PROVIDER_META = {}; // populated from /api/providers after login
14
+ let vault = loadVault(); // [{ id, providerId, apiKey, label, models:[{id,tier,power}], status }]
15
+ let conversation = [];
16
+ let attachedFiles = [];
17
+ let ttsState = 'mute';
18
+ let isStreaming = false;
19
+ let authToken = sessionStorage.getItem(TOKEN_KEY) || null;
20
+ let userScrolledUp = false;
21
+
22
+ let chatHistory = loadHistory(); // [{ id, title, updatedAt, messages: [] }]
23
+ let activeChatId = null;
24
+
25
+ const $ = (id) => document.getElementById(id);
26
+
27
+ function escapeHtml(str) {
28
+ const div = document.createElement('div');
29
+ div.textContent = str ?? '';
30
+ return div.innerHTML;
31
+ }
32
+
33
+ function showToast(message, kind = '') {
34
+ const toast = $('toast');
35
+ toast.textContent = message;
36
+ toast.className = `toast visible ${kind}`;
37
+ clearTimeout(showToast._t);
38
+ showToast._t = setTimeout(() => toast.classList.remove('visible'), 3200);
39
+ }
40
+
41
+ function injectLogo(hostEl, sizePx) {
42
+ if (!hostEl) return;
43
+ hostEl.innerHTML = '';
44
+ const img = document.createElement('img');
45
+ img.src = 'assets/muxmind-logo-icon.png';
46
+ img.alt = 'MuxMind AI';
47
+ img.style.width = '100%';
48
+ img.style.height = '100%';
49
+ img.style.objectFit = 'contain';
50
+ img.style.display = 'block';
51
+ hostEl.appendChild(img);
52
+ if (sizePx) { hostEl.style.width = sizePx + 'px'; hostEl.style.height = sizePx + 'px'; }
53
+ }
54
+
55
+ function authHeaders(extra = {}) {
56
+ return { ...extra, Authorization: `Bearer ${authToken}` };
57
+ }
58
+
59
+ async function apiFetch(url, options = {}) {
60
+ const res = await fetch(url, {
61
+ ...options,
62
+ headers: { ...(options.headers || {}), ...authHeaders(options.jsonBody ? { 'Content-Type': 'application/json' } : {}) },
63
+ body: options.jsonBody ? JSON.stringify(options.jsonBody) : options.body,
64
+ });
65
+ if (res.status === 401) {
66
+ logout('Session expired. Please log in again.');
67
+ throw new Error('Unauthorized');
68
+ }
69
+ return res;
70
+ }
71
+
72
+ // ==========================================================================
73
+ // THEME
74
+ // ==========================================================================
75
+
76
+ function applyTheme(theme) {
77
+ document.documentElement.setAttribute('data-theme', theme);
78
+ localStorage.setItem(THEME_KEY, theme);
79
+ const dark = $('theme-btn-dark'), light = $('theme-btn-light');
80
+ if (dark && light) {
81
+ dark.classList.toggle('active', theme === 'dark');
82
+ light.classList.toggle('active', theme === 'light');
83
+ }
84
+ }
85
+
86
+ (function initTheme() {
87
+ const saved = localStorage.getItem(THEME_KEY) ||
88
+ (window.matchMedia && window.matchMedia('(prefers-color-scheme: light)').matches ? 'light' : 'dark');
89
+ applyTheme(saved);
90
+ })();
91
+
92
+ // ==========================================================================
93
+ // LOGIN
94
+ // ==========================================================================
95
+
96
+ const loginScreen = $('login-screen');
97
+ const appShell = $('app-shell');
98
+ const loginForm = $('login-form');
99
+ const loginError = $('login-error');
100
+
101
+ injectLogo($('login-logo'));
102
+ injectLogo($('rail-logo'));
103
+
104
+ function showApp() {
105
+ loginScreen.style.display = 'none';
106
+ appShell.classList.remove('hidden');
107
+ bootstrapApp();
108
+ }
109
+
110
+ function showLogin(message) {
111
+ appShell.classList.add('hidden');
112
+ loginScreen.style.display = 'flex';
113
+ if (message) loginError.textContent = message;
114
+ }
115
+
116
+ function logout(message) {
117
+ authToken = null;
118
+ sessionStorage.removeItem(TOKEN_KEY);
119
+ showLogin(message || '');
120
+ }
121
+
122
+ loginForm.addEventListener('submit', async (e) => {
123
+ e.preventDefault();
124
+ loginError.textContent = '';
125
+ const password = $('login-password').value;
126
+ try {
127
+ const res = await fetch('/api/auth/login', {
128
+ method: 'POST',
129
+ headers: { 'Content-Type': 'application/json' },
130
+ body: JSON.stringify({ password }),
131
+ });
132
+ const data = await res.json();
133
+ if (!res.ok || !data.ok) {
134
+ loginError.textContent = data.error || 'Login failed.';
135
+ return;
136
+ }
137
+ authToken = data.token;
138
+ sessionStorage.setItem(TOKEN_KEY, authToken);
139
+ $('login-password').value = '';
140
+ showApp();
141
+ } catch (err) {
142
+ loginError.textContent = 'Could not reach the server.';
143
+ }
144
+ });
145
+
146
+ $('rail-logout').addEventListener('click', () => logout());
147
+ $('btn-settings-logout').addEventListener('click', () => logout());
148
+
149
+ // Try existing session token first (uses an authenticated call, not the
150
+ // public /api/ping, so a stale/expired token is actually detected).
151
+ if (authToken) {
152
+ apiFetch('/api/providers').then(() => showApp()).catch(() => showLogin());
153
+ } else {
154
+ showLogin();
155
+ }
156
+
157
+ // ==========================================================================
158
+ // APP BOOTSTRAP (runs once after successful login)
159
+ // ==========================================================================
160
+
161
+ let appBooted = false;
162
+
163
+ async function bootstrapApp() {
164
+ applyI18n(currentLang);
165
+
166
+ if (appBooted) return;
167
+ appBooted = true;
168
+
169
+ await loadProviderMeta();
170
+ populateVaultProviderSelect();
171
+ populateImageProviderSelect();
172
+ renderVaultEntries();
173
+ rebuildModelSelector();
174
+ renderProvidersPage();
175
+ renderNetworkMap();
176
+ setupRailNavigation();
177
+ setupChatUI();
178
+ setupVaultUI();
179
+ setupSettingsUI();
180
+ setupHistoryUI();
181
+ setupImageStudioUI();
182
+ setupFileViewer();
183
+ updateCompressionLabel();
184
+ renderHistoryList();
185
+
186
+ fetch('/api/ping').then((r) => r.json()).then((d) => console.log('[MuxMind AI] Connected to worker', d.pid));
187
+ }
188
+
189
+ window.onLangChanged = function onLangChanged() {
190
+ renderProvidersPage();
191
+ renderNetworkMap();
192
+ renderHistoryList();
193
+ populateImageProviderSelect();
194
+ };
195
+
196
+ async function loadProviderMeta() {
197
+ try {
198
+ const res = await apiFetch('/api/providers');
199
+ const data = await res.json();
200
+ if (data.ok) {
201
+ PROVIDER_META = {};
202
+ for (const p of data.providers) PROVIDER_META[p.id] = p;
203
+ }
204
+ } catch (err) {
205
+ console.error('Failed to load provider metadata', err);
206
+ }
207
+ }
208
+
209
+ // ==========================================================================
210
+ // RAIL NAVIGATION (sidebar pages)
211
+ // ==========================================================================
212
+
213
+ function setupRailNavigation() {
214
+ const railButtons = document.querySelectorAll('.rail-btn[data-page]');
215
+ railButtons.forEach((btn) => {
216
+ btn.addEventListener('click', () => {
217
+ railButtons.forEach((b) => b.classList.remove('active'));
218
+ btn.classList.add('active');
219
+ document.querySelectorAll('.page-view').forEach((p) => p.classList.remove('active'));
220
+ $(`page-${btn.dataset.page}`).classList.add('active');
221
+ if (btn.dataset.page === 'map') renderNetworkMap();
222
+ });
223
+ });
224
+ }
225
+
226
+ // ==========================================================================
227
+ // VAULT (LOCAL STORAGE) MANAGEMENT
228
+ // ==========================================================================
229
+
230
+ function loadVault() {
231
+ try { return JSON.parse(localStorage.getItem(STORAGE_KEY) || '[]'); } catch { return []; }
232
+ }
233
+ function persistVault() { localStorage.setItem(STORAGE_KEY, JSON.stringify(vault)); }
234
+ function maskKey(key) {
235
+ if (!key || key.length <= 8) return '****';
236
+ return `${key.slice(0, 6)}...${'*'.repeat(4)}`;
237
+ }
238
+
239
+ function populateVaultProviderSelect() {
240
+ const sel = $('vault-provider');
241
+ sel.innerHTML = '';
242
+ for (const id of Object.keys(PROVIDER_META)) {
243
+ const opt = document.createElement('option');
244
+ opt.value = id;
245
+ opt.textContent = PROVIDER_META[id].label;
246
+ sel.appendChild(opt);
247
+ }
248
+ }
249
+
250
+ function setupVaultUI() {
251
+ const vaultPanel = $('vault-panel');
252
+ const vaultOverlay = $('vault-overlay');
253
+
254
+ $('btn-api-manager').addEventListener('click', () => {
255
+ vaultPanel.classList.add('open'); vaultOverlay.classList.add('open');
256
+ });
257
+ $('btn-vault-close').addEventListener('click', closeVault);
258
+ vaultOverlay.addEventListener('click', closeVault);
259
+ function closeVault() { vaultPanel.classList.remove('open'); vaultOverlay.classList.remove('open'); }
260
+
261
+ $('vault-form').addEventListener('submit', async (e) => {
262
+ e.preventDefault();
263
+ const providerId = $('vault-provider').value;
264
+ const label = $('vault-label').value.trim() || (PROVIDER_META[providerId]?.label || providerId);
265
+ const apiKey = $('vault-key').value.trim();
266
+ if (!apiKey) return;
267
+
268
+ const entry = { id: `vault_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`, providerId, apiKey, label, status: 'checking', models: [] };
269
+ vault.push(entry);
270
+ persistVault();
271
+ renderVaultEntries();
272
+ $('vault-form').reset();
273
+ populateVaultProviderSelect();
274
+
275
+ await healthCheckEntry(entry.id);
276
+ });
277
+ }
278
+
279
+ async function healthCheckEntry(entryId) {
280
+ const entry = vault.find((v) => v.id === entryId);
281
+ if (!entry) return;
282
+ entry.status = 'checking';
283
+ renderVaultEntries();
284
+
285
+ try {
286
+ const res = await apiFetch('/api/vault/health-check', {
287
+ method: 'POST',
288
+ jsonBody: { entries: [{ providerId: entry.providerId, apiKey: entry.apiKey, label: entry.label }] },
289
+ });
290
+ const data = await res.json();
291
+ const result = data.results?.[0];
292
+ if (result && result.ok) {
293
+ entry.status = 'active';
294
+ entry.models = result.models || [];
295
+ entry.activeModel = entry.models[0]?.id || null;
296
+ showToast(`${entry.label}: ${entry.models.length} live model(s) verified`, 'success');
297
+ } else {
298
+ entry.status = 'dead';
299
+ entry.error = result?.error || 'Health check failed';
300
+ showToast(`${entry.label}: ${entry.error}`, 'error');
301
+ }
302
+ } catch (err) {
303
+ entry.status = 'dead';
304
+ entry.error = err.message;
305
+ }
306
+ persistVault();
307
+ renderVaultEntries();
308
+ rebuildModelSelector();
309
+ renderProvidersPage();
310
+ renderNetworkMap();
311
+ populateImageProviderSelect();
312
+ }
313
+
314
+ function removeVaultEntry(entryId) {
315
+ vault = vault.filter((v) => v.id !== entryId);
316
+ persistVault();
317
+ renderVaultEntries();
318
+ rebuildModelSelector();
319
+ renderProvidersPage();
320
+ renderNetworkMap();
321
+ populateImageProviderSelect();
322
+ }
323
+
324
+ function renderVaultEntries() {
325
+ const vaultEntries = $('vault-entries');
326
+ vaultEntries.innerHTML = '';
327
+ if (vault.length === 0) {
328
+ vaultEntries.innerHTML = `<div class="vault-note">${t('vault.empty')}</div>`;
329
+ return;
330
+ }
331
+ for (const entry of vault) {
332
+ const div = document.createElement('div');
333
+ div.className = 'vault-entry';
334
+ const statusClass = entry.status === 'active' ? 'active' : entry.status === 'checking' ? 'checking' : 'dead';
335
+ const statusLabel = entry.status === 'active'
336
+ ? `โœ“ ${entry.models.length} ${t('vault.liveCount')}`
337
+ : entry.status === 'checking' ? `โณ ${t('vault.checking')}` : `โœ• ${t('vault.dead')}`;
338
+ const label = PROVIDER_META[entry.providerId]?.label || entry.providerId;
339
+
340
+ div.innerHTML = `
341
+ <div class="vault-entry-info">
342
+ <span class="vault-entry-name">${escapeHtml(entry.label)} <span style="color:var(--text-muted);font-weight:400;">(${escapeHtml(label)})</span></span>
343
+ <span class="vault-entry-key">${maskKey(entry.apiKey)}</span>
344
+ </div>
345
+ <div style="display:flex;align-items:center;gap:8px;">
346
+ <span class="vault-entry-status ${statusClass}">${statusLabel}</span>
347
+ <div class="vault-entry-actions">
348
+ <button title="Re-check" data-action="recheck" data-id="${entry.id}">๐Ÿ”„</button>
349
+ <button title="Remove" data-action="remove" data-id="${entry.id}">๐Ÿ—‘๏ธ</button>
350
+ </div>
351
+ </div>
352
+ `;
353
+ vaultEntries.appendChild(div);
354
+ }
355
+ vaultEntries.querySelectorAll('[data-action="recheck"]').forEach((btn) => btn.addEventListener('click', () => healthCheckEntry(btn.dataset.id)));
356
+ vaultEntries.querySelectorAll('[data-action="remove"]').forEach((btn) => btn.addEventListener('click', () => removeVaultEntry(btn.dataset.id)));
357
+ }
358
+
359
+ function rebuildModelSelector() {
360
+ const modelSelector = $('model-selector-dropdown');
361
+ modelSelector.innerHTML = `<option value="auto">${t('nav.smartRouting')}</option>`;
362
+ for (const entry of vault) {
363
+ if (entry.status !== 'active') continue;
364
+ for (const model of entry.models.slice(0, 8)) {
365
+ const opt = document.createElement('option');
366
+ opt.value = `${entry.id}::${model.id}`;
367
+ const label = PROVIDER_META[entry.providerId]?.label || entry.providerId;
368
+ opt.textContent = `${label} โ€” ${model.id} (${model.tier})`;
369
+ modelSelector.appendChild(opt);
370
+ }
371
+ }
372
+ }
373
+
374
+ // ==========================================================================
375
+ // PROVIDERS PAGE
376
+ // ==========================================================================
377
+
378
+ function renderProvidersPage() {
379
+ const grid = $('provider-grid');
380
+ grid.innerHTML = '';
381
+
382
+ for (const id of Object.keys(PROVIDER_META)) {
383
+ const meta = PROVIDER_META[id];
384
+ const configuredEntries = vault.filter((v) => v.providerId === id);
385
+ const liveModels = configuredEntries.flatMap((e) => (e.status === 'active' ? e.models : []));
386
+
387
+ const card = document.createElement('div');
388
+ card.className = 'provider-card';
389
+
390
+ const modelsHtml = liveModels.length > 0
391
+ ? liveModels.slice(0, 6).map((m) => `<span class="live-model-chip">${escapeHtml(m.id)}</span>`).join('')
392
+ : `<span class="live-model-chip none">${t('providers.noKey')}</span>`;
393
+
394
+ card.innerHTML = `
395
+ <div class="provider-card-top">
396
+ <span class="provider-dot" style="background:${meta.color}"></span>
397
+ <h3>${escapeHtml(meta.label)}</h3>
398
+ </div>
399
+ <span class="prefix">Key format: ${escapeHtml(meta.keyPrefix || 'varies')}...</span>
400
+ <div class="live-models-strip">${modelsHtml}</div>
401
+ <div class="provider-card-links">
402
+ <a href="${meta.keyConsoleUrl}" target="_blank" rel="noopener noreferrer" class="primary">${t('providers.getKey')}</a>
403
+ <a href="${meta.docsUrl}" target="_blank" rel="noopener noreferrer">${t('providers.docs')}</a>
404
+ </div>
405
+ `;
406
+ grid.appendChild(card);
407
+ }
408
+ }
409
+
410
+ // ==========================================================================
411
+ // NETWORK MAP PAGE โ€” starts empty; nodes appear as providers get added,
412
+ // each labeled with its live model name + provider logo/color chip.
413
+ // ==========================================================================
414
+
415
+ // Builds a gently wavy SVG path between two points (instead of a straight
416
+ // line) by offsetting a handful of points perpendicular to the line in an
417
+ // alternating sine pattern, then connecting them with a smooth curve.
418
+ function buildWavyPath(x1, y1, x2, y2, { waves = 3, amplitude = 10 } = {}) {
419
+ const dx = x2 - x1, dy = y2 - y1;
420
+ const len = Math.sqrt(dx * dx + dy * dy) || 1;
421
+ const ux = dx / len, uy = dy / len; // unit vector along the line
422
+ const px = -uy, py = ux; // perpendicular unit vector
423
+
424
+ const segments = Math.max(waves * 2, 4);
425
+ const pts = [];
426
+ for (let i = 0; i <= segments; i++) {
427
+ const t = i / segments;
428
+ const baseX = x1 + dx * t;
429
+ const baseY = y1 + dy * t;
430
+ // taper the wave to zero at both ends so it meets the node/center cleanly
431
+ const taper = Math.sin(Math.PI * t);
432
+ const offset = Math.sin(t * Math.PI * 2 * waves) * amplitude * taper;
433
+ pts.push([baseX + px * offset, baseY + py * offset]);
434
+ }
435
+
436
+ // Smooth the point sequence into a curve: each interior point acts as the
437
+ // control point for a quadratic segment ending at the midpoint to the
438
+ // next point, so the path bends smoothly through every wave crest.
439
+ const f = (n) => n.toFixed(2);
440
+ let d = `M ${f(pts[0][0])} ${f(pts[0][1])}`;
441
+ for (let i = 1; i < pts.length - 1; i++) {
442
+ const [ctrlX, ctrlY] = pts[i];
443
+ const [nextX, nextY] = pts[i + 1];
444
+ const midX = (ctrlX + nextX) / 2, midY = (ctrlY + nextY) / 2;
445
+ d += ` Q ${f(ctrlX)} ${f(ctrlY)}, ${f(midX)} ${f(midY)}`;
446
+ }
447
+ const last = pts[pts.length - 1];
448
+ d += ` L ${f(last[0])} ${f(last[1])}`;
449
+ return d;
450
+ }
451
+
452
+ function renderNetworkMap() {
453
+ const wrap = $('map-canvas-wrap');
454
+ const svg = $('map-svg');
455
+ const logoHost = $('map-logo-host');
456
+ const emptyState = $('map-empty-state');
457
+ const legend = $('map-legend');
458
+ if (!wrap || !svg) return;
459
+
460
+ const configuredIds = [...new Set(vault.filter((v) => v.status === 'active').map((v) => v.providerId))];
461
+
462
+ if (configuredIds.length === 0) {
463
+ svg.innerHTML = '';
464
+ logoHost.innerHTML = '';
465
+ emptyState.style.display = 'flex';
466
+ legend.style.display = 'none';
467
+ injectLogo($('map-empty-logo'));
468
+ return;
469
+ }
470
+
471
+ emptyState.style.display = 'none';
472
+ legend.style.display = 'block';
473
+
474
+ const w = wrap.clientWidth || 800;
475
+ const h = wrap.clientHeight || 500;
476
+ const cx = w / 2, cy = h / 2;
477
+ const radius = Math.min(w, h) * 0.32;
478
+ const n = configuredIds.length;
479
+
480
+ svg.setAttribute('viewBox', `0 0 ${w} ${h}`);
481
+
482
+ const logoSize = 84;
483
+ logoHost.style.left = `${cx - logoSize / 2}px`;
484
+ logoHost.style.top = `${cy - logoSize / 2}px`;
485
+ injectLogo(logoHost, logoSize);
486
+
487
+ let linesHtml = '';
488
+ let nodesHtml = '';
489
+
490
+ configuredIds.forEach((id, i) => {
491
+ const angle = (i / n) * Math.PI * 2 - Math.PI / 2;
492
+ const nx = cx + radius * Math.cos(angle);
493
+ const ny = cy + radius * Math.sin(angle);
494
+ const meta = PROVIDER_META[id];
495
+ if (!meta) return;
496
+ const entry = vault.find((v) => v.providerId === id && v.status === 'active');
497
+ const modelName = entry?.activeModel || entry?.models?.[0]?.id || '';
498
+
499
+ const wavyD = buildWavyPath(cx, cy, nx, ny, { waves: 3, amplitude: Math.min(14, radius * 0.06) });
500
+ linesHtml += `<path d="${wavyD}" fill="none" stroke="${meta.color}" stroke-width="2" opacity="0.85" stroke-linecap="round">
501
+ <animate attributeName="opacity" values="0.85;0.35;0.85" dur="2.4s" repeatCount="indefinite"/>
502
+ </path>`;
503
+
504
+ nodesHtml += `
505
+ <g transform="translate(${nx},${ny})">
506
+ <circle r="28" fill="${meta.color}" opacity="0.16" stroke="${meta.color}" stroke-width="1.5"/>
507
+ <circle r="5" fill="${meta.color}"/>
508
+ <text y="44" text-anchor="middle" font-size="12" fill="var(--text-primary)" font-family="Segoe UI, sans-serif" font-weight="600">${escapeHtml(meta.label)}</text>
509
+ <text y="58" text-anchor="middle" font-size="9" fill="var(--accent-green)" font-family="monospace">${t('map.online')}</text>
510
+ ${modelName ? `<text y="70" text-anchor="middle" font-size="8" fill="var(--text-muted)" font-family="monospace">${escapeHtml(modelName.slice(0, 22))}</text>` : ''}
511
+ </g>`;
512
+ });
513
+
514
+ svg.innerHTML = linesHtml + nodesHtml;
515
+ }
516
+
517
+ window.addEventListener('resize', () => {
518
+ if ($('page-map').classList.contains('active')) renderNetworkMap();
519
+ });
520
+
521
+ // ==========================================================================
522
+ // SETTINGS PAGE
523
+ // ==========================================================================
524
+
525
+ function setupSettingsUI() {
526
+ $('theme-btn-dark').addEventListener('click', () => applyTheme('dark'));
527
+ $('theme-btn-light').addEventListener('click', () => applyTheme('light'));
528
+ applyTheme(localStorage.getItem(THEME_KEY) || 'dark');
529
+
530
+ $('lang-btn-en').addEventListener('click', () => applyI18n('en'));
531
+ $('lang-btn-ar').addEventListener('click', () => applyI18n('ar'));
532
+ applyI18n(currentLang);
533
+
534
+ $('btn-clear-vault').addEventListener('click', () => {
535
+ if (!confirm('Remove all saved provider keys from this browser?')) return;
536
+ vault = [];
537
+ persistVault();
538
+ renderVaultEntries();
539
+ rebuildModelSelector();
540
+ renderProvidersPage();
541
+ renderNetworkMap();
542
+ populateImageProviderSelect();
543
+ showToast('Vault cleared.', 'success');
544
+ });
545
+
546
+ $('btn-clear-history').addEventListener('click', () => {
547
+ if (!confirm(t('history.confirmClear'))) return;
548
+ chatHistory = [];
549
+ persistHistory();
550
+ renderHistoryList();
551
+ showToast(t('history.cleared'), 'success');
552
+ });
553
+
554
+ $('password-form').addEventListener('submit', async (e) => {
555
+ e.preventDefault();
556
+ const msgEl = $('pw-form-msg');
557
+ msgEl.textContent = ''; msgEl.className = 'form-msg';
558
+ const currentPassword = $('pw-current').value;
559
+ const newPassword = $('pw-new').value;
560
+ const confirmPassword = $('pw-confirm').value;
561
+
562
+ if (newPassword !== confirmPassword) {
563
+ msgEl.textContent = t('settings.pwMismatch');
564
+ msgEl.className = 'form-msg error';
565
+ return;
566
+ }
567
+
568
+ try {
569
+ const res = await apiFetch('/api/auth/change-password', {
570
+ method: 'POST',
571
+ jsonBody: { currentPassword, newPassword },
572
+ });
573
+ const data = await res.json();
574
+ if (!res.ok || !data.ok) {
575
+ msgEl.textContent = data.error || 'Failed to update password.';
576
+ msgEl.className = 'form-msg error';
577
+ return;
578
+ }
579
+ authToken = data.token;
580
+ sessionStorage.setItem(TOKEN_KEY, authToken);
581
+ $('password-form').reset();
582
+ msgEl.textContent = t('settings.pwSuccess');
583
+ msgEl.className = 'form-msg success';
584
+ showToast(t('settings.pwSuccess'), 'success');
585
+ } catch (err) {
586
+ msgEl.textContent = err.message;
587
+ msgEl.className = 'form-msg error';
588
+ }
589
+ });
590
+ }
591
+
592
+ // ==========================================================================
593
+ // CHAT HISTORY (persisted conversations sidebar)
594
+ // ==========================================================================
595
+
596
+ function loadHistory() {
597
+ try { return JSON.parse(localStorage.getItem(HISTORY_KEY) || '[]'); } catch { return []; }
598
+ }
599
+ function persistHistory() {
600
+ const trimmed = chatHistory
601
+ .sort((a, b) => b.updatedAt - a.updatedAt)
602
+ .slice(0, MAX_HISTORY_ITEMS);
603
+ localStorage.setItem(HISTORY_KEY, JSON.stringify(trimmed));
604
+ chatHistory = trimmed;
605
+ }
606
+
607
+ function setupHistoryUI() {
608
+ $('btn-new-chat').addEventListener('click', () => startNewChat());
609
+ $('btn-toggle-history').addEventListener('click', () => {
610
+ $('app-shell').classList.toggle('history-collapsed');
611
+ });
612
+ $('history-search-input').addEventListener('input', () => renderHistoryList());
613
+ }
614
+
615
+ function startNewChat() {
616
+ activeChatId = null;
617
+ clearChatUIOnly();
618
+ renderHistoryList();
619
+ }
620
+
621
+ function currentChatTitle() {
622
+ const firstUserMsg = conversation.find((m) => m.role === 'user');
623
+ if (!firstUserMsg) return t('history.untitled');
624
+ const text = (firstUserMsg.content || '').replace(/\s+/g, ' ').trim();
625
+ return text.length > 46 ? text.slice(0, 46) + 'โ€ฆ' : (text || t('history.untitled'));
626
+ }
627
+
628
+ function saveActiveChat() {
629
+ if (conversation.length === 0) return;
630
+ if (!activeChatId) {
631
+ activeChatId = `chat_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`;
632
+ }
633
+ const existingIdx = chatHistory.findIndex((c) => c.id === activeChatId);
634
+ const record = {
635
+ id: activeChatId,
636
+ title: currentChatTitle(),
637
+ updatedAt: Date.now(),
638
+ messages: conversation,
639
+ };
640
+ if (existingIdx >= 0) chatHistory[existingIdx] = record;
641
+ else chatHistory.push(record);
642
+ persistHistory();
643
+ renderHistoryList();
644
+ }
645
+
646
+ function loadChat(chatId) {
647
+ const record = chatHistory.find((c) => c.id === chatId);
648
+ if (!record) return;
649
+ activeChatId = chatId;
650
+ conversation = JSON.parse(JSON.stringify(record.messages));
651
+ attachedFiles = [];
652
+ renderFileChips();
653
+
654
+ const chatMessages = $('chat-messages');
655
+ chatMessages.innerHTML = '';
656
+ const empty = $('empty-state');
657
+ chatMessages.appendChild(empty);
658
+ empty.style.display = 'none';
659
+ const jump = document.createElement('button');
660
+ jump.className = 'jump-latest'; jump.id = 'jump-latest'; jump.textContent = t('chat.jumpLatest');
661
+ chatMessages.appendChild(jump);
662
+ jump.addEventListener('click', () => { userScrolledUp = false; chatMessages.scrollTop = chatMessages.scrollHeight; jump.classList.remove('visible'); });
663
+
664
+ for (const msg of conversation) {
665
+ if (msg.role === 'user' || msg.role === 'assistant') {
666
+ appendMessage(msg.role, msg.content);
667
+ }
668
+ }
669
+ userScrolledUp = false;
670
+ chatMessages.scrollTop = chatMessages.scrollHeight;
671
+ renderHistoryList();
672
+
673
+ document.querySelectorAll('.rail-btn[data-page]').forEach((b) => b.classList.remove('active'));
674
+ $('rail-chat').classList.add('active');
675
+ document.querySelectorAll('.page-view').forEach((p) => p.classList.remove('active'));
676
+ $('page-chat').classList.add('active');
677
+ }
678
+
679
+ function deleteChat(chatId, evt) {
680
+ if (evt) evt.stopPropagation();
681
+ if (!confirm(t('history.confirmDelete'))) return;
682
+ chatHistory = chatHistory.filter((c) => c.id !== chatId);
683
+ persistHistory();
684
+ if (activeChatId === chatId) startNewChat();
685
+ else renderHistoryList();
686
+ }
687
+
688
+ function renderHistoryList() {
689
+ const list = $('history-list');
690
+ if (!list) return;
691
+ const query = ($('history-search-input')?.value || '').trim().toLowerCase();
692
+ const items = chatHistory
693
+ .filter((c) => !query || c.title.toLowerCase().includes(query))
694
+ .sort((a, b) => b.updatedAt - a.updatedAt);
695
+
696
+ list.innerHTML = '';
697
+ if (items.length === 0) {
698
+ list.innerHTML = `<div class="history-empty">${t('history.untitled')}</div>`;
699
+ return;
700
+ }
701
+ for (const item of items) {
702
+ const div = document.createElement('div');
703
+ div.className = `history-item ${item.id === activeChatId ? 'active' : ''}`;
704
+ div.innerHTML = `
705
+ <span class="history-item-title">${escapeHtml(item.title)}</span>
706
+ <div class="history-item-actions">
707
+ <button title="Delete" data-id="${item.id}">๐Ÿ—‘๏ธ</button>
708
+ </div>
709
+ `;
710
+ div.addEventListener('click', () => loadChat(item.id));
711
+ div.querySelector('[data-id]').addEventListener('click', (e) => deleteChat(item.id, e));
712
+ list.appendChild(div);
713
+ }
714
+ }
715
+
716
+ // ==========================================================================
717
+ // COMPRESSION SLIDER
718
+ // ==========================================================================
719
+
720
+ const COMPRESSION_LABELS = [[0, 'Off'], [25, 'Light'], [50, 'Balanced'], [75, 'Aggressive'], [99, 'Extreme (99%)']];
721
+
722
+ function updateCompressionLabel() {
723
+ const slider = $('token-compression-slider');
724
+ const val = Number(slider.value);
725
+ let label = 'Off';
726
+ for (const [threshold, l] of COMPRESSION_LABELS) if (val >= threshold) label = l;
727
+ $('compression-value').textContent = `${label} (${val}%)`;
728
+ }
729
+
730
+ // ==========================================================================
731
+ // CHAT UI SETUP
732
+ // ==========================================================================
733
+
734
+ function setupChatUI() {
735
+ const promptInput = $('user-prompt-input');
736
+ const compressionSlider = $('token-compression-slider');
737
+ const chatMessages = $('chat-messages');
738
+ const jumpLatestBtn = $('jump-latest');
739
+
740
+ compressionSlider.addEventListener('input', updateCompressionLabel);
741
+
742
+ promptInput.addEventListener('input', () => {
743
+ promptInput.style.height = 'auto';
744
+ promptInput.style.height = Math.min(promptInput.scrollHeight, 200) + 'px';
745
+ });
746
+
747
+ promptInput.addEventListener('keydown', (e) => {
748
+ if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); sendMessage(); }
749
+ });
750
+
751
+ $('btn-send').addEventListener('click', sendMessage);
752
+ $('btn-stop').addEventListener('click', stopGeneration);
753
+ $('btn-attach').addEventListener('click', () => $('file-upload-input').click());
754
+ initVoiceInput();
755
+ $('file-upload-input').addEventListener('change', handleFileUpload);
756
+ $('btn-clear-chat').addEventListener('click', clearChat);
757
+ $('btn-tts-toggle').addEventListener('click', toggleTts);
758
+
759
+ // FIX: "scroll up shows old messages but keeps yanking back down".
760
+ // We only auto-scroll to bottom if the user is already near the bottom.
761
+ chatMessages.addEventListener('scroll', () => {
762
+ const distanceFromBottom = chatMessages.scrollHeight - chatMessages.scrollTop - chatMessages.clientHeight;
763
+ userScrolledUp = distanceFromBottom > 80;
764
+ jumpLatestBtn.classList.toggle('visible', userScrolledUp);
765
+ });
766
+
767
+ jumpLatestBtn.addEventListener('click', () => {
768
+ userScrolledUp = false;
769
+ chatMessages.scrollTop = chatMessages.scrollHeight;
770
+ jumpLatestBtn.classList.remove('visible');
771
+ });
772
+ }
773
+
774
+ function scrollToBottomIfAllowed() {
775
+ const chatMessages = $('chat-messages');
776
+ if (!userScrolledUp) {
777
+ chatMessages.scrollTop = chatMessages.scrollHeight;
778
+ } else {
779
+ $('jump-latest').classList.add('visible');
780
+ }
781
+ }
782
+
783
+ // ==========================================================================
784
+ // FILE ATTACHMENTS + VIEWER
785
+ // ==========================================================================
786
+
787
+ async function handleFileUpload() {
788
+ const fileInput = $('file-upload-input');
789
+ const files = Array.from(fileInput.files || []);
790
+ if (files.length === 0) return;
791
+
792
+ const formData = new FormData();
793
+ files.forEach((f) => formData.append('files', f));
794
+
795
+ try {
796
+ const res = await apiFetch('/api/upload', { method: 'POST', body: formData });
797
+ const data = await res.json();
798
+ if (data.ok) {
799
+ attachedFiles.push(...data.parsed.filter((p) => p.ok));
800
+ renderFileChips();
801
+ }
802
+ } catch (err) {
803
+ console.error('Upload failed', err);
804
+ }
805
+ fileInput.value = '';
806
+ }
807
+
808
+ function renderFileChips() {
809
+ const fileChipRow = $('file-chip-row');
810
+ fileChipRow.innerHTML = '';
811
+ attachedFiles.forEach((file, idx) => {
812
+ const chip = document.createElement('div');
813
+ chip.className = 'file-chip';
814
+ const icon = file.type === 'image' ? '๐Ÿ–ผ๏ธ' : file.type === 'pdf' ? '๐Ÿ“„' : '๐Ÿ“';
815
+ chip.innerHTML = `<span class="chip-open" data-idx="${idx}">${icon} ${escapeHtml(file.filename)}</span> <span class="remove" data-idx="${idx}">โœ•</span>`;
816
+ fileChipRow.appendChild(chip);
817
+ });
818
+ fileChipRow.querySelectorAll('.remove').forEach((el) => {
819
+ el.addEventListener('click', (e) => { e.stopPropagation(); attachedFiles.splice(Number(el.dataset.idx), 1); renderFileChips(); });
820
+ });
821
+ fileChipRow.querySelectorAll('.chip-open').forEach((el) => {
822
+ el.addEventListener('click', () => openFileViewer(attachedFiles[Number(el.dataset.idx)]));
823
+ });
824
+ }
825
+
826
+ function setupFileViewer() {
827
+ $('file-viewer-close').addEventListener('click', closeFileViewer);
828
+ $('file-viewer-overlay').addEventListener('click', (e) => {
829
+ if (e.target.id === 'file-viewer-overlay') closeFileViewer();
830
+ });
831
+ }
832
+
833
+ function openFileViewer(file) {
834
+ if (!file) return;
835
+ $('file-viewer-title').textContent = file.filename;
836
+ const body = $('file-viewer-body');
837
+ body.innerHTML = '';
838
+
839
+ if (file.type === 'image') {
840
+ const img = document.createElement('img');
841
+ img.src = `data:${file.mimeType};base64,${file.base64}`;
842
+ body.appendChild(img);
843
+ } else if (file.type === 'pdf') {
844
+ const iframe = document.createElement('iframe');
845
+ iframe.src = `data:application/pdf;base64,${file.base64}`;
846
+ body.appendChild(iframe);
847
+ } else {
848
+ const pre = document.createElement('pre');
849
+ pre.textContent = file.content || '';
850
+ body.appendChild(pre);
851
+ }
852
+ $('file-viewer-overlay').classList.add('open');
853
+ }
854
+
855
+ function closeFileViewer() {
856
+ $('file-viewer-overlay').classList.remove('open');
857
+ }
858
+
859
+ // ==========================================================================
860
+ // CHAT RENDERING
861
+ // ==========================================================================
862
+
863
+ function appendMessage(role, text, meta = {}) {
864
+ $('empty-state').style.display = 'none';
865
+ const chatMessages = $('chat-messages');
866
+ const wrap = document.createElement('div');
867
+ wrap.className = `msg ${role}`;
868
+
869
+ const bubble = document.createElement('div');
870
+ bubble.className = 'msg-bubble';
871
+ bubble.textContent = text;
872
+ wrap.appendChild(bubble);
873
+
874
+ if (meta.attachments && meta.attachments.length) {
875
+ const attWrap = document.createElement('div');
876
+ attWrap.className = 'msg-attachments';
877
+ meta.attachments.forEach((file, idx) => {
878
+ const chip = document.createElement('div');
879
+ chip.className = 'msg-attachment-chip';
880
+ if (file.type === 'image') {
881
+ chip.innerHTML = `<img class="thumb" src="data:${file.mimeType};base64,${file.base64}" /> ${escapeHtml(file.filename)}`;
882
+ } else {
883
+ const icon = file.type === 'pdf' ? '๐Ÿ“„' : '๐Ÿ“';
884
+ chip.textContent = `${icon} ${file.filename}`;
885
+ }
886
+ chip.addEventListener('click', () => openFileViewer(file));
887
+ attWrap.appendChild(chip);
888
+ });
889
+ wrap.appendChild(attWrap);
890
+ }
891
+
892
+ if (meta.providers && meta.providers.length) {
893
+ const metaEl = document.createElement('div');
894
+ metaEl.className = 'msg-meta';
895
+ metaEl.innerHTML = meta.providers.map((p) => `<span class="provider-tag">${escapeHtml(p)}</span>`).join('');
896
+ wrap.appendChild(metaEl);
897
+ }
898
+
899
+ chatMessages.insertBefore(wrap, $('jump-latest'));
900
+ scrollToBottomIfAllowed();
901
+ return bubble;
902
+ }
903
+
904
+ function appendTypingIndicator() {
905
+ $('empty-state').style.display = 'none';
906
+ const chatMessages = $('chat-messages');
907
+ const wrap = document.createElement('div');
908
+ wrap.className = 'msg assistant';
909
+ wrap.id = 'typing-indicator';
910
+ wrap.innerHTML = `<div class="msg-bubble"><span class="typing-dots"><span></span><span></span><span></span></span></div>`;
911
+ chatMessages.insertBefore(wrap, $('jump-latest'));
912
+ scrollToBottomIfAllowed();
913
+ }
914
+
915
+ function removeTypingIndicator() { const el = $('typing-indicator'); if (el) el.remove(); }
916
+
917
+ // ==========================================================================
918
+ // SEND / STREAM
919
+ // ==========================================================================
920
+
921
+ function getActiveVaultForRequest() {
922
+ const modelSelector = $('model-selector-dropdown');
923
+ // Smart Token Saver must only ever see providers proven live this
924
+ // session โ€” never guessed or stale entries.
925
+ const activeEntries = vault.filter((v) => v.status === 'active' && v.models && v.models.length > 0);
926
+ const selectedValue = modelSelector.value;
927
+
928
+ if (selectedValue && selectedValue !== 'auto') {
929
+ const [entryId, model] = selectedValue.split('::');
930
+ const entry = activeEntries.find((v) => v.id === entryId);
931
+ if (entry) return [{ providerId: entry.providerId, apiKey: entry.apiKey, model }];
932
+ return [];
933
+ }
934
+
935
+ return activeEntries.map((entry) => ({
936
+ providerId: entry.providerId,
937
+ apiKey: entry.apiKey,
938
+ model: entry.activeModel || entry.models[0]?.id,
939
+ })).filter((e) => e.model);
940
+ }
941
+
942
+ async function sendMessage() {
943
+ const promptInput = $('user-prompt-input');
944
+ const text = promptInput.value.trim();
945
+ if (!text && attachedFiles.length === 0) return;
946
+ if (isStreaming) return;
947
+
948
+ const activeVault = getActiveVaultForRequest();
949
+ if (activeVault.length === 0) {
950
+ appendMessage('assistant', t('chat.noProvider'));
951
+ return;
952
+ }
953
+
954
+ let fullPrompt = text;
955
+ const messageAttachments = [...attachedFiles];
956
+ if (attachedFiles.length > 0) {
957
+ const textFiles = attachedFiles.filter((f) => f.type === 'text');
958
+ if (textFiles.length > 0) {
959
+ const context = textFiles.map((f) => `--- FILE: ${f.filename} ---\n${f.content}\n--- END FILE ---`).join('\n\n');
960
+ fullPrompt = `${context}\n\n${text}`;
961
+ }
962
+ }
963
+
964
+ appendMessage('user', text || t('chat.attachedFiles'), { attachments: messageAttachments });
965
+ conversation.push({ role: 'user', content: fullPrompt });
966
+ promptInput.value = '';
967
+ promptInput.style.height = 'auto';
968
+ attachedFiles = [];
969
+ renderFileChips();
970
+
971
+ appendTypingIndicator();
972
+ setStreamingState(true);
973
+
974
+ let assistantBubble = null;
975
+ let accumulatedText = '';
976
+ const providersUsed = new Set();
977
+
978
+ try {
979
+ const res = await apiFetch('/api/chat/stream', {
980
+ method: 'POST',
981
+ jsonBody: {
982
+ vault: activeVault,
983
+ messages: conversation,
984
+ compressionLevel: Number($('token-compression-slider').value),
985
+ taskId: `task_${Date.now()}`,
986
+ },
987
+ });
988
+
989
+ if (!res.ok || !res.body) {
990
+ const errData = await res.json().catch(() => ({}));
991
+ throw new Error(errData.error || `HTTP ${res.status}`);
992
+ }
993
+
994
+ const reader = res.body.getReader();
995
+ const decoder = new TextDecoder();
996
+ let buffer = '';
997
+
998
+ setStatus('streaming', 'Engine: Streaming (smart routing)');
999
+
1000
+ while (true) {
1001
+ const { done, value } = await reader.read();
1002
+ if (done) break;
1003
+ buffer += decoder.decode(value, { stream: true });
1004
+
1005
+ const events = buffer.split('\n\n');
1006
+ buffer = events.pop();
1007
+
1008
+ for (const evt of events) {
1009
+ const lines = evt.split('\n');
1010
+ const eventType = lines.find((l) => l.startsWith('event:'))?.slice(6).trim();
1011
+ const dataLine = lines.find((l) => l.startsWith('data:'))?.slice(5).trim();
1012
+ if (!eventType || !dataLine) continue;
1013
+
1014
+ let data;
1015
+ try { data = JSON.parse(dataLine); } catch { continue; }
1016
+
1017
+ if (eventType === 'chunk') {
1018
+ removeTypingIndicator();
1019
+ if (!assistantBubble) assistantBubble = appendMessage('assistant', '');
1020
+ accumulatedText += data.text;
1021
+ assistantBubble.textContent = accumulatedText;
1022
+ scrollToBottomIfAllowed();
1023
+ } else if (eventType === 'provider') {
1024
+ providersUsed.add(`${data.provider}:${data.model}`);
1025
+ setStatus('streaming', `Engine: ${data.provider} โ†’ ${data.model}`);
1026
+ } else if (eventType === 'done') {
1027
+ const savings = estimateSavings(Number($('token-compression-slider').value));
1028
+ setStatus('idle', `Engine: Complete | Savings: ${savings}%`);
1029
+ } else if (eventType === 'error') {
1030
+ throw new Error(data.error);
1031
+ }
1032
+ }
1033
+ }
1034
+
1035
+ removeTypingIndicator();
1036
+ conversation.push({ role: 'assistant', content: accumulatedText });
1037
+
1038
+ if (providersUsed.size > 0 && assistantBubble) {
1039
+ const metaEl = document.createElement('div');
1040
+ metaEl.className = 'msg-meta';
1041
+ metaEl.innerHTML = [...providersUsed].map((p) => `<span class="provider-tag">${escapeHtml(p)}</span>`).join('');
1042
+ assistantBubble.parentElement.appendChild(metaEl);
1043
+ }
1044
+
1045
+ if (ttsState !== 'mute' && accumulatedText) speakText(accumulatedText, ttsState);
1046
+
1047
+ saveActiveChat();
1048
+ } catch (err) {
1049
+ removeTypingIndicator();
1050
+ appendMessage('assistant', `โš ๏ธ Error: ${err.message}`);
1051
+ setStatus('error', `Engine: Error`);
1052
+ saveActiveChat();
1053
+ } finally {
1054
+ setStreamingState(false);
1055
+ }
1056
+ }
1057
+
1058
+ function stopGeneration() {
1059
+ setStreamingState(false);
1060
+ setStatus('idle', 'Engine: Stopped');
1061
+ }
1062
+
1063
+ function setStreamingState(streaming) {
1064
+ isStreaming = streaming;
1065
+ $('btn-stop').classList.toggle('active', streaming);
1066
+ $('btn-send').disabled = streaming;
1067
+ }
1068
+
1069
+ function setStatus(kind, text) {
1070
+ const badge = $('status-badge');
1071
+ badge.textContent = `[${text}]`;
1072
+ badge.className = kind === 'streaming' ? 'streaming' : kind === 'error' ? 'error' : '';
1073
+ }
1074
+
1075
+ function estimateSavings(level) {
1076
+ if (level >= 99) return 99;
1077
+ if (level >= 75) return 75;
1078
+ if (level >= 50) return 50;
1079
+ if (level >= 25) return 25;
1080
+ return 0;
1081
+ }
1082
+
1083
+ function clearChatUIOnly() {
1084
+ conversation = [];
1085
+ attachedFiles = [];
1086
+ renderFileChips();
1087
+ const chatMessages = $('chat-messages');
1088
+ chatMessages.innerHTML = '';
1089
+ const empty = $('empty-state');
1090
+ chatMessages.appendChild(empty);
1091
+ const jump = document.createElement('button');
1092
+ jump.className = 'jump-latest'; jump.id = 'jump-latest'; jump.textContent = t('chat.jumpLatest');
1093
+ chatMessages.appendChild(jump);
1094
+ jump.addEventListener('click', () => { userScrolledUp = false; chatMessages.scrollTop = chatMessages.scrollHeight; jump.classList.remove('visible'); });
1095
+ empty.style.display = 'flex';
1096
+ setStatus('idle', 'Engine: Idle | Savings: 0%');
1097
+ }
1098
+
1099
+ function clearChat() {
1100
+ clearChatUIOnly();
1101
+ activeChatId = null;
1102
+ renderHistoryList();
1103
+ }
1104
+
1105
+ // ==========================================================================
1106
+ // IMAGE STUDIO
1107
+ // ==========================================================================
1108
+
1109
+ const IMAGE_CAPABLE_PROVIDERS = new Set(['openai', 'gemini']);
1110
+
1111
+ function populateImageProviderSelect() {
1112
+ const sel = $('imagegen-provider');
1113
+ if (!sel) return;
1114
+ const capable = vault.filter((v) => v.status === 'active' && IMAGE_CAPABLE_PROVIDERS.has(v.providerId));
1115
+ sel.innerHTML = '';
1116
+ if (capable.length === 0) {
1117
+ sel.innerHTML = `<option value="">${t('image.noProvider')}</option>`;
1118
+ sel.disabled = true;
1119
+ return;
1120
+ }
1121
+ sel.disabled = false;
1122
+ for (const entry of capable) {
1123
+ const opt = document.createElement('option');
1124
+ opt.value = entry.id;
1125
+ const label = PROVIDER_META[entry.providerId]?.label || entry.providerId;
1126
+ opt.textContent = `${label} (${entry.label})`;
1127
+ sel.appendChild(opt);
1128
+ }
1129
+ }
1130
+
1131
+ function setupImageStudioUI() {
1132
+ const form = $('imagegen-form');
1133
+ const promptInput = $('imagegen-prompt');
1134
+ promptInput.addEventListener('input', () => {
1135
+ promptInput.style.height = 'auto';
1136
+ promptInput.style.height = Math.min(promptInput.scrollHeight, 120) + 'px';
1137
+ });
1138
+
1139
+ renderImageResultsEmpty();
1140
+
1141
+ form.addEventListener('submit', async (e) => {
1142
+ e.preventDefault();
1143
+ const prompt = promptInput.value.trim();
1144
+ const entryId = $('imagegen-provider').value;
1145
+ if (!prompt || !entryId) return;
1146
+ const entry = vault.find((v) => v.id === entryId);
1147
+ if (!entry) return;
1148
+
1149
+ const submitBtn = $('imagegen-submit');
1150
+ submitBtn.disabled = true;
1151
+ submitBtn.textContent = t('image.generating');
1152
+ $('imagegen-results').innerHTML = `<div class="imagegen-loading">${t('image.generating')}</div>`;
1153
+
1154
+ try {
1155
+ const res = await apiFetch('/api/image/generate', {
1156
+ method: 'POST',
1157
+ jsonBody: { providerId: entry.providerId, apiKey: entry.apiKey, prompt },
1158
+ });
1159
+ const data = await res.json();
1160
+ if (!res.ok || !data.ok) throw new Error(data.error || 'Image generation failed.');
1161
+ renderImageResults(data.images, prompt);
1162
+ } catch (err) {
1163
+ $('imagegen-results').innerHTML = `<div class="imagegen-empty">โš ๏ธ ${escapeHtml(err.message)}</div>`;
1164
+ showToast(err.message, 'error');
1165
+ } finally {
1166
+ submitBtn.disabled = false;
1167
+ submitBtn.textContent = t('image.generate');
1168
+ }
1169
+ });
1170
+ }
1171
+
1172
+ function renderImageResultsEmpty() {
1173
+ $('imagegen-results').innerHTML = `<div class="imagegen-empty">${t('image.empty')}</div>`;
1174
+ }
1175
+
1176
+ function renderImageResults(images, prompt) {
1177
+ const container = $('imagegen-results');
1178
+ container.innerHTML = '';
1179
+ images.forEach((src, idx) => {
1180
+ const card = document.createElement('div');
1181
+ card.className = 'imagegen-result';
1182
+ card.innerHTML = `<img src="${src}" alt="${escapeHtml(prompt)}" /><a class="dl-btn" download="muxmind-image-${idx + 1}.png" href="${src}">${t('image.download')}</a>`;
1183
+ container.appendChild(card);
1184
+ });
1185
+ }
1186
+
1187
+ // ==========================================================================
1188
+ // VOICE INPUT (Speech-to-Text) โ€” press mic, speak, auto-fills + sends
1189
+ // ==========================================================================
1190
+
1191
+ let recognizer = null;
1192
+ let isListening = false;
1193
+
1194
+ function initVoiceInput() {
1195
+ const btnMic = $('btn-mic');
1196
+ if (!btnMic) return;
1197
+
1198
+ const SpeechRecognitionCtor = window.SpeechRecognition || window.webkitSpeechRecognition;
1199
+ if (!SpeechRecognitionCtor) {
1200
+ btnMic.disabled = true;
1201
+ btnMic.title = 'Voice input not supported in this browser';
1202
+ btnMic.style.opacity = '0.4';
1203
+ return;
1204
+ }
1205
+
1206
+ btnMic.addEventListener('click', () => {
1207
+ if (isListening) {
1208
+ stopVoiceInput();
1209
+ } else {
1210
+ startVoiceInput(SpeechRecognitionCtor);
1211
+ }
1212
+ });
1213
+ }
1214
+
1215
+ function startVoiceInput(SpeechRecognitionCtor) {
1216
+ const btnMic = $('btn-mic');
1217
+ const promptInput = $('user-prompt-input');
1218
+
1219
+ recognizer = new SpeechRecognitionCtor();
1220
+ // Match recognition language to whatever UI language is active so
1221
+ // Arabic and English speech both transcribe correctly out of the box.
1222
+ recognizer.lang = currentLang === 'ar' ? 'ar-SA' : 'en-US';
1223
+ recognizer.interimResults = true;
1224
+ recognizer.continuous = false;
1225
+ recognizer.maxAlternatives = 1;
1226
+
1227
+ let finalTranscript = '';
1228
+
1229
+ recognizer.onstart = () => {
1230
+ isListening = true;
1231
+ btnMic.dataset.state = 'listening';
1232
+ btnMic.textContent = '๐Ÿ”ด';
1233
+ btnMic.title = 'Listeningโ€ฆ click to stop';
1234
+ setStatus('listening', currentLang === 'ar' ? 'ุฌุงุฑู ุงู„ุงุณุชู…ุงุน...' : 'Listening...');
1235
+ };
1236
+
1237
+ recognizer.onresult = (event) => {
1238
+ let interim = '';
1239
+ for (let i = event.resultIndex; i < event.results.length; i++) {
1240
+ const transcript = event.results[i][0].transcript;
1241
+ if (event.results[i].isFinal) finalTranscript += transcript;
1242
+ else interim += transcript;
1243
+ }
1244
+ promptInput.value = (finalTranscript + interim).trim();
1245
+ promptInput.style.height = 'auto';
1246
+ promptInput.style.height = `${promptInput.scrollHeight}px`;
1247
+ };
1248
+
1249
+ recognizer.onerror = (event) => {
1250
+ if (event.error === 'no-speech') {
1251
+ showToast(currentLang === 'ar' ? 'ู„ู… ูŠูุณู…ุน ุฃูŠ ูƒู„ุงู…ุŒ ุญุงูˆู„ ู…ุฑุฉ ุฃุฎุฑู‰' : 'No speech detected, try again', 'error');
1252
+ } else if (event.error === 'not-allowed' || event.error === 'service-not-allowed') {
1253
+ showToast(currentLang === 'ar' ? 'ูŠุฑุฌู‰ ุงู„ุณู…ุงุญ ุจุงู„ูˆุตูˆู„ ุฅู„ู‰ ุงู„ู…ูŠูƒุฑูˆููˆู†' : 'Microphone access denied', 'error');
1254
+ } else {
1255
+ showToast(`Voice input error: ${event.error}`, 'error');
1256
+ }
1257
+ };
1258
+
1259
+ recognizer.onend = () => {
1260
+ isListening = false;
1261
+ btnMic.dataset.state = 'idle';
1262
+ btnMic.textContent = '๐ŸŽค';
1263
+ btnMic.title = 'Voice input';
1264
+ setStatus('idle', '');
1265
+ // Auto-send once speech has ended, if we actually captured something.
1266
+ if (finalTranscript.trim()) {
1267
+ sendMessage();
1268
+ }
1269
+ };
1270
+
1271
+ finalTranscript = '';
1272
+ recognizer.start();
1273
+ }
1274
+
1275
+ function stopVoiceInput() {
1276
+ if (recognizer && isListening) recognizer.stop();
1277
+ }
1278
+
1279
+ // ==========================================================================
1280
+ // TEXT-TO-SPEECH
1281
+ // ==========================================================================
1282
+
1283
+ function toggleTts() {
1284
+ if (ttsState === 'mute') ttsState = 'en';
1285
+ else if (ttsState === 'en') ttsState = 'ar';
1286
+ else ttsState = 'mute';
1287
+
1288
+ const btnTts = $('btn-tts-toggle');
1289
+ const labels = { mute: '๐Ÿ”‡ Mute', en: '๐Ÿ”Š English', ar: '๐Ÿ”Š Arabic' };
1290
+ btnTts.textContent = labels[ttsState];
1291
+ btnTts.dataset.state = ttsState;
1292
+ if (ttsState === 'mute') window.speechSynthesis.cancel();
1293
+ }
1294
+
1295
+ function speakText(text, langKey) {
1296
+ if (!('speechSynthesis' in window)) return;
1297
+ window.speechSynthesis.cancel();
1298
+ const langMap = { en: 'en-US', ar: 'ar-SA' };
1299
+ const segments = text.split(/(?<=[.!?ุŸ])\s+/).filter(Boolean);
1300
+ segments.forEach((segment) => {
1301
+ const utterance = new SpeechSynthesisUtterance(segment);
1302
+ utterance.lang = langMap[langKey] || 'en-US';
1303
+ utterance.rate = 1.0; utterance.pitch = 1.0;
1304
+ window.speechSynthesis.speak(utterance);
1305
+ });
1306
+ }