total-recall-brain 3.12.2 → 3.13.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/frontend/package.json +2 -1
- package/frontend/src/App.tsx +44 -0
- package/frontend/src/api.ts +101 -1
- package/frontend/src/components/UsageChart.tsx +78 -0
- package/frontend/src/pages/DesignDocsPage.tsx +614 -0
- package/frontend/src/pages/InstructionsPage.tsx +583 -0
- package/frontend/src/pages/ModelsPage.tsx +280 -23
- package/frontend/src/pages/OkfPage.tsx +535 -0
- package/frontend/src/pages/OpenWikiPage.tsx +472 -0
- package/frontend/src/pages/SettingsPage.tsx +183 -0
- package/frontend/src/pages/SkillsPage.tsx +1054 -37
- package/frontend/src/pages/UsagePage.tsx +254 -1
- package/frontend/src/types.ts +160 -0
- package/package.json +1 -1
- package/src/server/api.mjs +2 -0
- package/src/server/auth.mjs +2 -2
- package/src/server/rest.mjs +207 -43
- package/src/server/route-manifest.json +12 -0
- package/src/server/routes/_shared.mjs +1 -1
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { useState, useEffect, useCallback } from 'react';
|
|
2
|
-
import { fetchHealth, runAgentDiagnostics, fetchConfigJson, saveConfigJson } from '../api';
|
|
3
|
-
import
|
|
2
|
+
import { fetchHealth, runAgentDiagnostics, fetchConfigJson, saveConfigJson, fetchUsageStats, fetchGeminiModels, fetchClaudeModels, fetchOpenaiModels, fetchOpenRouterModels } from '../api';
|
|
3
|
+
import { UsageChart } from '../components/UsageChart';
|
|
4
|
+
import type { HealthData, ConfigJson, UsageData, GeminiModelInfo } from '../types';
|
|
4
5
|
|
|
5
6
|
export default function ModelsPage() {
|
|
6
7
|
const [health, setHealth] = useState<HealthData | null>(null);
|
|
@@ -14,6 +15,15 @@ export default function ModelsPage() {
|
|
|
14
15
|
const [runningDiagnostics, setRunningDiagnostics] = useState(false);
|
|
15
16
|
const [diagnosticLogs, setDiagnosticLogs] = useState<string | null>(null);
|
|
16
17
|
|
|
18
|
+
// OpenRouter state
|
|
19
|
+
const [orModels, setOrModels] = useState<GeminiModelInfo[]>([]);
|
|
20
|
+
|
|
21
|
+
// Global usage & dynamic models state
|
|
22
|
+
const [usageStats, setUsageStats] = useState<UsageData | null>(null);
|
|
23
|
+
const [geminiModels, setGeminiModels] = useState<GeminiModelInfo[]>([]);
|
|
24
|
+
const [claudeModels, setClaudeModels] = useState<GeminiModelInfo[]>([]);
|
|
25
|
+
const [openaiModels, setOpenaiModels] = useState<GeminiModelInfo[]>([]);
|
|
26
|
+
|
|
17
27
|
const fetchSystemData = useCallback(async () => {
|
|
18
28
|
try {
|
|
19
29
|
const systemHealth = await fetchHealth();
|
|
@@ -24,6 +34,21 @@ export default function ModelsPage() {
|
|
|
24
34
|
if (!data.brain) data.brain = {};
|
|
25
35
|
setConfigData(data);
|
|
26
36
|
|
|
37
|
+
const stats = await fetchUsageStats().catch(() => null);
|
|
38
|
+
setUsageStats(stats);
|
|
39
|
+
|
|
40
|
+
const gemModels = await fetchGeminiModels().catch(() => []);
|
|
41
|
+
setGeminiModels(gemModels);
|
|
42
|
+
|
|
43
|
+
const claModels = await fetchClaudeModels().catch(() => []);
|
|
44
|
+
setClaudeModels(claModels);
|
|
45
|
+
|
|
46
|
+
const openModels = await fetchOpenaiModels().catch(() => []);
|
|
47
|
+
setOpenaiModels(openModels);
|
|
48
|
+
|
|
49
|
+
const openRouterModels = await fetchOpenRouterModels().catch(() => []);
|
|
50
|
+
setOrModels(openRouterModels);
|
|
51
|
+
|
|
27
52
|
setError(null);
|
|
28
53
|
} catch (err: unknown) {
|
|
29
54
|
setError((err as Error).message || 'Failed to sync system data.');
|
|
@@ -108,9 +133,14 @@ export default function ModelsPage() {
|
|
|
108
133
|
return (
|
|
109
134
|
<div className="page" style={{ display: 'flex', flexDirection: 'column', height: '100%', overflowY: 'auto' }}>
|
|
110
135
|
<div className="page-header" style={{ marginBottom: 24, display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start' }}>
|
|
111
|
-
<div>
|
|
112
|
-
<
|
|
113
|
-
|
|
136
|
+
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
|
|
137
|
+
<button className="md-hidden" onClick={() => document.querySelector('.sidebar')?.classList.add('open')} style={{ background: 'none', border: 'none', color: 'var(--text-secondary)', cursor: 'pointer', padding: 4 }}>
|
|
138
|
+
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><line x1="3" y1="12" x2="21" y2="12"></line><line x1="3" y1="6" x2="21" y2="6"></line><line x1="3" y1="18" x2="21" y2="18"></line></svg>
|
|
139
|
+
</button>
|
|
140
|
+
<div>
|
|
141
|
+
<h1>Models & Agents</h1>
|
|
142
|
+
<p>Configure Bring Your Own Model (BYOM) settings and manage active reasoning agents</p>
|
|
143
|
+
</div>
|
|
114
144
|
</div>
|
|
115
145
|
{configData && (
|
|
116
146
|
<button
|
|
@@ -207,10 +237,20 @@ export default function ModelsPage() {
|
|
|
207
237
|
</div>
|
|
208
238
|
|
|
209
239
|
{/* Google Gemini API Key */}
|
|
210
|
-
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
|
211
|
-
<
|
|
212
|
-
|
|
213
|
-
|
|
240
|
+
<div style={{ display: 'flex', flexDirection: 'column', gap: 6, paddingBottom: 16, borderBottom: '1px solid rgba(255,255,255,0.05)' }}>
|
|
241
|
+
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
|
242
|
+
<label htmlFor="google_api_key" style={{ fontSize: 13, fontWeight: 500 }}>Google Gemini API Key</label>
|
|
243
|
+
<label style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 11, cursor: 'pointer', color: configData?.brain?.preferred_agent === 'gemini' ? 'var(--accent)' : 'var(--text-tertiary)' }}>
|
|
244
|
+
<input
|
|
245
|
+
type="radio"
|
|
246
|
+
name="preferred_agent"
|
|
247
|
+
checked={configData?.brain?.preferred_agent === 'gemini'}
|
|
248
|
+
onChange={() => updateBrainProp('preferred_agent', 'gemini')}
|
|
249
|
+
/>
|
|
250
|
+
Set Active
|
|
251
|
+
</label>
|
|
252
|
+
</div>
|
|
253
|
+
<form onSubmit={e => e.preventDefault()} style={{display:'inline',margin:0,padding:0,width:'100%'}}><input id="google_api_key"
|
|
214
254
|
type="password"
|
|
215
255
|
placeholder="AIzaSy..."
|
|
216
256
|
value={configData?.secrets?.google_api_key || ''}
|
|
@@ -224,14 +264,58 @@ export default function ModelsPage() {
|
|
|
224
264
|
outline: 'none',
|
|
225
265
|
fontSize: 13
|
|
226
266
|
}}
|
|
227
|
-
|
|
228
|
-
|
|
267
|
+
/></form>
|
|
268
|
+
|
|
269
|
+
<select
|
|
270
|
+
disabled={!configData?.secrets?.google_api_key}
|
|
271
|
+
value={configData?.brain?.gemini_model || ''}
|
|
272
|
+
onChange={(e) => updateBrainProp('gemini_model', e.target.value)}
|
|
273
|
+
style={{
|
|
274
|
+
background: 'var(--bg-tertiary)',
|
|
275
|
+
color: 'var(--text-primary)',
|
|
276
|
+
border: '1px solid var(--border)',
|
|
277
|
+
padding: '8px 12px',
|
|
278
|
+
borderRadius: 6,
|
|
279
|
+
outline: 'none',
|
|
280
|
+
fontSize: 13,
|
|
281
|
+
marginTop: 4
|
|
282
|
+
}}
|
|
283
|
+
>
|
|
284
|
+
<option value="">Default Gemini Model</option>
|
|
285
|
+
{geminiModels.map(m => {
|
|
286
|
+
let costStr = '';
|
|
287
|
+
if (m.pricing && m.pricing.prompt && m.pricing.completion) {
|
|
288
|
+
const promptCost = (parseFloat(m.pricing.prompt as string) * 1000000).toFixed(2);
|
|
289
|
+
const compCost = (parseFloat(m.pricing.completion as string) * 1000000).toFixed(2);
|
|
290
|
+
costStr = ` - $${promptCost}/$${compCost} per 1M`;
|
|
291
|
+
}
|
|
292
|
+
return (
|
|
293
|
+
<option key={m.id} value={m.id}>{m.displayName} ({m.id}){costStr}</option>
|
|
294
|
+
);
|
|
295
|
+
})}
|
|
296
|
+
</select>
|
|
297
|
+
{!configData?.secrets?.google_api_key && (
|
|
298
|
+
<div style={{ fontSize: 11, color: 'var(--text-error, #f44336)', marginTop: 8 }}>
|
|
299
|
+
⚠ API Key required to unlock model selection
|
|
300
|
+
</div>
|
|
301
|
+
)}
|
|
302
|
+
</div>
|
|
229
303
|
|
|
230
304
|
{/* Anthropic API Key */}
|
|
231
|
-
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
|
232
|
-
<
|
|
233
|
-
|
|
234
|
-
|
|
305
|
+
<div style={{ display: 'flex', flexDirection: 'column', gap: 6, paddingBottom: 16, borderBottom: '1px solid rgba(255,255,255,0.05)' }}>
|
|
306
|
+
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
|
307
|
+
<label htmlFor="anthropic_api_key" style={{ fontSize: 13, fontWeight: 500 }}>Anthropic API Key (Claude)</label>
|
|
308
|
+
<label style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 11, cursor: 'pointer', color: configData?.brain?.preferred_agent === 'claude' ? 'var(--accent)' : 'var(--text-tertiary)' }}>
|
|
309
|
+
<input
|
|
310
|
+
type="radio"
|
|
311
|
+
name="preferred_agent"
|
|
312
|
+
checked={configData?.brain?.preferred_agent === 'claude'}
|
|
313
|
+
onChange={() => updateBrainProp('preferred_agent', 'claude')}
|
|
314
|
+
/>
|
|
315
|
+
Set Active
|
|
316
|
+
</label>
|
|
317
|
+
</div>
|
|
318
|
+
<form onSubmit={e => e.preventDefault()} style={{display:'inline',margin:0,padding:0,width:'100%'}}><input id="anthropic_api_key"
|
|
235
319
|
type="password"
|
|
236
320
|
placeholder="sk-ant-..."
|
|
237
321
|
value={configData?.secrets?.anthropic_api_key || ''}
|
|
@@ -245,14 +329,58 @@ export default function ModelsPage() {
|
|
|
245
329
|
outline: 'none',
|
|
246
330
|
fontSize: 13
|
|
247
331
|
}}
|
|
248
|
-
|
|
249
|
-
|
|
332
|
+
/></form>
|
|
333
|
+
|
|
334
|
+
<select
|
|
335
|
+
disabled={!configData?.secrets?.anthropic_api_key}
|
|
336
|
+
value={configData?.brain?.claude_model || ''}
|
|
337
|
+
onChange={(e) => updateBrainProp('claude_model', e.target.value)}
|
|
338
|
+
style={{
|
|
339
|
+
background: 'var(--bg-tertiary)',
|
|
340
|
+
color: 'var(--text-primary)',
|
|
341
|
+
border: '1px solid var(--border)',
|
|
342
|
+
padding: '8px 12px',
|
|
343
|
+
borderRadius: 6,
|
|
344
|
+
outline: 'none',
|
|
345
|
+
fontSize: 13,
|
|
346
|
+
marginTop: 4
|
|
347
|
+
}}
|
|
348
|
+
>
|
|
349
|
+
<option value="">Default Claude Model</option>
|
|
350
|
+
{claudeModels.map(m => {
|
|
351
|
+
let costStr = '';
|
|
352
|
+
if (m.pricing && m.pricing.prompt && m.pricing.completion) {
|
|
353
|
+
const promptCost = (parseFloat(m.pricing.prompt as string) * 1000000).toFixed(2);
|
|
354
|
+
const compCost = (parseFloat(m.pricing.completion as string) * 1000000).toFixed(2);
|
|
355
|
+
costStr = ` - $${promptCost}/$${compCost} per 1M`;
|
|
356
|
+
}
|
|
357
|
+
return (
|
|
358
|
+
<option key={m.id} value={m.id}>{m.displayName} ({m.id}){costStr}</option>
|
|
359
|
+
);
|
|
360
|
+
})}
|
|
361
|
+
</select>
|
|
362
|
+
{!configData?.secrets?.anthropic_api_key && (
|
|
363
|
+
<div style={{ fontSize: 11, color: 'var(--text-error, #f44336)', marginTop: 8 }}>
|
|
364
|
+
⚠ API Key required to unlock model selection
|
|
365
|
+
</div>
|
|
366
|
+
)}
|
|
367
|
+
</div>
|
|
250
368
|
|
|
251
369
|
{/* OpenAI API Key */}
|
|
252
|
-
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
|
253
|
-
<
|
|
254
|
-
|
|
255
|
-
|
|
370
|
+
<div style={{ display: 'flex', flexDirection: 'column', gap: 6, paddingBottom: 16, borderBottom: '1px solid rgba(255,255,255,0.05)' }}>
|
|
371
|
+
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
|
372
|
+
<label htmlFor="openai_api_key" style={{ fontSize: 13, fontWeight: 500 }}>OpenAI API Key (Codex)</label>
|
|
373
|
+
<label style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 11, cursor: 'pointer', color: configData?.brain?.preferred_agent === 'codex' ? 'var(--accent)' : 'var(--text-tertiary)' }}>
|
|
374
|
+
<input
|
|
375
|
+
type="radio"
|
|
376
|
+
name="preferred_agent"
|
|
377
|
+
checked={configData?.brain?.preferred_agent === 'codex'}
|
|
378
|
+
onChange={() => updateBrainProp('preferred_agent', 'codex')}
|
|
379
|
+
/>
|
|
380
|
+
Set Active
|
|
381
|
+
</label>
|
|
382
|
+
</div>
|
|
383
|
+
<form onSubmit={e => e.preventDefault()} style={{display:'inline',margin:0,padding:0,width:'100%'}}><input id="openai_api_key"
|
|
256
384
|
type="password"
|
|
257
385
|
placeholder="sk-proj-..."
|
|
258
386
|
value={configData?.secrets?.openai_api_key || ''}
|
|
@@ -266,15 +394,144 @@ export default function ModelsPage() {
|
|
|
266
394
|
outline: 'none',
|
|
267
395
|
fontSize: 13
|
|
268
396
|
}}
|
|
269
|
-
|
|
397
|
+
/></form>
|
|
398
|
+
|
|
399
|
+
<select
|
|
400
|
+
disabled={!configData?.secrets?.openai_api_key}
|
|
401
|
+
value={configData?.brain?.openai_model || ''}
|
|
402
|
+
onChange={(e) => updateBrainProp('openai_model', e.target.value)}
|
|
403
|
+
style={{
|
|
404
|
+
background: 'var(--bg-tertiary)',
|
|
405
|
+
color: 'var(--text-primary)',
|
|
406
|
+
border: '1px solid var(--border)',
|
|
407
|
+
padding: '8px 12px',
|
|
408
|
+
borderRadius: 6,
|
|
409
|
+
outline: 'none',
|
|
410
|
+
fontSize: 13,
|
|
411
|
+
marginTop: 4
|
|
412
|
+
}}
|
|
413
|
+
>
|
|
414
|
+
<option value="">Default OpenAI Model</option>
|
|
415
|
+
{openaiModels.map(m => {
|
|
416
|
+
let costStr = '';
|
|
417
|
+
if (m.pricing && m.pricing.prompt && m.pricing.completion) {
|
|
418
|
+
const promptCost = (parseFloat(m.pricing.prompt as string) * 1000000).toFixed(2);
|
|
419
|
+
const compCost = (parseFloat(m.pricing.completion as string) * 1000000).toFixed(2);
|
|
420
|
+
costStr = ` - $${promptCost}/$${compCost} per 1M`;
|
|
421
|
+
}
|
|
422
|
+
return (
|
|
423
|
+
<option key={m.id} value={m.id}>{m.displayName} ({m.id}){costStr}</option>
|
|
424
|
+
);
|
|
425
|
+
})}
|
|
426
|
+
</select>
|
|
427
|
+
{!configData?.secrets?.openai_api_key && (
|
|
428
|
+
<div style={{ fontSize: 11, color: 'var(--text-error, #f44336)', marginTop: 8 }}>
|
|
429
|
+
⚠ API Key required to unlock model selection
|
|
430
|
+
</div>
|
|
431
|
+
)}
|
|
432
|
+
</div>
|
|
433
|
+
|
|
434
|
+
{/* OpenRouter API Key */}
|
|
435
|
+
<div style={{ display: 'flex', flexDirection: 'column', gap: 6, paddingBottom: 8 }}>
|
|
436
|
+
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
|
437
|
+
<label htmlFor="openrouter_api_key" style={{ fontSize: 13, fontWeight: 500 }}>OpenRouter API Key</label>
|
|
438
|
+
<label style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 11, cursor: 'pointer', color: configData?.brain?.preferred_agent === 'openrouter' ? 'var(--accent)' : 'var(--text-tertiary)' }}>
|
|
439
|
+
<input
|
|
440
|
+
type="radio"
|
|
441
|
+
name="preferred_agent"
|
|
442
|
+
checked={configData?.brain?.preferred_agent === 'openrouter'}
|
|
443
|
+
onChange={() => updateBrainProp('preferred_agent', 'openrouter')}
|
|
444
|
+
/>
|
|
445
|
+
Set Active
|
|
446
|
+
</label>
|
|
447
|
+
</div>
|
|
448
|
+
<form onSubmit={e => e.preventDefault()} style={{display:'inline',margin:0,padding:0,width:'100%'}}><input id="openrouter_api_key"
|
|
449
|
+
type="password"
|
|
450
|
+
placeholder="sk-or-..."
|
|
451
|
+
value={configData?.secrets?.openrouter_api_key || ''}
|
|
452
|
+
onChange={(e) => updateSecretsProp('openrouter_api_key', e.target.value)}
|
|
453
|
+
style={{
|
|
454
|
+
background: 'var(--bg-tertiary)',
|
|
455
|
+
color: 'var(--text-primary)',
|
|
456
|
+
border: '1px solid var(--border)',
|
|
457
|
+
padding: '8px 12px',
|
|
458
|
+
borderRadius: 6,
|
|
459
|
+
outline: 'none',
|
|
460
|
+
fontSize: 13
|
|
461
|
+
}}
|
|
462
|
+
/></form>
|
|
463
|
+
|
|
464
|
+
|
|
465
|
+
|
|
466
|
+
{loading && <div style={{ fontSize: 11, color: 'var(--text-tertiary)', padding: '0 4px' }}>Fetching models...</div>}
|
|
467
|
+
|
|
468
|
+
{!loading && orModels.length === 0 && configData?.secrets?.openrouter_api_key?.startsWith('sk-or-') && (
|
|
469
|
+
<div style={{ fontSize: 11, color: 'var(--accent-red)', padding: '0 4px' }}>Failed to fetch OpenRouter models.</div>
|
|
470
|
+
)}
|
|
471
|
+
{!loading && orModels.length > 0 && (
|
|
472
|
+
<select
|
|
473
|
+
id="openrouter_model"
|
|
474
|
+
value={configData?.brain?.openrouter_model || ''}
|
|
475
|
+
onChange={(e) => updateBrainProp('openrouter_model', e.target.value)}
|
|
476
|
+
style={{
|
|
477
|
+
background: 'var(--bg-tertiary)',
|
|
478
|
+
color: 'var(--text-primary)',
|
|
479
|
+
border: '1px solid var(--border)',
|
|
480
|
+
padding: '8px 12px',
|
|
481
|
+
borderRadius: 6,
|
|
482
|
+
outline: 'none',
|
|
483
|
+
fontSize: 13,
|
|
484
|
+
marginTop: 4
|
|
485
|
+
}}
|
|
486
|
+
>
|
|
487
|
+
<option value="">Default OpenRouter Model</option>
|
|
488
|
+
{(() => {
|
|
489
|
+
const groups: Record<string, typeof orModels> = {};
|
|
490
|
+
orModels.forEach(m => {
|
|
491
|
+
const provider = m.id.split('/')[0].toUpperCase();
|
|
492
|
+
if (!groups[provider]) groups[provider] = [];
|
|
493
|
+
groups[provider].push(m);
|
|
494
|
+
});
|
|
495
|
+
|
|
496
|
+
// Sort providers alphabetically
|
|
497
|
+
const sortedProviders = Object.keys(groups).sort((a, b) => a.localeCompare(b));
|
|
498
|
+
|
|
499
|
+
return sortedProviders.map(provider => {
|
|
500
|
+
// Sort models within provider by ID alphabetically
|
|
501
|
+
const sortedModels = groups[provider].sort((a, b) => a.id.localeCompare(b.id));
|
|
502
|
+
return (
|
|
503
|
+
<optgroup key={provider} label={provider}>
|
|
504
|
+
{sortedModels.map(m => {
|
|
505
|
+
let costStr = '';
|
|
506
|
+
if (m.pricing && m.pricing.prompt && m.pricing.completion) {
|
|
507
|
+
const promptCost = (parseFloat(m.pricing.prompt) * 1000000).toFixed(2);
|
|
508
|
+
const compCost = (parseFloat(m.pricing.completion) * 1000000).toFixed(2);
|
|
509
|
+
costStr = ` - ${promptCost}/${compCost} per 1M`;
|
|
510
|
+
}
|
|
511
|
+
return (
|
|
512
|
+
<option key={m.id} value={m.id}>{m.displayName} ({m.id}){costStr}</option>
|
|
513
|
+
);
|
|
514
|
+
})}
|
|
515
|
+
</optgroup>
|
|
516
|
+
);
|
|
517
|
+
});
|
|
518
|
+
})()}
|
|
519
|
+
</select>
|
|
520
|
+
)}
|
|
270
521
|
</div>
|
|
271
522
|
|
|
272
523
|
<span style={{ fontSize: 10, color: 'var(--text-tertiary)' }}>
|
|
273
524
|
These keys are stored locally and injected into the CLI reasoning agents on dispatch.
|
|
274
525
|
</span>
|
|
275
526
|
</div>
|
|
527
|
+
</div>
|
|
276
528
|
|
|
277
|
-
|
|
529
|
+
<UsageChart
|
|
530
|
+
usageData={usageStats}
|
|
531
|
+
geminiModels={geminiModels}
|
|
532
|
+
claudeModels={claudeModels}
|
|
533
|
+
openaiModels={openaiModels}
|
|
534
|
+
/>
|
|
278
535
|
|
|
279
536
|
{/* CLI Agents Catalog & Diagnostics */}
|
|
280
537
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(360px, 1fr))', gap: 24 }}>
|