muxmind-ai 2.1.1 → 2.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "muxmind-ai",
3
- "version": "2.1.1",
3
+ "version": "2.2.0",
4
4
  "description": "MuxMind AI - Multi-Provider AI Orchestration Engine with smart routing, failover, voice input, and image generation.",
5
5
  "main": "server.js",
6
6
  "type": "commonjs",
@@ -27,7 +27,7 @@
27
27
  "cors": "^2.8.5",
28
28
  "dotenv": "^16.4.5",
29
29
  "express-rate-limit": "^7.4.0",
30
- "multer": "^1.4.5-lts.1"
30
+ "multer": "^2.0.1"
31
31
  },
32
32
  "engines": {
33
33
  "node": ">=18.0.0"
package/server.js CHANGED
@@ -114,6 +114,8 @@ function startWorker() {
114
114
  keyConsoleUrl: p.keyConsoleUrl,
115
115
  docsUrl: p.docsUrl,
116
116
  keyPrefix: p.keyPrefix,
117
+ free: !!p.free,
118
+ tierNote: p.tierNote || '',
117
119
  }));
118
120
  res.json({ ok: true, providers: list });
119
121
  });
package/src/config.js CHANGED
@@ -21,6 +21,8 @@ const PROVIDERS = {
21
21
  keyConsoleUrl: 'https://platform.openai.com/api-keys',
22
22
  docsUrl: 'https://platform.openai.com/docs/api-reference',
23
23
  keyPrefix: 'sk-',
24
+ free: false,
25
+ tierNote: 'Paid — usage-based billing, no ongoing free tier',
24
26
  },
