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.
- package/.env.example +11 -0
- package/README.md +81 -0
- package/assets/favicon.png +0 -0
- package/assets/muxmind-logo-full.png +0 -0
- package/assets/muxmind-logo-icon.png +0 -0
- package/bin/cli.js +98 -0
- package/index.html +293 -0
- package/package.json +47 -0
- package/server.js +265 -0
- package/src/api-manager.js +227 -0
- package/src/auth.js +159 -0
- package/src/config.js +267 -0
- package/src/file-parser.js +122 -0
- package/src/image-engine.js +73 -0
- package/src/router.js +317 -0
- package/src/tts-engine.js +88 -0
- package/src/ui-render.js +91 -0
- package/src-client/app.js +1306 -0
- package/src-client/i18n.js +229 -0
- package/style.css +948 -0
|
@@ -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
|
+
}
|