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.
@@ -1,6 +1,7 @@
1
1
  import { useState, useEffect, useCallback } from 'react';
2
- import { fetchHealth, runAgentDiagnostics, fetchConfigJson, saveConfigJson } from '../api';
3
- import type { HealthData, ConfigJson } from '../types';
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
- <h1>Models & Agents</h1>
113
- <p>Configure Bring Your Own Model (BYOM) settings and manage active reasoning agents</p>
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
- <label htmlFor="google_api_key" style={{ fontSize: 13, fontWeight: 500 }}>Google Gemini API Key</label>
212
- <input
213
- id="google_api_key"
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
- </div>
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
- <label htmlFor="anthropic_api_key" style={{ fontSize: 13, fontWeight: 500 }}>Anthropic API Key (Claude)</label>
233
- <input
234
- id="anthropic_api_key"
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
- </div>
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
- <label htmlFor="openai_api_key" style={{ fontSize: 13, fontWeight: 500 }}>OpenAI API Key (Codex)</label>
254
- <input
255
- id="openai_api_key"
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
- </div>
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 }}>