25
27
  anthropic: {
26
28
  id: 'anthropic',
@@ -34,6 +36,8 @@ const PROVIDERS = {
34
36
  keyConsoleUrl: 'https://console.anthropic.com/settings/keys',
35
37
  docsUrl: 'https://docs.claude.com',
36
38
  keyPrefix: 'sk-ant-',
39
+ free: false,
40
+ tierNote: 'Paid — small free trial credit for new accounts',
37
41
  },
38
42
  groq: {
39
43
  id: 'groq',
@@ -47,6 +51,8 @@ const PROVIDERS = {
47
51
  keyConsoleUrl: 'https://console.groq.com/keys',
48
52
  docsUrl: 'https://console.groq.com/docs',
49
53
  keyPrefix: 'gsk_',
54
+ free: true,
55
+ tierNote: 'Free tier — generous rate limits, no card required',
50
56
  },
51
57
  gemini: {
52
58
  id: 'gemini',
@@ -61,6 +67,8 @@ const PROVIDERS = {
61
67
  keyConsoleUrl: 'https://aistudio.google.com/app/apikey',
62
68
  docsUrl: 'https://ai.google.dev/gemini-api/docs',
63
69
  keyPrefix: 'AIza',
70
+ free: true,
71
+ tierNote: 'Free tier via Google AI Studio',
64
72
  },
65
73
  openrouter: {
66
74
  id: 'openrouter',
@@ -74,6 +82,8 @@ const PROVIDERS = {
74
82
  keyConsoleUrl: 'https://openrouter.ai/settings/keys',
75
83
  docsUrl: 'https://openrouter.ai/docs',
76
84
  keyPrefix: 'sk-or-',
85
+ free: true,
86
+ tierNote: 'Free ":free" model pool + paid models on the same key',
77
87
  },
78
88
  mistral: {
79
89
  id: 'mistral',
@@ -87,6 +97,8 @@ const PROVIDERS = {
87
97
  keyConsoleUrl: 'https://console.mistral.ai/api-keys',
88
98
  docsUrl: 'https://docs.mistral.ai',
89
99
  keyPrefix: '',
100
+ free: true,
101
+ tierNote: 'Free experiment tier available on signup',
90
102
  },
91
103
  deepseek: {
92
104
  id: 'deepseek',
@@ -100,6 +112,8 @@ const PROVIDERS = {
100
112
  keyConsoleUrl: 'https://platform.deepseek.com/api_keys',
101
113
  docsUrl: 'https://api-docs.deepseek.com',
102
114
  keyPrefix: 'sk-',
115
+ free: false,
116
+ tierNote: 'Paid — very low per-token pricing',
103
117
  },
104
118
  xai: {
105
119
  id: 'xai',
@@ -113,6 +127,8 @@ const PROVIDERS = {
113
127
  keyConsoleUrl: 'https://console.x.ai',
114
128
  docsUrl: 'https://docs.x.ai',
115
129
  keyPrefix: 'xai-',
130
+ free: false,
131
+ tierNote: 'Paid — usage-based billing',
116
132
  },
117
133
  cohere: {
118
134
  id: 'cohere',
@@ -126,6 +142,8 @@ const PROVIDERS = {
126
142
  keyConsoleUrl: 'https://dashboard.cohere.com/api-keys',
127
143
  docsUrl: 'https://docs.cohere.com',
128
144
  keyPrefix: '',
145
+ free: true,
146
+ tierNote: 'Free trial key with rate limits, no card required',
129
147
  },
130
148
  together: {
131
149
  id: 'together',
@@ -139,6 +157,8 @@ const PROVIDERS = {
139
157
  keyConsoleUrl: 'https://api.together.xyz/settings/api-keys',
140
158
  docsUrl: 'https://docs.together.ai',
141
159
  keyPrefix: '',
160
+ free: true,
161
+ tierNote: 'Free-tagged models available alongside paid ones',
142
162
  },
143
163
  cerebras: {
144
164
  id: 'cerebras',
@@ -152,6 +172,8 @@ const PROVIDERS = {
152
172
  keyConsoleUrl: 'https://cloud.cerebras.ai',
153
173
  docsUrl: 'https://inference-docs.cerebras.ai',
154
174
  keyPrefix: 'csk-',
175
+ free: true,
176
+ tierNote: 'Free tier — fast inference, daily request caps',
155
177
  },
156
178
  perplexity: {
157
179
  id: 'perplexity',
@@ -165,11 +187,88 @@ const PROVIDERS = {
165
187
  keyConsoleUrl: 'https://www.perplexity.ai/settings/api',
166
188
  docsUrl: 'https://docs.perplexity.ai',
167
189
  keyPrefix: 'pplx-',
190
+ free: false,
191
+ tierNote: 'Paid — usage-based billing',
192
+ },
193
+ huggingface: {
194
+ id: 'huggingface',
195
+ label: 'Hugging Face',
196
+ color: '#ffcc4d',
197
+ baseUrl: 'https://api-inference.huggingface.co',
198
+ modelsEndpoint: '/models',
199
+ chatEndpoint: '/v1/chat/completions',
200
+ authHeader: (key) => ({ Authorization: `Bearer ${key}` }),
201
+ defaultModels: ['meta-llama/Llama-3.1-8B-Instruct', 'Qwen/Qwen2.5-7B-Instruct'],
202
+ keyConsoleUrl: 'https://huggingface.co/settings/tokens',
203
+ docsUrl: 'https://huggingface.co/docs/api-inference',
204
+ keyPrefix: 'hf_',
205
+ free: true,
206
+ tierNote: 'Free Inference API tier with monthly credits',
207
+ },
208
+ fireworks: {
209
+ id: 'fireworks',
210
+ label: 'Fireworks AI',
211
+ color: '#6620ff',
212
+ baseUrl: 'https://api.fireworks.ai/inference/v1',
213
+ modelsEndpoint: '/models',
214
+ chatEndpoint: '/chat/completions',
215
+ authHeader: (key) => ({ Authorization: `Bearer ${key}` }),
216
+ defaultModels: ['accounts/fireworks/models/llama-v3p1-8b-instruct'],
217
+ keyConsoleUrl: 'https://fireworks.ai/api-keys',
218
+ docsUrl: 'https://docs.fireworks.ai',
219
+ keyPrefix: 'fw_',
220
+ free: true,
221
+ tierNote: 'Free trial credits on signup',
222
+ },
223
+ deepinfra: {
224
+ id: 'deepinfra',
225
+ label: 'DeepInfra',
226
+ color: '#00b894',
227
+ baseUrl: 'https://api.deepinfra.com/v1/openai',
228
+ modelsEndpoint: '/models',
229
+ chatEndpoint: '/chat/completions',
230
+ authHeader: (key) => ({ Authorization: `Bearer ${key}` }),
231
+ defaultModels: ['meta-llama/Meta-Llama-3.1-8B-Instruct'],
232
+ keyConsoleUrl: 'https://deepinfra.com/dash/api_keys',
233
+ docsUrl: 'https://deepinfra.com/docs',
234
+ keyPrefix: '',
235
+ free: true,
236
+ tierNote: 'Free trial credits, then low pay-as-you-go pricing',
237
+ },
238
+ githubmodels: {
239
+ id: 'githubmodels',
240
+ label: 'GitHub Models',
241
+ color: '#24292e',
242
+ baseUrl: 'https://models.inference.ai.azure.com',
243
+ modelsEndpoint: '/models',
244
+ chatEndpoint: '/chat/completions',
245
+ authHeader: (key) => ({ Authorization: `Bearer ${key}` }),
246
+ defaultModels: ['gpt-4o-mini', 'Meta-Llama-3.1-8B-Instruct'],
247
+ keyConsoleUrl: 'https://github.com/marketplace/models',
248
+ docsUrl: 'https://docs.github.com/en/github-models',
249
+ keyPrefix: 'github_pat_',
250
+ free: true,
251
+ tierNote: 'Free with a GitHub account, rate-limited playground',
252
+ },
253
+ novita: {
254
+ id: 'novita',
255
+ label: 'Novita AI',
256
+ color: '#6c5ce7',
257
+ baseUrl: 'https://api.novita.ai/v3/openai',
258
+ modelsEndpoint: '/models',
259
+ chatEndpoint: '/chat/completions',
260
+ authHeader: (key) => ({ Authorization: `Bearer ${key}` }),
261
+ defaultModels: ['meta-llama/llama-3.1-8b-instruct'],
262
+ keyConsoleUrl: 'https://novita.ai/settings/key-management',
263
+ docsUrl: 'https://novita.ai/docs',
264
+ keyPrefix: '',
265
+ free: true,
266
+ tierNote: 'Free trial credits on signup',
168
267
  },
169
268
  };
170
269
 
171
270
  // Priority order for the staggered round-robin token optimizer.
172
- const FALLBACK_CHAIN = ['groq', 'cerebras', 'gemini', 'deepseek', 'mistral', 'together', 'xai', 'cohere', 'openai', 'anthropic', 'perplexity', 'openrouter'];
271
+ const FALLBACK_CHAIN = ['groq', 'cerebras', 'gemini', 'deepseek', 'mistral', 'together', 'huggingface', 'fireworks', 'deepinfra', 'githubmodels', 'novita', 'xai', 'cohere', 'openai', 'anthropic', 'perplexity', 'openrouter'];
173
272
 
174
273
  // Providers with a documented image-generation endpoint we can drive from
175
274
  // the same vault key the user already validated for chat.
package/src-client/app.js CHANGED
@@ -375,36 +375,68 @@ function rebuildModelSelector() {
375
375
  // PROVIDERS PAGE
376
376
  // ==========================================================================
377
377
 
378
+ function buildProviderCard(id, meta) {
379
+ const configuredEntries = vault.filter((v) => v.providerId === id);
380
+ const liveModels = configuredEntries.flatMap((e) => (e.status === 'active' ? e.models : []));
381
+
382
+ const card = document.createElement('div');
383
+ card.className = `provider-card ${meta.free ? 'is-free' : 'is-paid'}`;
384
+
385
+ const modelsHtml = liveModels.length > 0
386
+ ? liveModels.slice(0, 6).map((m) => `<span class="live-model-chip">${escapeHtml(m.id)}</span>`).join('')
387
+ : `<span class="live-model-chip none">${t('providers.noKey')}</span>`;
388
+
389
+ const badgeLabel = meta.free ? t('providers.freeBadge') : t('providers.paidBadge');
390
+ const badgeClass = meta.free ? 'badge-free' : 'badge-paid';
391
+
392
+ card.innerHTML = `
393
+ <div class="provider-card-top">
394
+ <span class="provider-dot" style="background:${meta.color}"></span>
395
+ <h3>${escapeHtml(meta.label)}</h3>
396
+ <span class="provider-tier-badge ${badgeClass}">${badgeLabel}</span>
397
+ </div>
398
+ <span class="prefix">Key format: ${escapeHtml(meta.keyPrefix || 'varies')}...</span>
399
+ ${meta.tierNote ? `<span class="tier-note">${escapeHtml(meta.tierNote)}</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
+ return card;
407
+ }
408
+
378
409
  function renderProvidersPage() {
379
410
  const grid = $('provider-grid');
411
+ if (!grid) return;
380
412
  grid.innerHTML = '';
381
413
 
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>
414
+ const ids = Object.keys(PROVIDER_META);
415
+ const freeIds = ids.filter((id) => PROVIDER_META[id].free);
416
+ const paidIds = ids.filter((id) => !PROVIDER_META[id].free);
417
+
418
+ const buildSection = (titleKey, descKey, sectionClass, sectionIds) => {
419
+ if (sectionIds.length === 0) return null;
420
+ const section = document.createElement('div');
421
+ section.className = `provider-section ${sectionClass}`;
422
+ section.innerHTML = `
423
+ <div class="provider-section-header">
424
+ <h2>${t(titleKey)}</h2>
425
+ <p>${t(descKey)}</p>
404
426
  </div>
427
+ <div class="provider-section-grid"></div>
405
428
  `;
406
- grid.appendChild(card);
407
- }
429
+ const sectionGrid = section.querySelector('.provider-section-grid');
430
+ for (const id of sectionIds) {
431
+ sectionGrid.appendChild(buildProviderCard(id, PROVIDER_META[id]));
432
+ }
433
+ return section;
434
+ };
435
+
436
+ const freeSection = buildSection('providers.freeSection', 'providers.freeSectionDesc', 'section-free', freeIds);
437
+ const paidSection = buildSection('providers.paidSection', 'providers.paidSectionDesc', 'section-paid', paidIds);
438
+ if (freeSection) grid.appendChild(freeSection);
439
+ if (paidSection) grid.appendChild(paidSection);
408
440
  }
409
441
 
410
442
  // ==========================================================================
@@ -449,6 +481,38 @@ function buildWavyPath(x1, y1, x2, y2, { waves = 3, amplitude = 10 } = {}) {
449
481
  return d;
450
482
  }
451
483
 
484
+ // Node positions persist across re-renders (and across sessions) so a
485
+ // user's manual drag layout isn't reset every time a key is verified.
486
+ const MAP_LAYOUT_KEY = 'muxmind_map_layout_v1';
487
+ let mapNodePositions = loadMapLayout();
488
+ let mapDragState = null;
489
+
490
+ function loadMapLayout() {
491
+ try { return JSON.parse(localStorage.getItem(MAP_LAYOUT_KEY) || '{}'); } catch { return {}; }
492
+ }
493
+ function persistMapLayout() {
494
+ localStorage.setItem(MAP_LAYOUT_KEY, JSON.stringify(mapNodePositions));
495
+ }
496
+
497
+ function showMapTooltip(el, html) {
498
+ let tip = $('map-tooltip');
499
+ if (!tip) {
500
+ tip = document.createElement('div');
501
+ tip.id = 'map-tooltip';
502
+ tip.className = 'map-tooltip';
503
+ document.body.appendChild(tip);
504
+ }
505
+ tip.innerHTML = html;
506
+ tip.classList.add('visible');
507
+ const rect = el.getBoundingClientRect();
508
+ tip.style.left = `${rect.left + rect.width / 2}px`;
509
+ tip.style.top = `${rect.top - 12}px`;
510
+ }
511
+ function hideMapTooltip() {
512
+ const tip = $('map-tooltip');
513
+ if (tip) tip.classList.remove('visible');
514
+ }
515
+
452
516
  function renderNetworkMap() {
453
517
  const wrap = $('map-canvas-wrap');
454
518
  const svg = $('map-svg');
@@ -469,7 +533,12 @@ function renderNetworkMap() {
469
533
  }
470
534
 
471
535
  emptyState.style.display = 'none';
472
- legend.style.display = 'block';
536
+ legend.style.display = 'flex';
537
+ legend.innerHTML = `
538
+ <div class="map-legend-item"><span class="map-legend-dot" style="background:var(--accent-green);"></span> <span>${t('map.legendFree')}</span></div>
539
+ <div class="map-legend-item"><span class="map-legend-dot" style="background:var(--accent-amber);"></span> <span>${t('map.legendPaid')}</span></div>
540
+ <div class="map-legend-hint">${t('map.hint')}</div>
541
+ `;
473
542
 
474
543
  const w = wrap.clientWidth || 800;
475
544
  const h = wrap.clientHeight || 500;
@@ -484,34 +553,121 @@ function renderNetworkMap() {
484
553
  logoHost.style.top = `${cy - logoSize / 2}px`;
485
554
  injectLogo(logoHost, logoSize);
486
555
 
487
- let linesHtml = '';
556
+ // Resolve each node's position: a saved drag position if present and
557
+ // still in-bounds, otherwise the default ring layout.
558
+ const positions = {};
559
+ configuredIds.forEach((id, i) => {
560
+ const saved = mapNodePositions[id];
561
+ if (saved && saved.nx <= 1 && saved.ny <= 1) {
562
+ positions[id] = { x: saved.nx * w, y: saved.ny * h };
563
+ } else {
564
+ const angle = (i / n) * Math.PI * 2 - Math.PI / 2;
565
+ positions[id] = { x: cx + radius * Math.cos(angle), y: cy + radius * Math.sin(angle) };
566
+ }
567
+ });
568
+
569
+ let defsHtml = `<defs>
570
+ <radialGradient id="mapCoreGlow" cx="50%" cy="50%" r="50%">
571
+ <stop offset="0%" stop-color="var(--accent-cyan)" stop-opacity="0.28"/>
572
+ <stop offset="100%" stop-color="var(--accent-cyan)" stop-opacity="0"/>
573
+ </radialGradient>
574
+ </defs>`;
575
+ let linesHtml = `<circle cx="${cx}" cy="${cy}" r="${radius * 1.5}" fill="url(#mapCoreGlow)"/>`;
488
576
  let nodesHtml = '';
489
577
 
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);
578
+ configuredIds.forEach((id) => {
494
579
  const meta = PROVIDER_META[id];
495
580
  if (!meta) return;
581
+ const { x: nx, y: ny } = positions[id];
496
582
  const entry = vault.find((v) => v.providerId === id && v.status === 'active');
497
583
  const modelName = entry?.activeModel || entry?.models?.[0]?.id || '';
584
+ const tierColor = meta.free ? 'var(--accent-green)' : 'var(--accent-amber)';
498
585
 
499
586
  const wavyD = buildWavyPath(cx, cy, nx, ny, { waves: 3, amplitude: Math.min(14, radius * 0.06) });
500
587
  linesHtml += `<path d="${wavyD}" fill="none" stroke="${meta.color}" stroke-width="2" opacity="0.85" stroke-linecap="round">
501
588
  <animate attributeName="opacity" values="0.85;0.35;0.85" dur="2.4s" repeatCount="indefinite"/>
502
589
  </path>`;
590
+ // Traveling pulse along each connection for a "live traffic" feel.
591
+ linesHtml += `<circle r="3.2" fill="${meta.color}">
592
+ <animateMotion dur="${(2.6 + Math.random() * 1.6).toFixed(2)}s" repeatCount="indefinite" path="${wavyD}"/>
593
+ </circle>`;
503
594
 
504
595
  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>` : ''}
596
+ <g class="map-node" data-provider-id="${id}" transform="translate(${nx},${ny})" style="cursor:grab;">
597
+ <circle r="30" fill="${meta.color}" opacity="0.14" stroke="${meta.color}" stroke-width="1.5">
598
+ <animate attributeName="r" values="30;33;30" dur="3s" repeatCount="indefinite"/>
599
+ </circle>
600
+ <circle r="5.5" fill="${meta.color}"/>
601
+ <circle r="2" fill="${tierColor}" cx="9" cy="-9"/>
602
+ <text y="46" text-anchor="middle" font-size="12" fill="var(--text-primary)" font-family="Segoe UI, sans-serif" font-weight="700">${escapeHtml(meta.label)}</text>
603
+ <text y="60" text-anchor="middle" font-size="9" fill="${tierColor}" font-family="monospace">${t('map.online')}</text>
604
+ ${modelName ? `<text y="72" text-anchor="middle" font-size="8" fill="var(--text-muted)" font-family="monospace">${escapeHtml(modelName.slice(0, 22))}</text>` : ''}
511
605
  </g>`;
512
606
  });
513
607
 
514
- svg.innerHTML = linesHtml + nodesHtml;
608
+ svg.innerHTML = defsHtml + linesHtml + nodesHtml;
609
+
610
+ // --- Interactivity: drag to reposition, hover for detail tooltip, click
611
+ // (without drag) to jump into the Vault for that provider. ---
612
+ svg.querySelectorAll('.map-node').forEach((nodeEl) => {
613
+ const id = nodeEl.dataset.providerId;
614
+ const meta = PROVIDER_META[id];
615
+ const entry = vault.find((v) => v.providerId === id && v.status === 'active');
616
+
617
+ nodeEl.addEventListener('mouseenter', () => {
618
+ if (mapDragState) return;
619
+ const badge = meta.free ? t('providers.freeBadge') : t('providers.paidBadge');
620
+ showMapTooltip(nodeEl, `
621
+ <strong>${escapeHtml(meta.label)}</strong> <span class="map-tooltip-badge">${badge}</span>
622
+ <div>${t('map.status')}: <span style="color:var(--accent-green)">${t('map.online')}</span></div>
623
+ ${entry?.activeModel ? `<div>${t('map.model')}: ${escapeHtml(entry.activeModel)}</div>` : ''}
624
+ `);
625
+ });
626
+ nodeEl.addEventListener('mouseleave', () => { if (!mapDragState) hideMapTooltip(); });
627
+ nodeEl.addEventListener('mousedown', (e) => startNodeDrag(e, id, nodeEl));
628
+ nodeEl.addEventListener('touchstart', (e) => startNodeDrag(e, id, nodeEl), { passive: true });
629
+ nodeEl.addEventListener('click', () => {
630
+ if (nodeEl._didDrag) { nodeEl._didDrag = false; return; }
631
+ hideMapTooltip();
632
+ $('btn-api-manager')?.click();
633
+ });
634
+ });
635
+ }
636
+
637
+ function startNodeDrag(evt, id, nodeEl) {
638
+ const svg = $('map-svg');
639
+ const wrap = $('map-canvas-wrap');
640
+ if (!svg || !wrap) return;
641
+ nodeEl.style.cursor = 'grabbing';
642
+ hideMapTooltip();
643
+ mapDragState = { id, nodeEl };
644
+
645
+ const rect = wrap.getBoundingClientRect();
646
+ const w = rect.width, h = rect.height;
647
+
648
+ const move = (e) => {
649
+ mapDragState.moved = true;
650
+ const point = e.touches ? e.touches[0] : e;
651
+ const x = Math.max(20, Math.min(w - 20, point.clientX - rect.left));
652
+ const y = Math.max(20, Math.min(h - 20, point.clientY - rect.top));
653
+ nodeEl.setAttribute('transform', `translate(${x},${y})`);
654
+ mapNodePositions[id] = { nx: x / w, ny: y / h };
655
+ };
656
+ const up = () => {
657
+ if (mapDragState?.moved) nodeEl._didDrag = true;
658
+ persistMapLayout();
659
+ nodeEl.style.cursor = 'grab';
660
+ mapDragState = null;
661
+ document.removeEventListener('mousemove', move);
662
+ document.removeEventListener('mouseup', up);
663
+ document.removeEventListener('touchmove', move);
664
+ document.removeEventListener('touchend', up);
665
+ renderNetworkMap();
666
+ };
667
+ document.addEventListener('mousemove', move);
668
+ document.addEventListener('mouseup', up);
669
+ document.addEventListener('touchmove', move, { passive: true });
670
+ document.addEventListener('touchend', up);
515
671
  }
516
672
 
517
673
  window.addEventListener('resize', () => {
@@ -613,6 +769,8 @@ function setupHistoryUI() {
613
769
  }
614
770
 
615
771
  function startNewChat() {
772
+ // Make sure the chat we're leaving is safely on disk before we clear it.
773
+ if (conversation.length > 0) saveActiveChat();
616
774
  activeChatId = null;
617
775
  clearChatUIOnly();
618
776
  renderHistoryList();
@@ -643,6 +801,37 @@ function saveActiveChat() {
643
801
  renderHistoryList();
644
802
  }
645
803
 
804
+ // While an assistant reply is streaming in, persist the in-progress text
805
+ // every couple of seconds (throttled) so closing the tab mid-stream never
806
+ // loses the conversation — only the final few chunks are ever at risk.
807
+ let _partialSaveTimer = null;
808
+ function schedulePartialSave(partialText) {
809
+ if (_partialSaveTimer) return;
810
+ _partialSaveTimer = setTimeout(() => {
811
+ _partialSaveTimer = null;
812
+ const snapshot = [...conversation, { role: 'assistant', content: partialText }];
813
+ if (!activeChatId) activeChatId = `chat_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`;
814
+ const existingIdx = chatHistory.findIndex((c) => c.id === activeChatId);
815
+ const record = { id: activeChatId, title: currentChatTitle(), updatedAt: Date.now(), messages: snapshot };
816
+ if (existingIdx >= 0) chatHistory[existingIdx] = record;
817
+ else chatHistory.push(record);
818
+ persistHistory();
819
+ }, 1500);
820
+ }
821
+
822
+ // Final safety net: flush whatever we have the instant the tab is hidden,
823
+ // backgrounded, or closed — covers mobile app-switching and desktop
824
+ // tab-close alike (visibilitychange fires reliably in both cases, unlike
825
+ // beforeunload on some mobile browsers).
826
+ function flushChatOnExit() {
827
+ try { saveActiveChat(); } catch {}
828
+ }
829
+ document.addEventListener('visibilitychange', () => {
830
+ if (document.visibilityState === 'hidden') flushChatOnExit();
831
+ });
832
+ window.addEventListener('pagehide', flushChatOnExit);
833
+ window.addEventListener('beforeunload', flushChatOnExit);
834
+
646
835
  function loadChat(chatId) {
647
836
  const record = chatHistory.find((c) => c.id === chatId);
648
837
  if (!record) return;
@@ -968,6 +1157,10 @@ async function sendMessage() {
968
1157
  attachedFiles = [];
969
1158
  renderFileChips();
970
1159
 
1160
+ // Persist immediately so the message survives a closed tab / crash even
1161
+ // before the assistant reply finishes streaming.
1162
+ saveActiveChat();
1163
+
971
1164
  appendTypingIndicator();
972
1165
  setStreamingState(true);
973
1166
 
@@ -1020,6 +1213,7 @@ async function sendMessage() {
1020
1213
  accumulatedText += data.text;
1021
1214
  assistantBubble.textContent = accumulatedText;
1022
1215
  scrollToBottomIfAllowed();
1216
+ schedulePartialSave(accumulatedText);
1023
1217
  } else if (eventType === 'provider') {
1024
1218
  providersUsed.add(`${data.provider}:${data.model}`);
1025
1219
  setStatus('streaming', `Engine: ${data.provider} → ${data.model}`);
@@ -1097,6 +1291,7 @@ function clearChatUIOnly() {
1097
1291
  }
1098
1292
 
1099
1293
  function clearChat() {
1294
+ if (conversation.length > 0) saveActiveChat();
1100
1295
  clearChatUIOnly();
1101
1296
  activeChatId = null;
1102
1297
  renderHistoryList();
@@ -47,10 +47,22 @@ const I18N_DICT = {
47
47
  'providers.getKey': 'Get API Key ↗',
48
48
  'providers.docs': 'Docs ↗',
49
49
  'providers.noKey': 'No key configured yet',
50
+ 'providers.freeSection': '🆓 Free Providers',
51
+ 'providers.freeSectionDesc': 'No card required to start — generous free tiers or trial credits.',
52
+ 'providers.paidSection': '💳 Paid Providers',
53
+ 'providers.paidSectionDesc': 'Usage-based billing — best raw quality and highest limits.',
54
+ 'providers.freeBadge': 'Free',
55
+ 'providers.paidBadge': 'Paid',
50
56
  'map.empty': 'No providers connected yet. Add a key in the API Vault and it will appear here automatically.',
51
57
  'map.active': 'Active provider',
52
58
  'map.notConfigured': 'NOT CONFIGURED',
53
59
  'map.online': 'ONLINE',
60
+ 'map.hint': 'Drag to rearrange · hover a node for details · click to open in Vault',
61
+ 'map.legendFree': 'Free provider',
62
+ 'map.legendPaid': 'Paid provider',
63
+ 'map.model': 'Model',
64
+ 'map.status': 'Status',
65
+ 'map.tier': 'Tier',
54
66
  'settings.title': '⚙️ Settings',
55
67
  'settings.subtitle': 'Appearance, language, security, and session preferences.',
56
68
  'settings.appearance': 'Appearance',
@@ -140,6 +152,18 @@ const I18N_DICT = {
140
152
  'providers.getKey': 'احصل على مفتاح ↗',
141
153
  'providers.docs': 'التوثيق ↗',
142
154
  'providers.noKey': 'لم يُضَف مفتاح بعد',
155
+ 'providers.freeSection': '🆓 مزوّدون مجانيون',
156
+ 'providers.freeSectionDesc': 'لا تحتاج بطاقة دفع للبدء — حدود مجانية سخية أو رصيد تجريبي.',
157
+ 'providers.paidSection': '💳 مزوّدون مدفوعون',
158
+ 'providers.paidSectionDesc': 'فوترة حسب الاستخدام — أفضل جودة وأعلى الحدود.',
159
+ 'providers.freeBadge': 'مجاني',
160
+ 'providers.paidBadge': 'مدفوع',
161
+ 'map.hint': 'اسحب لإعادة الترتيب · مرّر المؤشر لرؤية التفاصيل · اضغط لفتح الخزنة',
162
+ 'map.legendFree': 'مزوّد مجاني',
163
+ 'map.legendPaid': 'مزوّد مدفوع',
164
+ 'map.model': 'النموذج',
165
+ 'map.status': 'الحالة',
166
+ 'map.tier': 'الفئة',
143
167
  'map.empty': 'لا يوجد مزوّدون متصلون بعد. أضف مفتاحًا في خزنة API وسيظهر هنا تلقائيًا.',
144
168
  'map.active': 'مزوّد نشط',
145
169
  'map.notConfigured': 'غير مُهيّأ',
package/style.css CHANGED
@@ -481,31 +481,85 @@ select#model-selector-dropdown {
481
481
  .page-header h1 { font-size: 1.4rem; margin-bottom: 6px; }
482
482
  .page-header p { color: var(--text-secondary); font-size: 0.88rem; }
483
483
 
484
- .provider-grid {
484
+ .provider-section { margin-bottom: 32px; }
485
+ .provider-section:last-child { margin-bottom: 0; }
486
+
487
+ .provider-section-header { margin-bottom: 16px; display: flex; flex-direction: column; gap: 4px; }
488
+ .provider-section-header h2 {
489
+ font-size: 1.05rem;
490
+ font-weight: 700;
491
+ letter-spacing: 0.02em;
492
+ }
493
+ .provider-section-header p { color: var(--text-secondary); font-size: 0.82rem; }
494
+
495
+ .provider-section.section-free .provider-section-header h2 { color: var(--accent-green); }
496
+ .provider-section.section-paid .provider-section-header h2 { color: var(--accent-amber); }
497
+
498
+ .provider-section-grid {
485
499
  display: grid;
486
500
  grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
487
501
  gap: 16px;
488
502
  }
489
503
 
490
504
  .provider-card {
505
+ position: relative;
491
506
  background: var(--bg-panel);
492
507
  backdrop-filter: blur(14px);
493
508
  border: 1px solid var(--border-glass);
494
509
  border-radius: var(--radius-lg);
495
510
  padding: 18px;
496
511
  display: flex; flex-direction: column; gap: 10px;
497
- transition: transform 0.18s ease, box-shadow 0.18s ease;
512
+ overflow: hidden;
513
+ transition: transform 0.2s cubic-bezier(.2,.8,.2,1), box-shadow 0.2s ease, border-color 0.2s ease;
498
514
  }
499
515
 
500
- .provider-card:hover { transform: translateY(-3px); box-shadow: var(--shadow-glow); }
516
+ .provider-card::before {
517
+ content: '';
518
+ position: absolute; inset: 0;
519
+ background: linear-gradient(135deg, rgba(255,255,255,0.05), transparent 60%);
520
+ pointer-events: none;
521
+ }
522
+
523
+ .provider-card:hover {
524
+ transform: translateY(-4px) scale(1.01);
525
+ box-shadow: var(--shadow-glow), var(--shadow-deep);
526
+ border-color: var(--accent-cyan-dim);
527
+ }
528
+
529
+ .provider-card.is-free:hover { border-color: rgba(46,230,166,0.45); }
530
+ .provider-card.is-paid:hover { border-color: rgba(255,176,32,0.45); }
501
531
 
502
532
  .provider-card-top { display: flex; align-items: center; gap: 10px; }
503
533
 
504
- .provider-dot { width: 12px; height: 12px; border-radius: 50%; flex-shrink: 0; }
534
+ .provider-dot {
535
+ width: 12px; height: 12px; border-radius: 50%; flex-shrink: 0;
536
+ box-shadow: 0 0 10px currentColor;
537
+ }
538
+
539
+ .provider-card h3 { font-size: 1rem; flex: 1; }
505
540
 
506
- .provider-card h3 { font-size: 1rem; }
541
+ .provider-tier-badge {
542
+ font-size: 0.62rem;
543
+ font-weight: 800;
544
+ letter-spacing: 0.04em;
545
+ text-transform: uppercase;
546
+ padding: 3px 9px;
547
+ border-radius: 20px;
548
+ flex-shrink: 0;
549
+ }
550
+ .provider-tier-badge.badge-free {
551
+ background: linear-gradient(135deg, rgba(46,230,166,0.22), rgba(46,230,166,0.08));
552
+ color: var(--accent-green);
553
+ border: 1px solid rgba(46,230,166,0.4);
554
+ }
555
+ .provider-tier-badge.badge-paid {
556
+ background: linear-gradient(135deg, rgba(255,176,32,0.22), rgba(255,176,32,0.08));
557
+ color: var(--accent-amber);
558
+ border: 1px solid rgba(255,176,32,0.4);
559
+ }
507
560
 
508
561
  .provider-card .prefix { font-family: var(--font-mono); font-size: 0.72rem; color: var(--text-muted); }
562
+ .provider-card .tier-note { font-size: 0.74rem; color: var(--text-secondary); line-height: 1.4; }
509
563
 
510
564
  .provider-card-links { display: flex; gap: 8px; margin-top: auto; }
511
565
 
@@ -687,7 +741,54 @@ select#model-selector-dropdown {
687
741
  position: relative;
688
742
  display: flex; align-items: center; justify-content: center;
689
743
  overflow: hidden;
744
+ border-radius: var(--radius-lg);
745
+ background:
746
+ radial-gradient(circle at 50% 45%, rgba(0,242,254,0.05), transparent 60%),
747
+ linear-gradient(rgba(255,255,255,0.025) 1px, transparent 1px),
748
+ linear-gradient(90deg, rgba(255,255,255,0.025) 1px, transparent 1px);
749
+ background-size: auto, 34px 34px, 34px 34px;
750
+ border: 1px solid var(--border-glass);
751
+ }
752
+ [data-theme="light"] .map-canvas-wrap {
753
+ background:
754
+ radial-gradient(circle at 50% 45%, rgba(0,144,168,0.05), transparent 60%),
755
+ linear-gradient(rgba(10,20,40,0.035) 1px, transparent 1px),
756
+ linear-gradient(90deg, rgba(10,20,40,0.035) 1px, transparent 1px);
757
+ background-size: auto, 34px 34px, 34px 34px;
758
+ }
759
+
760
+ .map-node { transition: filter 0.15s ease; }
761
+ .map-node:hover { filter: brightness(1.25); }
762
+
763
+ .map-tooltip {
764
+ position: fixed;
765
+ transform: translate(-50%, -100%);
766
+ background: var(--bg-panel-solid);
767
+ border: 1px solid var(--accent-cyan-dim);
768
+ border-radius: var(--radius-sm);
769
+ padding: 10px 14px;
770
+ font-size: 0.76rem;
771
+ color: var(--text-primary);
772
+ box-shadow: var(--shadow-deep), var(--shadow-glow);
773
+ pointer-events: none;
774
+ opacity: 0;
775
+ visibility: hidden;
776
+ transition: opacity 0.15s ease;
777
+ z-index: 50;
778
+ white-space: nowrap;
690
779
  }
780
+ .map-tooltip.visible { opacity: 1; visibility: visible; }
781
+ .map-tooltip strong { color: var(--accent-cyan); }
782
+ .map-tooltip-badge {
783
+ font-size: 0.6rem;
784
+ font-weight: 800;
785
+ padding: 1px 7px;
786
+ border-radius: 10px;
787
+ background: rgba(0,242,254,0.12);
788
+ color: var(--accent-cyan);
789
+ margin-inline-start: 6px;
790
+ }
791
+ .map-tooltip div { color: var(--text-secondary); margin-top: 2px; }
691
792
 
692
793
  .map-legend {
693
794
  position: absolute; bottom: 20px; left: 20px;
@@ -699,10 +800,13 @@ select#model-selector-dropdown {
699
800
  font-size: 0.74rem;
700
801
  color: var(--text-secondary);
701
802
  display: flex; flex-direction: column; gap: 6px;
803
+ box-shadow: var(--shadow-deep);
702
804
  }
805
+ [dir="rtl"] .map-legend { left: auto; right: 20px; }
703
806
 
704
807
  .map-legend-item { display: flex; align-items: center; gap: 8px; }
705
- .map-legend-dot { width: 9px; height: 9px; border-radius: 50%; }
808
+ .map-legend-dot { width: 9px; height: 9px; border-radius: 50%; box-shadow: 0 0 8px currentColor; }
809
+ .map-legend-hint { font-size: 0.68rem; color: var(--text-muted); border-top: 1px solid var(--border-glass); padding-top: 6px; margin-top: 2px; }
706
810
 
707
811
  /* ---------------------------------------------------------------------- */
708
812
  /* Empty state */