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 +2 -2
- package/server.js +2 -0
- package/src/config.js +100 -1
- package/src-client/app.js +232 -37
- package/src-client/i18n.js +24 -0
- package/style.css +110 -6
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "muxmind-ai",
|
|
3
|
-
"version": "2.
|
|
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": "^
|
|
30
|
+
"multer": "^2.0.1"
|
|
31
31
|
},
|
|
32
32
|
"engines": {
|
|
33
33
|
"node": ">=18.0.0"
|
package/server.js
CHANGED
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
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
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
|
-
|
|
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 = '
|
|
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
|
-
|
|
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
|
|
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="
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
<
|
|
510
|
-
|
|
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();
|
package/src-client/i18n.js
CHANGED
|
@@ -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-
|
|
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
|
-
|
|
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
|
|
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 {
|
|
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-
|
|
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 */
|