agent-nuvira 2.7.0 โ 2.7.2
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/dist/agents/orchestrator.d.ts.map +1 -1
- package/dist/agents/orchestrator.js +17 -3
- package/dist/agents/orchestrator.js.map +1 -1
- package/dist/app.js +26 -0
- package/dist/cli/chat.d.ts.map +1 -1
- package/dist/cli/chat.js +108 -2
- package/dist/cli/chat.js.map +1 -1
- package/dist/config/auth.js +18 -0
- package/dist/config/jwt.js +24 -0
- package/dist/config/keys.js +14 -0
- package/dist/gateway/chat-store.d.ts +32 -5
- package/dist/gateway/chat-store.d.ts.map +1 -1
- package/dist/gateway/chat-store.js +52 -6
- package/dist/gateway/chat-store.js.map +1 -1
- package/dist/gateway/registry.d.ts.map +1 -1
- package/dist/gateway/registry.js +54 -10
- package/dist/gateway/registry.js.map +1 -1
- package/dist/inference/model-validator.d.ts.map +1 -1
- package/dist/inference/model-validator.js +11 -1
- package/dist/inference/model-validator.js.map +1 -1
- package/dist/inference/tool-call-utils.d.ts +31 -0
- package/dist/inference/tool-call-utils.d.ts.map +1 -1
- package/dist/inference/tool-call-utils.js +41 -0
- package/dist/inference/tool-call-utils.js.map +1 -1
- package/dist/learning/reasoning-trace.d.ts +5 -3
- package/dist/learning/reasoning-trace.d.ts.map +1 -1
- package/dist/learning/reasoning-trace.js +2 -2
- package/dist/learning/reasoning-trace.js.map +1 -1
- package/dist/middleware/auth.js +30 -0
- package/dist/nlu/conversation-gate.js +1 -1
- package/dist/nlu/conversation-gate.js.map +1 -1
- package/dist/nlu/entities.d.ts.map +1 -1
- package/dist/nlu/entities.js +12 -0
- package/dist/nlu/entities.js.map +1 -1
- package/dist/nlu/intent.d.ts.map +1 -1
- package/dist/nlu/intent.js +83 -15
- package/dist/nlu/intent.js.map +1 -1
- package/dist/passport.js +34 -0
- package/dist/routes/auth.js +42 -0
- package/dist/routes/user.js +31 -0
- package/dist/server.js +17 -0
- package/dist/tools/pipeline-tool.d.ts.map +1 -1
- package/dist/tools/pipeline-tool.js +14 -0
- package/dist/tools/pipeline-tool.js.map +1 -1
- package/dist/tools/release-sync.d.ts +52 -16
- package/dist/tools/release-sync.d.ts.map +1 -1
- package/dist/tools/release-sync.js +143 -79
- package/dist/tools/release-sync.js.map +1 -1
- package/dist/web-dashboard/src/App.js +85 -0
- package/dist/web-dashboard/src/admin-auth.test.js +186 -0
- package/dist/web-dashboard/src/ansi.js +23 -0
- package/dist/web-dashboard/src/ansi.test.js +31 -0
- package/dist/web-dashboard/src/api-admin-auth.test.js +172 -0
- package/dist/web-dashboard/src/api-admin.test.js +65 -0
- package/dist/web-dashboard/src/api-hub.test.js +117 -0
- package/dist/web-dashboard/src/api.js +1421 -0
- package/dist/web-dashboard/src/api.test.js +51 -0
- package/dist/web-dashboard/src/artifacts.js +128 -0
- package/dist/web-dashboard/src/artifacts.test.js +139 -0
- package/dist/web-dashboard/src/components/AdminPanel.js +567 -0
- package/dist/web-dashboard/src/components/AdminPanel.test.js +288 -0
- package/dist/web-dashboard/src/components/AgentHub.js +1580 -0
- package/dist/web-dashboard/src/components/AgentHub.test.js +343 -0
- package/dist/web-dashboard/src/components/BedrockOnboarding.js +320 -0
- package/dist/web-dashboard/src/components/BenchmarkCharts.js +228 -0
- package/dist/web-dashboard/src/components/ChatPage.js +1586 -0
- package/dist/web-dashboard/src/components/ChatPage.test.js +899 -0
- package/dist/web-dashboard/src/components/ContactsPage.js +141 -0
- package/dist/web-dashboard/src/components/CostDashboard.js +105 -0
- package/dist/web-dashboard/src/components/DAGView.js +477 -0
- package/dist/web-dashboard/src/components/DAGView.test.js +147 -0
- package/dist/web-dashboard/src/components/EnvVarEditor.js +138 -0
- package/dist/web-dashboard/src/components/EvalsPage.js +73 -0
- package/dist/web-dashboard/src/components/EvalsPage.test.js +120 -0
- package/dist/web-dashboard/src/components/ExecutionHistory.js +201 -0
- package/dist/web-dashboard/src/components/GatewayPage.js +40 -0
- package/dist/web-dashboard/src/components/GatewayPage.test.js +74 -0
- package/dist/web-dashboard/src/components/HealthPanel.js +68 -0
- package/dist/web-dashboard/src/components/HistoryBrowser.js +34 -0
- package/dist/web-dashboard/src/components/Layout.js +50 -0
- package/dist/web-dashboard/src/components/Markdown.js +152 -0
- package/dist/web-dashboard/src/components/Markdown.test.js +126 -0
- package/dist/web-dashboard/src/components/MarkdownZeroDep.js +237 -0
- package/dist/web-dashboard/src/components/MemoryPanel.js +81 -0
- package/dist/web-dashboard/src/components/ModelTimeline.js +298 -0
- package/dist/web-dashboard/src/components/ModelsPanel.js +1484 -0
- package/dist/web-dashboard/src/components/ModelsPanel.test.js +460 -0
- package/dist/web-dashboard/src/components/Overview.js +123 -0
- package/dist/web-dashboard/src/components/PhaseTimeline.js +359 -0
- package/dist/web-dashboard/src/components/PhaseTimeline.test.js +234 -0
- package/dist/web-dashboard/src/components/PlatformConfigSection.js +384 -0
- package/dist/web-dashboard/src/components/PlatformConfigSection.test.js +106 -0
- package/dist/web-dashboard/src/components/PlatformsPage.js +250 -0
- package/dist/web-dashboard/src/components/QuotaPanel.js +159 -0
- package/dist/web-dashboard/src/components/QuotaPanel.test.js +85 -0
- package/dist/web-dashboard/src/components/RequestsPanel.js +229 -0
- package/dist/web-dashboard/src/components/RequestsPanel.test.js +103 -0
- package/dist/web-dashboard/src/components/RoutingInsightsPanel.js +938 -0
- package/dist/web-dashboard/src/components/RoutingInsightsPanel.test.js +339 -0
- package/dist/web-dashboard/src/components/RoutingWalkthrough.js +408 -0
- package/dist/web-dashboard/src/components/RoutingWalkthrough.test.js +209 -0
- package/dist/web-dashboard/src/components/TaskConsole.js +119 -0
- package/dist/web-dashboard/src/components/TaskConsole.test.js +123 -0
- package/dist/web-dashboard/src/components/TasksPage.js +256 -0
- package/dist/web-dashboard/src/components/TasksPage.test.js +103 -0
- package/dist/web-dashboard/src/components/TracePanel.js +306 -0
- package/dist/web-dashboard/src/components/WhatsAppPanel.js +195 -0
- package/dist/web-dashboard/src/components/WhatsAppPanel.test.js +122 -0
- package/dist/web-dashboard/src/jsonOrNull.js +37 -0
- package/dist/web-dashboard/src/main.js +15 -0
- package/dist/web-dashboard/src/mask.js +11 -0
- package/dist/web-dashboard/vite.config.js +23 -0
- package/dist/web-dashboard/vitest.config.js +15 -0
- package/package.json +5 -3
|
@@ -0,0 +1,1484 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.FlakinessChip = FlakinessChip;
|
|
4
|
+
exports.ContextWindowChip = ContextWindowChip;
|
|
5
|
+
exports.FlakinessSparkline = FlakinessSparkline;
|
|
6
|
+
exports.keyHygieneWarning = keyHygieneWarning;
|
|
7
|
+
exports.dedupeDayEvents = dedupeDayEvents;
|
|
8
|
+
exports.ActionTimelineChart = ActionTimelineChart;
|
|
9
|
+
exports.ActionTelemetryCard = ActionTelemetryCard;
|
|
10
|
+
exports.default = ModelsPanel;
|
|
11
|
+
const react_1 = require("react");
|
|
12
|
+
const jsonOrNull_1 = require("../jsonOrNull");
|
|
13
|
+
// โโโ Constants โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
|
14
|
+
const LOCAL_PROVIDERS = new Set(['local', 'lmstudio', 'vllm']);
|
|
15
|
+
/** Future speech/TTS providers โ always appear last when implemented */
|
|
16
|
+
const SPEECH_PROVIDERS = new Set([]);
|
|
17
|
+
const STATUS_STYLES = {
|
|
18
|
+
available: { bg: '#0a2e1a', text: '#3fb950', dot: '#3fb950', cardBorder: '#3fb950', cardBg: '#0d2818' },
|
|
19
|
+
limited: { bg: '#2d1f00', text: '#d29922', dot: '#d29922', cardBorder: '#d29922', cardBg: '#1f1700' },
|
|
20
|
+
unavailable: { bg: '#2d0f0f', text: '#f85149', dot: '#f85149', cardBorder: '#f85149', cardBg: '#1f0a0a' },
|
|
21
|
+
};
|
|
22
|
+
const COL_OPTIONS = [3, 4, 5];
|
|
23
|
+
// โโโ Resilient fetch helpers โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
|
24
|
+
// The Models page must never die to a single transient network hiccup (browser
|
|
25
|
+
// socket-pool contention with the SSE feed, tab throttling, a slow provider
|
|
26
|
+
// probe, an IPv4/IPv6 race on `localhost`). Each fetch gets a hard
|
|
27
|
+
// AbortController timeout, the two endpoints are fetched INDEPENDENTLY (a
|
|
28
|
+
// failure on one must never hide the other), and network-level failures
|
|
29
|
+
// auto-retry with backoff before the panel ever shows an error.
|
|
30
|
+
/** Hard ceiling for one fetch โ the server probes providers with a 5s budget. */
|
|
31
|
+
const FETCH_TIMEOUT_MS = 12_000;
|
|
32
|
+
/** Backoff before retrying a TRANSIENT (network-level) failure. */
|
|
33
|
+
const RETRY_BACKOFF_MS = [300, 700];
|
|
34
|
+
/** Healthy auto-refresh cadence. */
|
|
35
|
+
const POLL_INTERVAL_MS = 60_000;
|
|
36
|
+
/** After a failed load, re-poll this quickly so the panel self-heals. */
|
|
37
|
+
const FAILED_REPOLL_MS = 5_000;
|
|
38
|
+
function sleep(ms) {
|
|
39
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
40
|
+
}
|
|
41
|
+
async function fetchWithTimeout(url, timeoutMs) {
|
|
42
|
+
const controller = new AbortController();
|
|
43
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
44
|
+
try {
|
|
45
|
+
return await fetch(url, { signal: controller.signal });
|
|
46
|
+
}
|
|
47
|
+
finally {
|
|
48
|
+
clearTimeout(timer);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
function isTransientNetworkError(err) {
|
|
52
|
+
// TypeError("Failed to fetch") or an AbortError (a DOMException in browsers,
|
|
53
|
+
// a plain Error with name 'AbortError' in Node/undici) means the server never
|
|
54
|
+
// answered โ a transient hiccup worth retrying. HTTP errors / parse failures
|
|
55
|
+
// are definitive (the server answered) and are NOT retried.
|
|
56
|
+
return err instanceof TypeError
|
|
57
|
+
|| (err instanceof Error && err.name === 'AbortError');
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* GET /api/models with timeout + retry. Throws after retries are exhausted.
|
|
61
|
+
* `onTransient` fires whenever a network-level (transient) failure occurred,
|
|
62
|
+
* so the caller can trigger a fast self-healing re-poll. Returns the parsed
|
|
63
|
+
* health payload, or throws "unexpected response" if the server answered with
|
|
64
|
+
* something that isn't a health payload (stale server โ definitive, no retry).
|
|
65
|
+
*/
|
|
66
|
+
async function fetchHealthWithRetry(onTransient) {
|
|
67
|
+
let lastStatus = null;
|
|
68
|
+
let sawTransient = false;
|
|
69
|
+
for (let attempt = 0;; attempt++) {
|
|
70
|
+
try {
|
|
71
|
+
const res = await fetchWithTimeout('/api/models', FETCH_TIMEOUT_MS);
|
|
72
|
+
if (!res.ok) {
|
|
73
|
+
lastStatus = res.status;
|
|
74
|
+
}
|
|
75
|
+
else {
|
|
76
|
+
const data = await (0, jsonOrNull_1.parseJsonOrNull)(res);
|
|
77
|
+
if (data && Array.isArray(data.providers))
|
|
78
|
+
return data;
|
|
79
|
+
// Answered, but not a health payload โ stale/incompatible dashboard server.
|
|
80
|
+
throw new Error('Model health endpoint returned an unexpected response โ is the dashboard server up to date?');
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
catch (err) {
|
|
84
|
+
if (!isTransientNetworkError(err))
|
|
85
|
+
throw err;
|
|
86
|
+
sawTransient = true;
|
|
87
|
+
onTransient?.();
|
|
88
|
+
}
|
|
89
|
+
if (attempt >= RETRY_BACKOFF_MS.length) {
|
|
90
|
+
throw new Error(sawTransient
|
|
91
|
+
? 'Dashboard server unreachable โ is it still running? Retrying automaticallyโฆ'
|
|
92
|
+
: `Model health endpoint failed (HTTP ${lastStatus})`);
|
|
93
|
+
}
|
|
94
|
+
await sleep(RETRY_BACKOFF_MS[attempt]);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* GET /api/model-registry, best-effort: null on ANY failure, never throws.
|
|
99
|
+
* Retries a transient network failure once; onTransient lets the caller
|
|
100
|
+
* schedule a fast re-poll so the optional section self-heals quickly too.
|
|
101
|
+
*/
|
|
102
|
+
async function fetchRegistryBestEffort(onTransient) {
|
|
103
|
+
for (let attempt = 0;; attempt++) {
|
|
104
|
+
try {
|
|
105
|
+
const res = await fetchWithTimeout('/api/model-registry', FETCH_TIMEOUT_MS);
|
|
106
|
+
if (!res.ok)
|
|
107
|
+
return null;
|
|
108
|
+
return await (0, jsonOrNull_1.parseJsonOrNull)(res);
|
|
109
|
+
}
|
|
110
|
+
catch (err) {
|
|
111
|
+
if (!isTransientNetworkError(err) || attempt >= 1)
|
|
112
|
+
return null;
|
|
113
|
+
onTransient?.();
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
// โโโ Helpers โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
|
118
|
+
function getStatusLabel(status) {
|
|
119
|
+
return status === 'available' ? 'Available' : status === 'limited' ? 'Limited' : 'Unavailable';
|
|
120
|
+
}
|
|
121
|
+
function getStatusBadgeLabel(status) {
|
|
122
|
+
return status === 'available' ? 'Ready' : status === 'limited' ? 'Limited' : 'Down';
|
|
123
|
+
}
|
|
124
|
+
function getProviderIcon(provider) {
|
|
125
|
+
const iconMap = {
|
|
126
|
+
local: '๐ป', groq: '๐ข', nim: '๐ถ', gemini: '๐ท', openrouter: '๐ฃ',
|
|
127
|
+
openai: '๐ค', anthropic: '๐ฎ', mistral: '๐', cohere: '๐ง ',
|
|
128
|
+
together: '๐ข', deepinfra: '๐', fireworks: '๐', perplexity: 'โ',
|
|
129
|
+
azure: '๐ต', anyscale: '๐ท', lmstudio: '๐จ', vllm: 'โก',
|
|
130
|
+
};
|
|
131
|
+
return iconMap[provider] || '๐';
|
|
132
|
+
}
|
|
133
|
+
function getProviderLabel(provider) {
|
|
134
|
+
const labelMap = {
|
|
135
|
+
local: 'Ollama', groq: 'Groq', nim: 'NVIDIA NIM', gemini: 'Gemini',
|
|
136
|
+
openrouter: 'OpenRouter', openai: 'OpenAI', anthropic: 'Anthropic',
|
|
137
|
+
mistral: 'Mistral', cohere: 'Cohere', together: 'Together AI',
|
|
138
|
+
deepinfra: 'DeepInfra', fireworks: 'Fireworks AI', perplexity: 'Perplexity',
|
|
139
|
+
azure: 'Azure OpenAI', anyscale: 'Anyscale', lmstudio: 'LM Studio',
|
|
140
|
+
vllm: 'vLLM / TGI',
|
|
141
|
+
};
|
|
142
|
+
return labelMap[provider] || provider;
|
|
143
|
+
}
|
|
144
|
+
// โโโ Status Badge โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
|
145
|
+
function StatusBadge({ status, label }) {
|
|
146
|
+
const s = STATUS_STYLES[status];
|
|
147
|
+
return (<span style={{
|
|
148
|
+
display: 'inline-flex', alignItems: 'center', gap: 6,
|
|
149
|
+
background: s.bg, color: s.text, padding: '3px 10px',
|
|
150
|
+
borderRadius: 12, fontSize: 12, fontWeight: 500,
|
|
151
|
+
border: `1px solid ${s.text}22`,
|
|
152
|
+
}}>
|
|
153
|
+
<span style={{ width: 8, height: 8, borderRadius: '50%', background: s.dot }}/>
|
|
154
|
+
{label}
|
|
155
|
+
</span>);
|
|
156
|
+
}
|
|
157
|
+
// โโโ Action Bar โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
|
158
|
+
function ActionBar({ onRefresh, loading }) {
|
|
159
|
+
return (<div className="stats-grid mini" style={{ marginBottom: 16 }}>
|
|
160
|
+
<button onClick={onRefresh} disabled={loading} className="stat-card" style={{
|
|
161
|
+
cursor: loading ? 'not-allowed' : 'pointer',
|
|
162
|
+
opacity: loading ? 0.6 : 1,
|
|
163
|
+
border: '1px solid #30363d',
|
|
164
|
+
justifyContent: 'center',
|
|
165
|
+
fontSize: 13,
|
|
166
|
+
}}>
|
|
167
|
+
{loading ? 'โณ Testing...' : '๐ Refresh Status'}
|
|
168
|
+
</button>
|
|
169
|
+
<div className="stat-card" style={{ border: '1px solid #30363d' }}>
|
|
170
|
+
<div style={{ fontSize: 13, color: '#8b949e', textAlign: 'center', width: '100%' }}>
|
|
171
|
+
Tests all configured providers and their API keys in real time
|
|
172
|
+
</div>
|
|
173
|
+
</div>
|
|
174
|
+
</div>);
|
|
175
|
+
}
|
|
176
|
+
// โโโ Progress Bar โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
|
177
|
+
function ProgressBar({ data }) {
|
|
178
|
+
const total = data.totalModels;
|
|
179
|
+
if (total === 0)
|
|
180
|
+
return null;
|
|
181
|
+
const available = data.available || 0;
|
|
182
|
+
const limited = data.limited || 0;
|
|
183
|
+
const unavailable = data.unavailable || 0;
|
|
184
|
+
return (<div style={{ marginBottom: 16 }}>
|
|
185
|
+
<div style={{
|
|
186
|
+
background: '#0d1117', borderRadius: 8, overflow: 'hidden',
|
|
187
|
+
height: 10, display: 'flex', border: '1px solid #21262d',
|
|
188
|
+
}}>
|
|
189
|
+
{available > 0 && <div style={{ width: `${(available / total) * 100}%`, background: '#3fb950', transition: 'width 0.5s' }} title={`${available} available`}/>}
|
|
190
|
+
{limited > 0 && <div style={{ width: `${(limited / total) * 100}%`, background: '#d29922', transition: 'width 0.5s' }} title={`${limited} limited`}/>}
|
|
191
|
+
{unavailable > 0 && <div style={{ width: `${(unavailable / total) * 100}%`, background: '#f85149', transition: 'width 0.5s' }} title={`${unavailable} unavailable`}/>}
|
|
192
|
+
</div>
|
|
193
|
+
<div style={{ display: 'flex', gap: 16, marginTop: 6, fontSize: 12, color: '#8b949e' }}>
|
|
194
|
+
<span><span style={{ color: '#3fb950' }}>โ</span> Ready</span>
|
|
195
|
+
<span><span style={{ color: '#d29922' }}>โ</span> Limited</span>
|
|
196
|
+
<span><span style={{ color: '#f85149' }}>โ</span> Unavailable</span>
|
|
197
|
+
</div>
|
|
198
|
+
</div>);
|
|
199
|
+
}
|
|
200
|
+
// โโโ Provider Card โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
|
201
|
+
function ProviderCard({ provider }) {
|
|
202
|
+
const [expanded, setExpanded] = (0, react_1.useState)(false);
|
|
203
|
+
const borderColor = STATUS_STYLES[provider.overallStatus].text;
|
|
204
|
+
const counts = {
|
|
205
|
+
available: provider.models.filter((m) => m.status === 'available').length,
|
|
206
|
+
limited: provider.models.filter((m) => m.status === 'limited').length,
|
|
207
|
+
unavailable: provider.models.filter((m) => m.status === 'unavailable').length,
|
|
208
|
+
};
|
|
209
|
+
return (<div style={{
|
|
210
|
+
background: '#161b22', borderRadius: 12,
|
|
211
|
+
border: `1px solid ${borderColor}44`,
|
|
212
|
+
borderLeft: `4px solid ${borderColor}`,
|
|
213
|
+
marginBottom: 12, overflow: 'hidden',
|
|
214
|
+
}}>
|
|
215
|
+
<div onClick={() => setExpanded(!expanded)} style={{
|
|
216
|
+
padding: '14px 18px', cursor: 'pointer',
|
|
217
|
+
display: 'flex', alignItems: 'center', gap: 14,
|
|
218
|
+
userSelect: 'none',
|
|
219
|
+
}}>
|
|
220
|
+
<span style={{ fontSize: 24 }}>{provider.icon}</span>
|
|
221
|
+
<div style={{ flex: 1 }}>
|
|
222
|
+
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 2 }}>
|
|
223
|
+
<span style={{ fontSize: 16, fontWeight: 600, color: '#e6edf3' }}>
|
|
224
|
+
{provider.providerLabel}
|
|
225
|
+
</span>
|
|
226
|
+
<StatusBadge status={provider.overallStatus} label={getStatusLabel(provider.overallStatus)}/>
|
|
227
|
+
</div>
|
|
228
|
+
<div style={{ fontSize: 13, color: '#8b949e' }}>
|
|
229
|
+
{provider.models.length} model{provider.models.length !== 1 ? 's' : ''}
|
|
230
|
+
{counts.available > 0 && <span style={{ color: '#3fb950' }}> ยท {counts.available} ready</span>}
|
|
231
|
+
{counts.limited > 0 && <span style={{ color: '#d29922' }}> ยท {counts.limited} limited</span>}
|
|
232
|
+
{counts.unavailable > 0 && <span style={{ color: '#f85149' }}> ยท {counts.unavailable} unavailable</span>}
|
|
233
|
+
</div>
|
|
234
|
+
</div>
|
|
235
|
+
<div style={{ fontSize: 12, color: '#8b949e', textAlign: 'right' }}>
|
|
236
|
+
<div style={{ marginBottom: 2, color: provider.apiConfigured ? '#3fb950' : '#f85149' }}>
|
|
237
|
+
{provider.apiConfigured ? 'โ
Key set' : 'โ No key'}
|
|
238
|
+
</div>
|
|
239
|
+
<div style={{ color: provider.apiAccessible ? '#3fb950' : '#f85149' }}>
|
|
240
|
+
{provider.apiAccessible ? 'โ
Connected' : 'โ Offline'}
|
|
241
|
+
</div>
|
|
242
|
+
</div>
|
|
243
|
+
<span style={{
|
|
244
|
+
color: '#8b949e', fontSize: 18,
|
|
245
|
+
transition: 'transform 0.2s',
|
|
246
|
+
transform: expanded ? 'rotate(90deg)' : 'rotate(0deg)',
|
|
247
|
+
}}>โถ</span>
|
|
248
|
+
</div>
|
|
249
|
+
|
|
250
|
+
{expanded && (<>
|
|
251
|
+
<div style={{
|
|
252
|
+
padding: '10px 18px', background: '#0d1117', fontSize: 13, color: '#8b949e',
|
|
253
|
+
display: 'flex', justifyContent: 'space-between', flexWrap: 'wrap', gap: 8,
|
|
254
|
+
borderTop: '1px solid #21262d',
|
|
255
|
+
}}>
|
|
256
|
+
<span>{provider.notes}</span>
|
|
257
|
+
{provider.freeTierInfo && (<span style={{ color: '#d29922' }}>๐ {provider.freeTierInfo}</span>)}
|
|
258
|
+
</div>
|
|
259
|
+
<div style={{ overflowX: 'auto', borderTop: '1px solid #21262d' }}>
|
|
260
|
+
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
|
|
261
|
+
<thead>
|
|
262
|
+
<tr style={{ borderBottom: '1px solid #21262d', color: '#8b949e' }}>
|
|
263
|
+
<th style={{ padding: '8px 18px', textAlign: 'left', fontWeight: 500 }}>Model</th>
|
|
264
|
+
<th style={{ padding: '8px 12px', textAlign: 'left', fontWeight: 500 }}>Status</th>
|
|
265
|
+
<th style={{ padding: '8px 12px', textAlign: 'left', fontWeight: 500 }}>Quota</th>
|
|
266
|
+
<th style={{ padding: '8px 18px', textAlign: 'left', fontWeight: 500 }}>Details</th>
|
|
267
|
+
</tr>
|
|
268
|
+
</thead>
|
|
269
|
+
<tbody>
|
|
270
|
+
{provider.models.map((model, i) => (<tr key={model.id} style={{
|
|
271
|
+
borderBottom: i < provider.models.length - 1 ? '1px solid #21262d' : 'none',
|
|
272
|
+
background: model.status === 'unavailable' ? '#0d1117' : 'transparent',
|
|
273
|
+
}}>
|
|
274
|
+
<td style={{
|
|
275
|
+
padding: '8px 18px', color: '#e6edf3',
|
|
276
|
+
fontFamily: "'SFMono-Regular', Consolas, monospace",
|
|
277
|
+
fontSize: 12,
|
|
278
|
+
}}>
|
|
279
|
+
<span style={{
|
|
280
|
+
display: 'inline-block', width: 8, height: 8, borderRadius: '50%',
|
|
281
|
+
background: STATUS_STYLES[model.status].dot, marginRight: 8,
|
|
282
|
+
}}/>
|
|
283
|
+
{model.name}
|
|
284
|
+
</td>
|
|
285
|
+
<td style={{ padding: '8px 12px' }}>
|
|
286
|
+
<StatusBadge status={model.status} label={getStatusBadgeLabel(model.status)}/>
|
|
287
|
+
</td>
|
|
288
|
+
<td style={{ padding: '8px 12px', color: '#8b949e', fontSize: 12, fontFamily: "'SFMono-Regular', Consolas, monospace" }}>
|
|
289
|
+
{model.rateLimitRemaining !== undefined
|
|
290
|
+
? model.rateLimitTotal
|
|
291
|
+
? `${model.rateLimitRemaining}/${model.rateLimitTotal}`
|
|
292
|
+
: `${model.rateLimitRemaining} left`
|
|
293
|
+
: 'โ'}
|
|
294
|
+
</td>
|
|
295
|
+
<td style={{ padding: '8px 18px', color: '#8b949e', fontSize: 12 }}>
|
|
296
|
+
{model.statusReason}
|
|
297
|
+
</td>
|
|
298
|
+
</tr>))}
|
|
299
|
+
</tbody>
|
|
300
|
+
</table>
|
|
301
|
+
</div>
|
|
302
|
+
</>)}
|
|
303
|
+
</div>);
|
|
304
|
+
}
|
|
305
|
+
// โโโ Model Availability Registry Section โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
|
306
|
+
// The UNIFIED enterprise read store: the exact sub-ms FAISS/JSON snapshot the
|
|
307
|
+
// Auto router consults on every pick. Shows verified / unavailable / parked
|
|
308
|
+
// availability + the quota telemetry (tokens remaining, reset windows) that
|
|
309
|
+
// syncQuota mirrors from the ledger โ one card, one source of truth.
|
|
310
|
+
function fmtDuration(ms) {
|
|
311
|
+
if (ms <= 0)
|
|
312
|
+
return 'now';
|
|
313
|
+
const h = Math.floor(ms / 3_600_000);
|
|
314
|
+
const m = Math.floor((ms % 3_600_000) / 60_000);
|
|
315
|
+
if (h > 0)
|
|
316
|
+
return `${h}h ${m}m`;
|
|
317
|
+
if (m > 0)
|
|
318
|
+
return `${m}m`;
|
|
319
|
+
return `${Math.ceil(ms / 1000)}s`;
|
|
320
|
+
}
|
|
321
|
+
function registryStatusStyle(status) {
|
|
322
|
+
if (status === 'verified')
|
|
323
|
+
return { text: '#3fb950', bg: '#0a2e1a', dot: '#3fb950' };
|
|
324
|
+
if (status === 'unavailable')
|
|
325
|
+
return { text: '#f85149', bg: '#2d0f0f', dot: '#f85149' };
|
|
326
|
+
return { text: '#8b949e', bg: '#21262d', dot: '#8b949e' };
|
|
327
|
+
}
|
|
328
|
+
/**
|
|
329
|
+
* P4 M4.4 โ mid-stream flakiness chip (violet โธ). Mirrors the CLI's `โธ flaky
|
|
330
|
+
* N%` chip: this model started streaming then died before finishing, so the
|
|
331
|
+
* router scales its reliability down (capped 40%) and it ranks below
|
|
332
|
+
* otherwise-identical healthy models. `rate` is the 0-1 EMA from the registry.
|
|
333
|
+
*/
|
|
334
|
+
function FlakinessChip({ rate }) {
|
|
335
|
+
const pct = Math.round(rate * 100);
|
|
336
|
+
return (<span title={`โธ flaky mid-stream ${pct}% โ started streaming, died before finish; the router deprioritizes flaky models (P4 M4.4)`} style={{
|
|
337
|
+
marginLeft: 8, fontSize: 10, padding: '1px 6px', borderRadius: 8, whiteSpace: 'nowrap',
|
|
338
|
+
background: '#21122e', border: '1px solid #bc8cff', color: '#bc8cff',
|
|
339
|
+
}}>
|
|
340
|
+
โธ flaky {pct}%
|
|
341
|
+
</span>);
|
|
342
|
+
}
|
|
343
|
+
/**
|
|
344
|
+
* v1.60.1/1.60.2 โ live context-window chip (โณ). The provider-advertised
|
|
345
|
+
* input window the model probe recorded into the registry (Ollama /api/tags +
|
|
346
|
+
* /api/show fallback, OpenRouter /models, Gemini inputTokenLimit, NIM
|
|
347
|
+
* max_model_len) โ the REAL spec the router's context preflight prefers over
|
|
348
|
+
* static estimates. Mirrors the CLI's `โณ ctx` chip and the Routing Insights
|
|
349
|
+
* preference panel. Renders compact (128K / 1M) with the exact tokens in the
|
|
350
|
+
* tooltip.
|
|
351
|
+
*/
|
|
352
|
+
function ContextWindowChip({ tokens }) {
|
|
353
|
+
const compact = tokens >= 1_048_576
|
|
354
|
+
? `${(tokens / 1_048_576).toFixed(1).replace(/\.0$/, '')}M`
|
|
355
|
+
: tokens >= 1024
|
|
356
|
+
? `${(tokens / 1024).toFixed(0)}K`
|
|
357
|
+
: `${tokens}`;
|
|
358
|
+
return (<span title={`โณ context window ${tokens.toLocaleString()} tokens โ live from the provider's model list (v1.60.x); feeds the router's context preflight`} style={{
|
|
359
|
+
marginLeft: 8, fontSize: 10, padding: '1px 6px', borderRadius: 8, whiteSpace: 'nowrap',
|
|
360
|
+
background: '#0a1e2e', border: '1px solid #58a6ff', color: '#58a6ff',
|
|
361
|
+
}}>
|
|
362
|
+
โณ {compact}
|
|
363
|
+
</span>);
|
|
364
|
+
}
|
|
365
|
+
/**
|
|
366
|
+
* P4 M4.4 โ flakiness-over-time mini sparkline (violet). Plots the entry's
|
|
367
|
+
* partialRate EMA trajectory: a trend toward 0 = the provider is HEALING via
|
|
368
|
+
* clean successes (each decay point recorded by recordCall); climbing =
|
|
369
|
+
* flakiness accumulating (each partial bump recorded by recordPartial).
|
|
370
|
+
* Renders only when >= 2 samples exist. Tooltip calls out the direction.
|
|
371
|
+
*/
|
|
372
|
+
function FlakinessSparkline({ history }) {
|
|
373
|
+
if (!history || history.length < 2)
|
|
374
|
+
return null;
|
|
375
|
+
const W = 46;
|
|
376
|
+
const H = 14;
|
|
377
|
+
const PAD = 1.5;
|
|
378
|
+
const max = Math.max(0.01, ...history.map((p) => p.rate));
|
|
379
|
+
const pts = history.map((p, i) => {
|
|
380
|
+
const x = PAD + (i / (history.length - 1)) * (W - PAD * 2);
|
|
381
|
+
const y = H - PAD - (p.rate / max) * (H - PAD * 2);
|
|
382
|
+
return `${x.toFixed(1)},${y.toFixed(1)}`;
|
|
383
|
+
}).join(' ');
|
|
384
|
+
const first = history[0].rate;
|
|
385
|
+
const last = history[history.length - 1].rate;
|
|
386
|
+
const healing = last < first;
|
|
387
|
+
const pct = Math.round(last * 100);
|
|
388
|
+
return (<svg width={W} height={H} viewBox={`0 0 ${W} ${H}`} style={{ marginLeft: 8, verticalAlign: 'middle', cursor: 'default' }} aria-label="Flakiness trend">
|
|
389
|
+
<title>
|
|
390
|
+
{healing
|
|
391
|
+
? `Flakiness healing โ ${pct}% now, trending down (clean successes decay the signal)`
|
|
392
|
+
: `Flakiness climbing โ ${pct}% now (recent mid-stream interruptions)`}
|
|
393
|
+
</title>
|
|
394
|
+
<polyline points={pts} fill="none" stroke="#bc8cff" strokeWidth={1.5} strokeLinecap="round" strokeLinejoin="round" opacity={0.9}/>
|
|
395
|
+
<circle cx={W - PAD} cy={H - PAD - (last / max) * (H - PAD * 2)} r={2} fill={healing ? '#3fb950' : '#bc8cff'}/>
|
|
396
|
+
</svg>);
|
|
397
|
+
}
|
|
398
|
+
function RegistryEntryRow({ entry }) {
|
|
399
|
+
const style = registryStatusStyle(entry.status);
|
|
400
|
+
const tokens = entry.remainingTokens >= 0
|
|
401
|
+
? `${entry.remainingTokens.toLocaleString()} left`
|
|
402
|
+
: 'unlimited';
|
|
403
|
+
return (<tr style={{ borderBottom: '1px solid #21262d' }}>
|
|
404
|
+
<td style={{ padding: '8px 12px', color: '#e6edf3', fontFamily: "'SFMono-Regular', Consolas, monospace", fontSize: 12 }}>
|
|
405
|
+
<span style={{ display: 'inline-block', width: 8, height: 8, borderRadius: '50%', background: style.dot, marginRight: 8 }}/>
|
|
406
|
+
{entry.model.length > 32 ? entry.model.slice(0, 29) + 'โฆ' : entry.model}
|
|
407
|
+
{(entry.partialRate ?? 0) > 0 && <FlakinessChip rate={entry.partialRate ?? 0}/>}
|
|
408
|
+
{entry.partialHistory && entry.partialHistory.length >= 2 && <FlakinessSparkline history={entry.partialHistory}/>}
|
|
409
|
+
{entry.parked && (<span style={{
|
|
410
|
+
marginLeft: 8, fontSize: 10, padding: '1px 6px', borderRadius: 8,
|
|
411
|
+
background: '#2d1616', border: '1px solid #f85149', color: '#f85149',
|
|
412
|
+
}}>โธ parked</span>)}
|
|
413
|
+
{entry.measuredSamples ? (<span style={{
|
|
414
|
+
marginLeft: 8, fontSize: 10, padding: '1px 6px', borderRadius: 8, whiteSpace: 'nowrap',
|
|
415
|
+
background: '#0a2e1a', border: '1px solid #3fb950', color: '#3fb950',
|
|
416
|
+
}}>
|
|
417
|
+
๐ {entry.measuredInputTokens}โ{entry.measuredOutputTokens} tok
|
|
418
|
+
</span>) : (<span style={{
|
|
419
|
+
marginLeft: 8, fontSize: 10, padding: '1px 6px', borderRadius: 8, whiteSpace: 'nowrap',
|
|
420
|
+
background: '#21262d', border: '1px solid #8b949e', color: '#8b949e',
|
|
421
|
+
}}>๐ est</span>)}
|
|
422
|
+
{entry.contextWindowTokens && <ContextWindowChip tokens={entry.contextWindowTokens}/>}
|
|
423
|
+
</td>
|
|
424
|
+
<td style={{ padding: '8px 12px' }}>
|
|
425
|
+
<span style={{
|
|
426
|
+
display: 'inline-flex', alignItems: 'center', gap: 5, fontSize: 11, fontWeight: 600,
|
|
427
|
+
background: style.bg, color: style.text, padding: '2px 8px', borderRadius: 6,
|
|
428
|
+
}}>
|
|
429
|
+
{entry.status === 'verified' ? 'โ Verified' : entry.status === 'unavailable' ? 'โ Unavailable' : 'โ Unverified'}
|
|
430
|
+
</span>
|
|
431
|
+
</td>
|
|
432
|
+
<td style={{ padding: '8px 12px', textAlign: 'right', fontFamily: "'SFMono-Regular', Consolas, monospace", fontSize: 12, color: entry.remainingTokens >= 0 && entry.remainingTokens <= 100 ? '#d29922' : '#8b949e' }}>
|
|
433
|
+
{tokens}
|
|
434
|
+
</td>
|
|
435
|
+
<td style={{ padding: '8px 12px', textAlign: 'right', color: '#8b949e', fontSize: 12, whiteSpace: 'nowrap' }}>
|
|
436
|
+
{entry.resetsInMs > 0 ? fmtDuration(entry.resetsInMs) : 'โ'}
|
|
437
|
+
</td>
|
|
438
|
+
<td style={{ padding: '8px 12px', textAlign: 'right', color: '#8b949e', fontSize: 12, fontFamily: "'SFMono-Regular', Consolas, monospace" }}>
|
|
439
|
+
{entry.latencyMs !== undefined ? `${entry.latencyMs}ms` : 'โ'}
|
|
440
|
+
</td>
|
|
441
|
+
<td style={{ padding: '8px 12px', color: '#6e7681', fontSize: 11, maxWidth: 180, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
|
442
|
+
{entry.lastError || (entry.source ? `learned via ${entry.source}` : '')}
|
|
443
|
+
</td>
|
|
444
|
+
</tr>);
|
|
445
|
+
}
|
|
446
|
+
function RegistryCard({ provider }) {
|
|
447
|
+
const [expanded, setExpanded] = (0, react_1.useState)(false);
|
|
448
|
+
const borderColor = provider.verified > 0 ? '#3fb950' : provider.unavailable > 0 ? '#f85149' : '#8b949e';
|
|
449
|
+
return (<div style={{
|
|
450
|
+
background: '#161b22', borderRadius: 12,
|
|
451
|
+
border: `1px solid ${borderColor}44`,
|
|
452
|
+
borderLeft: `4px solid ${borderColor}`,
|
|
453
|
+
marginBottom: 12, overflow: 'hidden',
|
|
454
|
+
}}>
|
|
455
|
+
<div onClick={() => setExpanded(!expanded)} style={{ padding: '14px 18px', cursor: 'pointer', display: 'flex', alignItems: 'center', gap: 14, userSelect: 'none' }}>
|
|
456
|
+
<span style={{ fontSize: 24 }}>{getProviderIcon(provider.provider)}</span>
|
|
457
|
+
<div style={{ flex: 1 }}>
|
|
458
|
+
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 2 }}>
|
|
459
|
+
<span style={{ fontSize: 16, fontWeight: 600, color: '#e6edf3' }}>{getProviderLabel(provider.provider)}</span>
|
|
460
|
+
<span style={{
|
|
461
|
+
fontSize: 11, padding: '2px 8px', borderRadius: 10,
|
|
462
|
+
background: provider.parked > 0 ? '#2d1616' : '#12291a',
|
|
463
|
+
border: `1px solid ${provider.parked > 0 ? '#f85149' : '#238636'}`,
|
|
464
|
+
color: provider.parked > 0 ? '#f85149' : '#3fb950',
|
|
465
|
+
}}>
|
|
466
|
+
{provider.parked > 0 ? `${provider.parked} parked` : 'routable'}
|
|
467
|
+
</span>
|
|
468
|
+
{(provider.flaky ?? 0) > 0 && (<span style={{
|
|
469
|
+
fontSize: 11, padding: '2px 8px', borderRadius: 10,
|
|
470
|
+
background: '#21122e', border: '1px solid #bc8cff', color: '#bc8cff',
|
|
471
|
+
}}>
|
|
472
|
+
โธ {provider.flaky} flaky
|
|
473
|
+
</span>)}
|
|
474
|
+
</div>
|
|
475
|
+
<div style={{ fontSize: 13, color: '#8b949e' }}>
|
|
476
|
+
{provider.verified} verified ยท {provider.unverified} unverified ยท {provider.unavailable} unavailable
|
|
477
|
+
</div>
|
|
478
|
+
</div>
|
|
479
|
+
<span style={{ color: '#8b949e', fontSize: 18, transition: 'transform 0.2s', transform: expanded ? 'rotate(90deg)' : 'rotate(0deg)' }}>โถ</span>
|
|
480
|
+
</div>
|
|
481
|
+
{expanded && (<div style={{ overflowX: 'auto', borderTop: '1px solid #21262d' }}>
|
|
482
|
+
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
|
|
483
|
+
<thead>
|
|
484
|
+
<tr style={{ borderBottom: '1px solid #21262d', color: '#8b949e' }}>
|
|
485
|
+
<th style={{ padding: '8px 12px', textAlign: 'left', fontWeight: 500 }}>Model</th>
|
|
486
|
+
<th style={{ padding: '8px 12px', textAlign: 'left', fontWeight: 500 }}>Availability</th>
|
|
487
|
+
<th style={{ padding: '8px 12px', textAlign: 'right', fontWeight: 500 }}>Tokens left</th>
|
|
488
|
+
<th style={{ padding: '8px 12px', textAlign: 'right', fontWeight: 500 }}>Resets in</th>
|
|
489
|
+
<th style={{ padding: '8px 12px', textAlign: 'right', fontWeight: 500 }}>Latency</th>
|
|
490
|
+
<th style={{ padding: '8px 12px', textAlign: 'left', fontWeight: 500 }}>Reason / Source</th>
|
|
491
|
+
</tr>
|
|
492
|
+
</thead>
|
|
493
|
+
<tbody>
|
|
494
|
+
{provider.models.map((entry) => (<RegistryEntryRow key={entry.model} entry={entry}/>))}
|
|
495
|
+
</tbody>
|
|
496
|
+
</table>
|
|
497
|
+
</div>)}
|
|
498
|
+
</div>);
|
|
499
|
+
}
|
|
500
|
+
// ISSUE-004: providers with consecutive 401/403 auth failures climbing toward
|
|
501
|
+
// the auto-clear threshold (3). Warns BEFORE the key is cleared so the user can
|
|
502
|
+
// fix it โ and confirms the cleanup happened once it's past the threshold.
|
|
503
|
+
function keyHygieneWarning(hygiene) {
|
|
504
|
+
if (!hygiene)
|
|
505
|
+
return null;
|
|
506
|
+
const pending = Object.entries(hygiene.consecutive).filter(([, count]) => count > 0);
|
|
507
|
+
if (pending.length === 0)
|
|
508
|
+
return null;
|
|
509
|
+
return (<div style={{
|
|
510
|
+
background: 'rgba(210, 153, 34, 0.08)', border: '1px solid #9e6a03',
|
|
511
|
+
borderRadius: 10, padding: '10px 14px', marginBottom: 14,
|
|
512
|
+
fontSize: 12, color: '#d29922', display: 'flex', gap: 10, alignItems: 'flex-start',
|
|
513
|
+
}}>
|
|
514
|
+
<span style={{ fontSize: 15, lineHeight: '18px' }}>๐งน</span>
|
|
515
|
+
<div>
|
|
516
|
+
<div style={{ fontWeight: 600, color: '#e3b341', marginBottom: 2 }}>Key hygiene in progress</div>
|
|
517
|
+
<div>
|
|
518
|
+
{pending.map(([provider, count]) => (<span key={provider} style={{ display: 'inline-block', marginRight: 12 }}>
|
|
519
|
+
<code style={{ color: '#e6edf3' }}>{provider}</code> {count}/{hygiene.threshold} consecutive auth failures
|
|
520
|
+
{count >= hygiene.threshold ? ' โ key auto-cleared ๐ซ' : ' โ key will be auto-cleared at the threshold'}
|
|
521
|
+
</span>))}
|
|
522
|
+
</div>
|
|
523
|
+
<div style={{ marginTop: 4, color: '#9e6a03' }}>
|
|
524
|
+
After {hygiene.threshold} consecutive 401/403s the invalid key is removed from config; run{' '}
|
|
525
|
+
<code style={{ color: '#58a6ff' }}>buff config set providers.<provider>.apiKey <real-key></code> to re-enable.
|
|
526
|
+
</div>
|
|
527
|
+
</div>
|
|
528
|
+
</div>);
|
|
529
|
+
}
|
|
530
|
+
function ModelRegistrySection({ data }) {
|
|
531
|
+
if (!data.enabled) {
|
|
532
|
+
return (<div style={{
|
|
533
|
+
background: '#161b22', borderRadius: 12, border: '1px dashed #30363d',
|
|
534
|
+
padding: '20px 24px', marginTop: 24, textAlign: 'center',
|
|
535
|
+
}}>
|
|
536
|
+
<div style={{ fontSize: 24, marginBottom: 8 }}>๐ฆ</div>
|
|
537
|
+
<div style={{ fontSize: 14, fontWeight: 600, color: '#e6edf3', marginBottom: 4 }}>
|
|
538
|
+
Model Availability Registry
|
|
539
|
+
</div>
|
|
540
|
+
<div style={{ fontSize: 12, color: '#6e7681' }}>
|
|
541
|
+
No registry data yet โ run <code style={{ color: '#58a6ff' }}>buff models refresh</code> or use Auto routing;
|
|
542
|
+
the registry learns from real usage and probes.
|
|
543
|
+
</div>
|
|
544
|
+
</div>);
|
|
545
|
+
}
|
|
546
|
+
return (<>
|
|
547
|
+
<h2 className="section-title" style={{ marginTop: 36 }}>๐ฆ Model Availability Registry โ the store routing reads</h2>
|
|
548
|
+
<p className="section-description">
|
|
549
|
+
The unified sub-ms FAISS/JSON snapshot the Auto router consults on every pick:
|
|
550
|
+
verified vs unavailable models plus quota telemetry (tokens remaining, reset
|
|
551
|
+
windows) mirrored from the ledger. Each row's <strong>โณ chip</strong> is the
|
|
552
|
+
LIVE provider-advertised context window (v1.60.x) โ the real spec the router's
|
|
553
|
+
context preflight uses, recorded by the probe from each provider's model list
|
|
554
|
+
(Ollama /api/tags + /api/show, OpenRouter, Gemini, NIM). State changes during a
|
|
555
|
+
session are reported to the watch daemon and recorded here immediately.
|
|
556
|
+
</p>
|
|
557
|
+
|
|
558
|
+
<div className="stats-grid" style={{ marginBottom: 16 }}>
|
|
559
|
+
<div className="stat-card">
|
|
560
|
+
<span className="stat-icon">๐ฆ</span>
|
|
561
|
+
<div className="stat-body">
|
|
562
|
+
<div className="stat-value">{data.total}</div>
|
|
563
|
+
<div className="stat-label">Tracked models</div>
|
|
564
|
+
</div>
|
|
565
|
+
</div>
|
|
566
|
+
<div className="stat-card">
|
|
567
|
+
<span className="stat-icon">โ
</span>
|
|
568
|
+
<div className="stat-body">
|
|
569
|
+
<div className="stat-value" style={{ color: '#3fb950' }}>{data.verified}</div>
|
|
570
|
+
<div className="stat-label">Verified</div>
|
|
571
|
+
</div>
|
|
572
|
+
</div>
|
|
573
|
+
<div className="stat-card">
|
|
574
|
+
<span className="stat-icon">โ</span>
|
|
575
|
+
<div className="stat-body">
|
|
576
|
+
<div className="stat-value" style={{ color: '#8b949e' }}>{data.unverified}</div>
|
|
577
|
+
<div className="stat-label">Unverified</div>
|
|
578
|
+
</div>
|
|
579
|
+
</div>
|
|
580
|
+
<div className="stat-card">
|
|
581
|
+
<span className="stat-icon">โ</span>
|
|
582
|
+
<div className="stat-body">
|
|
583
|
+
<div className="stat-value" style={{ color: '#f85149' }}>{data.unavailable}</div>
|
|
584
|
+
<div className="stat-label">Unavailable</div>
|
|
585
|
+
</div>
|
|
586
|
+
</div>
|
|
587
|
+
<div className="stat-card">
|
|
588
|
+
<span className="stat-icon">โธ</span>
|
|
589
|
+
<div className="stat-body">
|
|
590
|
+
<div className="stat-value" style={{ color: '#d29922' }}>{data.parked}</div>
|
|
591
|
+
<div className="stat-label">Quota-parked</div>
|
|
592
|
+
</div>
|
|
593
|
+
</div>
|
|
594
|
+
<div className="stat-card">
|
|
595
|
+
<span className="stat-icon">โธ</span>
|
|
596
|
+
<div className="stat-body">
|
|
597
|
+
<div className="stat-value" style={{ color: '#bc8cff' }}>{data.flaky ?? 0}</div>
|
|
598
|
+
<div className="stat-label">Flaky mid-stream</div>
|
|
599
|
+
</div>
|
|
600
|
+
</div>
|
|
601
|
+
<div className="stat-card">
|
|
602
|
+
<span className="stat-icon">๐๏ธ</span>
|
|
603
|
+
<div className="stat-body">
|
|
604
|
+
<div className="stat-value" style={{ color: '#8b949e' }}>{data.deletedLocal ?? 0}</div>
|
|
605
|
+
<div className="stat-label">Deleted locally</div>
|
|
606
|
+
</div>
|
|
607
|
+
</div>
|
|
608
|
+
</div>
|
|
609
|
+
|
|
610
|
+
{keyHygieneWarning(data.keyHygiene)}
|
|
611
|
+
|
|
612
|
+
{data.providers.map((provider) => (<RegistryCard key={provider.provider} provider={provider}/>))}
|
|
613
|
+
|
|
614
|
+
<div style={{ textAlign: 'center', fontSize: 12, color: '#484f58', marginTop: 12 }}>
|
|
615
|
+
Backend snapshot ยท auto-refreshes every 60s
|
|
616
|
+
</div>
|
|
617
|
+
</>);
|
|
618
|
+
}
|
|
619
|
+
// โโโ Learned-from-real-usage Telemetry (per action) โโโโโโโโโโโโโโโโโโโโโโโโโ
|
|
620
|
+
// Every LLM call writes through to the health store WITH its action tag (chat /
|
|
621
|
+
// execute / plan / edit / ...). This section shows which provider ร model each
|
|
622
|
+
// action verified or killed โ making the predictive skips routing performs
|
|
623
|
+
// visible: a provider killed by ANY action is skipped by all others.
|
|
624
|
+
const ACTION_ICONS = {
|
|
625
|
+
chat: '๐ฌ', execute: 'โ๏ธ', plan: '๐บ๏ธ', edit: 'โ๏ธ', skill: '๐งฉ', learn: '๐',
|
|
626
|
+
ci: '๐', doctor: '๐ฉบ', probe: '๐ญ', 'spot-check': '๐งช', telemetry: '๐ก', usage: '๐งฎ',
|
|
627
|
+
};
|
|
628
|
+
function actionLabel(action) {
|
|
629
|
+
const map = {
|
|
630
|
+
chat: 'Chat', execute: 'Execute', plan: 'Plan', edit: 'Edit', skill: 'Skill',
|
|
631
|
+
learn: 'Learn', ci: 'CI Review', doctor: 'Doctor Probe', probe: 'Probe',
|
|
632
|
+
'spot-check': 'Spot-check', telemetry: 'Usage mirror',
|
|
633
|
+
};
|
|
634
|
+
return map[action] || action.charAt(0).toUpperCase() + action.slice(1);
|
|
635
|
+
}
|
|
636
|
+
function actionIcon(action) {
|
|
637
|
+
return ACTION_ICONS[action] || '๐ฏ';
|
|
638
|
+
}
|
|
639
|
+
function fmtShortTime(ms) {
|
|
640
|
+
if (!ms)
|
|
641
|
+
return 'โ';
|
|
642
|
+
return new Date(ms).toLocaleDateString() + ' ' +
|
|
643
|
+
new Date(ms).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
|
644
|
+
}
|
|
645
|
+
function ModelLearnChip({ provider, model, reason, killed, transient, partial, streamedChunks }) {
|
|
646
|
+
const isKilled = killed === true;
|
|
647
|
+
const isTransient = transient === true;
|
|
648
|
+
const isPartial = partial === true;
|
|
649
|
+
// Partial gets its own violet signal: distinct from a clean error (transient)
|
|
650
|
+
// because a provider that starts-but-can't-finish is a worse reliability
|
|
651
|
+
// signal โ the router deprioritizes flaky mid-stream providers.
|
|
652
|
+
const color = isPartial ? '#bc8cff' : isTransient ? '#d29922' : isKilled ? '#f85149' : '#3fb950';
|
|
653
|
+
const bg = isPartial ? '#21122e' : isTransient ? '#2d1f00' : isKilled ? '#2d0f0f' : '#0a2e1a';
|
|
654
|
+
return (<span title={isPartial
|
|
655
|
+
? `Mid-stream interruption โ started streaming${typeof streamedChunks === 'number' ? ` ~${streamedChunks} chunks in` : ''}, died before finish (P4 M4.4); router deprioritizes flaky mid-stream providers${reason ? ` ยท ${reason}` : ''}`
|
|
656
|
+
: isTransient
|
|
657
|
+
? `Transient failure โ health decayed, no flip${reason ? ` ยท ${reason}` : ''}`
|
|
658
|
+
: isKilled
|
|
659
|
+
? `Killed by this action โ predictively skipped by routing${reason ? ` ยท ${reason}` : ''}`
|
|
660
|
+
: 'Verified by this action โ trusted by routing'} style={{
|
|
661
|
+
display: 'inline-flex', alignItems: 'center', gap: 6,
|
|
662
|
+
background: bg, border: `1px solid ${color}`,
|
|
663
|
+
color, padding: '3px 9px', borderRadius: 8, fontSize: 11,
|
|
664
|
+
fontFamily: "'SFMono-Regular', Consolas, monospace", whiteSpace: 'nowrap',
|
|
665
|
+
transition: 'transform 0.15s',
|
|
666
|
+
cursor: 'default',
|
|
667
|
+
}} onMouseEnter={(e) => { e.currentTarget.style.transform = 'scale(1.05)'; }} onMouseLeave={(e) => { e.currentTarget.style.transform = 'scale(1)'; }}>
|
|
668
|
+
<span style={{ opacity: 0.85 }}>{isPartial ? 'โธ' : isTransient ? '~' : isKilled ? 'โ' : 'โ'}</span>
|
|
669
|
+
{provider}/{model.length > 30 ? model.slice(0, 27) + 'โฆ' : model}
|
|
670
|
+
{(isKilled || isPartial) && reason && (<span style={{ color: `${color}99`, fontSize: 10, fontWeight: 400 }}>ยท {reason}</span>)}
|
|
671
|
+
</span>);
|
|
672
|
+
}
|
|
673
|
+
/**
|
|
674
|
+
* One chip per provider ร model ร outcome for a day (latest event wins),
|
|
675
|
+
* ordered killed โ partial โ verified โ transient so the most actionable
|
|
676
|
+
* learning (predictive skips first, then flaky mid-stream providers) surfaces
|
|
677
|
+
* first.
|
|
678
|
+
*/
|
|
679
|
+
function dedupeDayEvents(events) {
|
|
680
|
+
const latest = new Map();
|
|
681
|
+
for (const e of events)
|
|
682
|
+
latest.set(`${e.provider}|${e.model}|${e.outcome}`, e);
|
|
683
|
+
const priority = { unavailable: 0, partial: 1, verified: 2, error: 3 };
|
|
684
|
+
return [...latest.values()].sort((a, b) => priority[a.outcome] - priority[b.outcome] || b.at - a.at);
|
|
685
|
+
}
|
|
686
|
+
/** Default scrub position: the most recent day with events (else the last day). */
|
|
687
|
+
function lastDayWithEvents(timeline) {
|
|
688
|
+
for (let i = timeline.length - 1; i >= 0; i--) {
|
|
689
|
+
const b = timeline[i];
|
|
690
|
+
if (b.verified + b.killed + b.transient + (b.partial || 0) > 0)
|
|
691
|
+
return i;
|
|
692
|
+
}
|
|
693
|
+
return Math.max(0, timeline.length - 1);
|
|
694
|
+
}
|
|
695
|
+
function ActionTimelineChart({ timeline }) {
|
|
696
|
+
const [dayIdx, setDayIdx] = (0, react_1.useState)(() => lastDayWithEvents(timeline || []));
|
|
697
|
+
const [playing, setPlaying] = (0, react_1.useState)(false);
|
|
698
|
+
const trackRef = (0, react_1.useRef)(null);
|
|
699
|
+
const draggingRef = (0, react_1.useRef)(false);
|
|
700
|
+
const len = timeline?.length ?? 0;
|
|
701
|
+
const clamped = Math.max(0, Math.min(dayIdx, len - 1));
|
|
702
|
+
// Clamp the selection when the timeline refreshes (60s poll) and resizes.
|
|
703
|
+
(0, react_1.useEffect)(() => {
|
|
704
|
+
setDayIdx((cur) => Math.min(cur, Math.max(0, len - 1)));
|
|
705
|
+
}, [len]);
|
|
706
|
+
// Drag: pointer-down on the track grabs it; window move/up drive the caret.
|
|
707
|
+
const setFromClientX = (0, react_1.useCallback)((clientX) => {
|
|
708
|
+
const el = trackRef.current;
|
|
709
|
+
if (!el || len <= 0)
|
|
710
|
+
return;
|
|
711
|
+
const rect = el.getBoundingClientRect();
|
|
712
|
+
const width = rect.width || 1;
|
|
713
|
+
const frac = Math.min(1, Math.max(0, (clientX - rect.left) / width));
|
|
714
|
+
setDayIdx(Math.min(len - 1, Math.floor(frac * len)));
|
|
715
|
+
}, [len]);
|
|
716
|
+
const handlePointerDown = (e) => {
|
|
717
|
+
draggingRef.current = true;
|
|
718
|
+
setPlaying(false);
|
|
719
|
+
setFromClientX(e.clientX);
|
|
720
|
+
};
|
|
721
|
+
// Register the window listeners once; the drag flag lives in a ref so the
|
|
722
|
+
// listeners don't churn on every caret tick during playback.
|
|
723
|
+
(0, react_1.useEffect)(() => {
|
|
724
|
+
const onMove = (e) => {
|
|
725
|
+
if (draggingRef.current)
|
|
726
|
+
setFromClientX(e.clientX);
|
|
727
|
+
};
|
|
728
|
+
const onUp = () => {
|
|
729
|
+
draggingRef.current = false;
|
|
730
|
+
};
|
|
731
|
+
window.addEventListener('pointermove', onMove);
|
|
732
|
+
window.addEventListener('pointerup', onUp);
|
|
733
|
+
return () => {
|
|
734
|
+
window.removeEventListener('pointermove', onMove);
|
|
735
|
+
window.removeEventListener('pointerup', onUp);
|
|
736
|
+
};
|
|
737
|
+
}, [setFromClientX]);
|
|
738
|
+
// Playback: sweep one day at a time (~300ms/day), then rewind + rest.
|
|
739
|
+
(0, react_1.useEffect)(() => {
|
|
740
|
+
if (!playing || len <= 1)
|
|
741
|
+
return;
|
|
742
|
+
const interval = setInterval(() => {
|
|
743
|
+
setDayIdx((prev) => Math.min(len - 1, prev + 1));
|
|
744
|
+
}, 300);
|
|
745
|
+
return () => clearInterval(interval);
|
|
746
|
+
}, [playing, len]);
|
|
747
|
+
// Reached the last day during playback โ stop and rewind to the start.
|
|
748
|
+
// len > 1 guard keeps a single-day timeline from instantly stop/rewinding
|
|
749
|
+
// (the interval effect already refuses to sweep for len <= 1).
|
|
750
|
+
(0, react_1.useEffect)(() => {
|
|
751
|
+
if (playing && len > 1 && dayIdx >= len - 1) {
|
|
752
|
+
setPlaying(false);
|
|
753
|
+
setDayIdx(0);
|
|
754
|
+
}
|
|
755
|
+
}, [playing, len, dayIdx]);
|
|
756
|
+
if (!timeline || len === 0)
|
|
757
|
+
return null;
|
|
758
|
+
const max = Math.max(1, ...timeline.map((b) => b.verified + b.killed + b.transient + (b.partial || 0)));
|
|
759
|
+
const dayLabel = (day) => new Date(day).toLocaleDateString([], { month: 'short', day: 'numeric' });
|
|
760
|
+
const day = timeline[clamped];
|
|
761
|
+
const chips = day ? dedupeDayEvents(day.events || []) : [];
|
|
762
|
+
return (<div style={{ marginTop: 12, paddingTop: 10, borderTop: '1px solid #21262d' }}>
|
|
763
|
+
<div style={{
|
|
764
|
+
fontSize: 11, fontWeight: 600, color: '#8b949e', marginBottom: 8,
|
|
765
|
+
textTransform: 'uppercase', letterSpacing: 0.4,
|
|
766
|
+
display: 'flex', alignItems: 'center', gap: 6, flexWrap: 'wrap',
|
|
767
|
+
}}>
|
|
768
|
+
<span>๐ Learned from real usage โ last {len} days</span>
|
|
769
|
+
<span style={{ fontWeight: 400, color: '#6e7681', letterSpacing: 0 }}>
|
|
770
|
+
โ drag across days ยท click a day ยท play to sweep
|
|
771
|
+
</span>
|
|
772
|
+
</div>
|
|
773
|
+
|
|
774
|
+
{/* Scrub controls (matches the Run Timeline interaction) */}
|
|
775
|
+
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 8 }}>
|
|
776
|
+
<button onClick={() => {
|
|
777
|
+
if (playing) {
|
|
778
|
+
setPlaying(false);
|
|
779
|
+
}
|
|
780
|
+
else {
|
|
781
|
+
if (dayIdx >= len - 1)
|
|
782
|
+
setDayIdx(0);
|
|
783
|
+
setPlaying(true);
|
|
784
|
+
}
|
|
785
|
+
}} disabled={len <= 1} aria-label={playing ? 'Pause scrub' : 'Play scrub'} style={{
|
|
786
|
+
background: '#21262d', border: `1px solid ${playing ? '#f85149' : '#30363d'}`,
|
|
787
|
+
color: '#e6edf3', padding: '3px 10px', borderRadius: 6,
|
|
788
|
+
cursor: len <= 1 ? 'not-allowed' : 'pointer', fontSize: 11, fontWeight: 600,
|
|
789
|
+
transition: 'all 0.15s', whiteSpace: 'nowrap',
|
|
790
|
+
opacity: len <= 1 ? 0.5 : 1,
|
|
791
|
+
}}>
|
|
792
|
+
{playing ? 'โธ Pause' : 'โถ Play'}
|
|
793
|
+
</button>
|
|
794
|
+
<span style={{ fontSize: 11, color: '#8b949e', whiteSpace: 'nowrap' }}>
|
|
795
|
+
{dayLabel(day.day)} ยท โ {day.verified} ยท โ {day.killed}
|
|
796
|
+
{day.transient > 0 ? ` ยท ~ ${day.transient}` : ''}
|
|
797
|
+
{day.partial > 0 ? ` ยท โธ ${day.partial}` : ''}
|
|
798
|
+
</span>
|
|
799
|
+
<input type="range" min={0} max={Math.max(0, len - 1)} step={1} value={clamped} onChange={(e) => { setPlaying(false); setDayIdx(Number(e.target.value)); }} aria-label="Scrub action timeline" style={{ flex: 1, accentColor: '#58a6ff', cursor: 'pointer', minWidth: 80 }}/>
|
|
800
|
+
</div>
|
|
801
|
+
|
|
802
|
+
{/* Day bars โ the scrub track */}
|
|
803
|
+
<div ref={trackRef} onPointerDown={handlePointerDown} style={{
|
|
804
|
+
position: 'relative', display: 'flex', alignItems: 'flex-end',
|
|
805
|
+
gap: 3, height: 64, padding: '0 2px', cursor: 'grab',
|
|
806
|
+
userSelect: 'none', touchAction: 'none',
|
|
807
|
+
}}>
|
|
808
|
+
{timeline.map((b, i) => {
|
|
809
|
+
const total = b.verified + b.killed + b.transient + (b.partial || 0);
|
|
810
|
+
const hVerified = (b.verified / max) * 56;
|
|
811
|
+
const hKilled = (b.killed / max) * 56;
|
|
812
|
+
const hTransient = (b.transient / max) * 56;
|
|
813
|
+
const hPartial = ((b.partial || 0) / max) * 56;
|
|
814
|
+
const isActive = i === clamped;
|
|
815
|
+
return (<div key={b.day} onClick={() => { setPlaying(false); setDayIdx(i); }} title={`${dayLabel(b.day)} โ โ ${b.verified} verified ยท โ ${b.killed} killed ยท ~ ${b.transient} transient${(b.partial || 0) > 0 ? ` ยท โธ ${b.partial} partial` : ''}`} style={{
|
|
816
|
+
flex: 1, display: 'flex', flexDirection: 'column-reverse',
|
|
817
|
+
alignItems: 'center', gap: 0, cursor: 'pointer',
|
|
818
|
+
}}>
|
|
819
|
+
<div style={{
|
|
820
|
+
position: 'relative', width: '100%', borderRadius: 3,
|
|
821
|
+
overflow: isActive ? 'visible' : 'hidden',
|
|
822
|
+
background: total === 0 ? '#21262d' : 'transparent',
|
|
823
|
+
height: total === 0 ? 4 : 56,
|
|
824
|
+
display: 'flex', flexDirection: 'column-reverse',
|
|
825
|
+
boxShadow: isActive ? '0 0 0 1.5px #58a6ff' : undefined,
|
|
826
|
+
opacity: total === 0 ? 0.5 : 1,
|
|
827
|
+
transition: 'box-shadow 0.15s',
|
|
828
|
+
}}>
|
|
829
|
+
{/* Caret โ pinned to the ACTIVE bar's own geometry (gap/padding exact) */}
|
|
830
|
+
{isActive && (<div style={{
|
|
831
|
+
position: 'absolute', top: -3, bottom: -3, width: 2, left: '50%',
|
|
832
|
+
transform: 'translateX(-50%)',
|
|
833
|
+
background: '#58a6ff', borderRadius: 2, pointerEvents: 'none',
|
|
834
|
+
boxShadow: '0 0 8px #58a6ff88', zIndex: 1,
|
|
835
|
+
}}/>)}
|
|
836
|
+
{b.verified > 0 && (<div style={{ height: hVerified, background: '#3fb950', minHeight: 3 }}/>)}
|
|
837
|
+
{b.killed > 0 && (<div style={{ height: hKilled, background: '#f85149', minHeight: 3 }}/>)}
|
|
838
|
+
{b.transient > 0 && (<div style={{ height: hTransient, background: '#d29922', minHeight: 3 }}/>)}
|
|
839
|
+
{b.partial > 0 && (<div style={{ height: hPartial, background: '#bc8cff', minHeight: 3 }}/>)}
|
|
840
|
+
</div>
|
|
841
|
+
<div style={{
|
|
842
|
+
fontSize: 9, color: isActive ? '#58a6ff' : '#6e7681', marginTop: 4,
|
|
843
|
+
whiteSpace: 'nowrap', fontWeight: isActive ? 700 : 400,
|
|
844
|
+
}}>
|
|
845
|
+
{dayLabel(b.day)}
|
|
846
|
+
</div>
|
|
847
|
+
</div>);
|
|
848
|
+
})}
|
|
849
|
+
</div>
|
|
850
|
+
<div style={{ display: 'flex', gap: 14, marginTop: 6, fontSize: 11, color: '#8b949e' }}>
|
|
851
|
+
<span><span style={{ color: '#3fb950' }}>โ </span> verified</span>
|
|
852
|
+
<span><span style={{ color: '#f85149' }}>โ </span> killed</span>
|
|
853
|
+
<span><span style={{ color: '#d29922' }}>โ </span> transient</span>
|
|
854
|
+
<span><span style={{ color: '#bc8cff' }}>โ </span> partial</span>
|
|
855
|
+
</div>
|
|
856
|
+
|
|
857
|
+
{/* Day detail โ the chips for the scrubbed day */}
|
|
858
|
+
<div style={{
|
|
859
|
+
marginTop: 10, background: '#161b22', border: '1px solid #21262d',
|
|
860
|
+
borderRadius: 8, padding: '10px 12px',
|
|
861
|
+
}}>
|
|
862
|
+
<div style={{ fontSize: 12, fontWeight: 600, color: '#e6edf3', marginBottom: 6 }}>
|
|
863
|
+
{dayLabel(day.day)}{' '}
|
|
864
|
+
<span style={{ color: '#8b949e', fontWeight: 400 }}>
|
|
865
|
+
โ what this action learned that day
|
|
866
|
+
</span>
|
|
867
|
+
</div>
|
|
868
|
+
{chips.length === 0 ? (<div style={{ fontSize: 12, color: '#6e7681' }}>
|
|
869
|
+
No learning recorded that day โ nothing verified, killed, or partial.
|
|
870
|
+
</div>) : (<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6 }}>
|
|
871
|
+
{chips.map((e) => e.outcome === 'unavailable' ? (<ModelLearnChip key={`${e.provider}|${e.model}|${e.outcome}`} provider={e.provider} model={e.model} reason={e.errorType} killed/>) : e.outcome === 'verified' ? (<ModelLearnChip key={`${e.provider}|${e.model}|${e.outcome}`} provider={e.provider} model={e.model}/>) : e.outcome === 'partial' ? (<ModelLearnChip key={`${e.provider}|${e.model}|${e.outcome}`} provider={e.provider} model={e.model} reason={e.errorType} partial streamedChunks={e.streamedChunks}/>) : (<ModelLearnChip key={`${e.provider}|${e.model}|${e.outcome}`} provider={e.provider} model={e.model} reason={e.errorType} transient/>))}
|
|
872
|
+
</div>)}
|
|
873
|
+
</div>
|
|
874
|
+
</div>);
|
|
875
|
+
}
|
|
876
|
+
function ActionTelemetryCard({ entry }) {
|
|
877
|
+
const [expanded, setExpanded] = (0, react_1.useState)(true);
|
|
878
|
+
// Partial mid-stream interruptions are the strongest reliability signal โ
|
|
879
|
+
// violet border wins even over killed (a provider that starts-but-can't-
|
|
880
|
+
// finish is worse than one that errors cleanly).
|
|
881
|
+
const borderColor = (entry.partial || 0) > 0 ? '#bc8cff' : entry.killed > 0 ? '#f85149' : entry.verified > 0 ? '#3fb950' : '#d29922';
|
|
882
|
+
return (<div style={{
|
|
883
|
+
background: '#161b22', borderRadius: 12,
|
|
884
|
+
border: `1px solid ${borderColor}33`,
|
|
885
|
+
borderLeft: `4px solid ${borderColor}`,
|
|
886
|
+
marginBottom: 12, overflow: 'hidden',
|
|
887
|
+
}}>
|
|
888
|
+
<div onClick={() => setExpanded(!expanded)} style={{ padding: '12px 16px', cursor: 'pointer', display: 'flex', alignItems: 'center', gap: 12, userSelect: 'none' }}>
|
|
889
|
+
<span style={{ fontSize: 20 }}>{actionIcon(entry.action)}</span>
|
|
890
|
+
<div style={{ flex: 1 }}>
|
|
891
|
+
<div style={{ fontSize: 14, fontWeight: 600, color: '#e6edf3', marginBottom: 2 }}>
|
|
892
|
+
{actionLabel(entry.action)}
|
|
893
|
+
</div>
|
|
894
|
+
<div style={{ fontSize: 12, color: '#8b949e', display: 'flex', gap: 14, flexWrap: 'wrap' }}>
|
|
895
|
+
<span><span style={{ color: '#3fb950' }}>โ {entry.verified}</span> verified</span>
|
|
896
|
+
<span><span style={{ color: '#f85149' }}>โ {entry.killed}</span> killed</span>
|
|
897
|
+
{entry.transient > 0 && <span><span style={{ color: '#d29922' }}>~ {entry.transient}</span> transient</span>}
|
|
898
|
+
{(entry.partial || 0) > 0 && <span><span style={{ color: '#bc8cff' }}>โธ {entry.partial}</span> partial</span>}
|
|
899
|
+
</div>
|
|
900
|
+
</div>
|
|
901
|
+
<span style={{ color: '#8b949e', fontSize: 16, transition: 'transform 0.2s', transform: expanded ? 'rotate(90deg)' : 'rotate(0deg)' }}>โถ</span>
|
|
902
|
+
</div>
|
|
903
|
+
{expanded && (<div style={{ padding: '12px 16px', borderTop: '1px solid #21262d', background: '#0d1117' }}>
|
|
904
|
+
{entry.killedModels.length > 0 && (<>
|
|
905
|
+
<div style={{
|
|
906
|
+
fontSize: 11, fontWeight: 600, color: '#f85149', marginBottom: 6,
|
|
907
|
+
textTransform: 'uppercase', letterSpacing: 0.4,
|
|
908
|
+
}}>
|
|
909
|
+
โ Killed โ skipped predictively by routing
|
|
910
|
+
</div>
|
|
911
|
+
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6, marginBottom: 14 }}>
|
|
912
|
+
{entry.killedModels.map((m) => (<ModelLearnChip key={`${m.provider}|${m.model}`} provider={m.provider} model={m.model} reason={m.reason} killed/>))}
|
|
913
|
+
</div>
|
|
914
|
+
</>)}
|
|
915
|
+
{entry.verifiedModels.length > 0 && (<>
|
|
916
|
+
<div style={{
|
|
917
|
+
fontSize: 11, fontWeight: 600, color: '#3fb950', marginBottom: 6,
|
|
918
|
+
textTransform: 'uppercase', letterSpacing: 0.4,
|
|
919
|
+
}}>
|
|
920
|
+
โ
Verified โ trusted by routing
|
|
921
|
+
</div>
|
|
922
|
+
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6 }}>
|
|
923
|
+
{entry.verifiedModels.map((m) => (<ModelLearnChip key={`${m.provider}|${m.model}`} provider={m.provider} model={m.model}/>))}
|
|
924
|
+
</div>
|
|
925
|
+
</>)}
|
|
926
|
+
{(entry.partialModels?.length || 0) > 0 && (<>
|
|
927
|
+
<div style={{
|
|
928
|
+
fontSize: 11, fontWeight: 600, color: '#bc8cff', marginBottom: 6,
|
|
929
|
+
textTransform: 'uppercase', letterSpacing: 0.4,
|
|
930
|
+
}}>
|
|
931
|
+
โธ Partial โ mid-stream interruption (flaky provider, deprioritized)
|
|
932
|
+
</div>
|
|
933
|
+
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6, marginBottom: 14 }}>
|
|
934
|
+
{entry.partialModels.map((m) => (<ModelLearnChip key={`${m.provider}|${m.model}`} provider={m.provider} model={m.model} reason={m.reason} partial streamedChunks={m.streamedChunks}/>))}
|
|
935
|
+
</div>
|
|
936
|
+
</>)}
|
|
937
|
+
{entry.killedModels.length === 0 && entry.verifiedModels.length === 0 && (entry.partialModels?.length || 0) === 0 && (<div style={{ fontSize: 12, color: '#6e7681' }}>
|
|
938
|
+
Only transient failures โ health decayed, no model flipped.
|
|
939
|
+
</div>)}
|
|
940
|
+
<ActionTimelineChart timeline={entry.timeline}/>
|
|
941
|
+
</div>)}
|
|
942
|
+
</div>);
|
|
943
|
+
}
|
|
944
|
+
function ActionTelemetrySection({ registry }) {
|
|
945
|
+
const tele = registry.actionTelemetry;
|
|
946
|
+
if (!tele)
|
|
947
|
+
return null;
|
|
948
|
+
if (!tele.enabled) {
|
|
949
|
+
return (<div style={{
|
|
950
|
+
background: '#161b22', borderRadius: 12, border: '1px dashed #30363d',
|
|
951
|
+
padding: '18px 24px', marginTop: 24, textAlign: 'center',
|
|
952
|
+
}}>
|
|
953
|
+
<div style={{ fontSize: 22, marginBottom: 6 }}>๐</div>
|
|
954
|
+
<div style={{ fontSize: 13, fontWeight: 600, color: '#e6edf3', marginBottom: 4 }}>
|
|
955
|
+
Learned from real usage โ per action
|
|
956
|
+
</div>
|
|
957
|
+
<div style={{ fontSize: 12, color: '#6e7681', lineHeight: 1.5 }}>
|
|
958
|
+
No per-action telemetry yet. As you use <strong style={{ color: '#8b949e' }}>chat</strong>,{' '}
|
|
959
|
+
<strong style={{ color: '#8b949e' }}>execute</strong>, <strong style={{ color: '#8b949e' }}>plan</strong>,
|
|
960
|
+
and <strong style={{ color: '#8b949e' }}>edit</strong>, each action's verified / killed provider ร
|
|
961
|
+
model combos appear here โ showing exactly what routing learned from real usage.
|
|
962
|
+
</div>
|
|
963
|
+
</div>);
|
|
964
|
+
}
|
|
965
|
+
return (<>
|
|
966
|
+
<h2 className="section-title" style={{ marginTop: 36 }}>๐ Learned from real usage โ per action</h2>
|
|
967
|
+
<p className="section-description">
|
|
968
|
+
Every LLM call writes through to the health store with its action tag. This panel shows which
|
|
969
|
+
provider ร model each action <span style={{ color: '#3fb950' }}>verified</span> (routable) or{' '}
|
|
970
|
+
<span style={{ color: '#f85149' }}>killed</span> (predictively skipped) โ the exact feed that turns
|
|
971
|
+
“fail gemini โ fail nim โ local” into “straight to local”.
|
|
972
|
+
</p>
|
|
973
|
+
|
|
974
|
+
<div className="stats-grid mini" style={{ marginBottom: 16 }}>
|
|
975
|
+
<div className="stat-card">
|
|
976
|
+
<span className="stat-icon">๐</span>
|
|
977
|
+
<div className="stat-body">
|
|
978
|
+
<div className="stat-value">{tele.total}</div>
|
|
979
|
+
<div className="stat-label">Telemetry events</div>
|
|
980
|
+
</div>
|
|
981
|
+
</div>
|
|
982
|
+
<div className="stat-card">
|
|
983
|
+
<span className="stat-icon">๐ฏ</span>
|
|
984
|
+
<div className="stat-body">
|
|
985
|
+
<div className="stat-value">{tele.actions.length}</div>
|
|
986
|
+
<div className="stat-label">Actions learning</div>
|
|
987
|
+
</div>
|
|
988
|
+
</div>
|
|
989
|
+
<div className="stat-card">
|
|
990
|
+
<span className="stat-icon">โฑ๏ธ</span>
|
|
991
|
+
<div className="stat-body">
|
|
992
|
+
<div className="stat-value" style={{ fontSize: 15 }}>{fmtShortTime(tele.updatedAt)}</div>
|
|
993
|
+
<div className="stat-label">Last update</div>
|
|
994
|
+
</div>
|
|
995
|
+
</div>
|
|
996
|
+
</div>
|
|
997
|
+
|
|
998
|
+
{tele.actions.map((a) => <ActionTelemetryCard key={a.action} entry={a}/>)}
|
|
999
|
+
|
|
1000
|
+
<div style={{ textAlign: 'center', fontSize: 12, color: '#484f58', marginTop: 12 }}>
|
|
1001
|
+
All actions share one health store โ a provider killed by any action is skipped by all others
|
|
1002
|
+
</div>
|
|
1003
|
+
</>);
|
|
1004
|
+
}
|
|
1005
|
+
// โโโ Section Header โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
|
1006
|
+
function SectionHeader({ icon, title, count }) {
|
|
1007
|
+
if (count === 0)
|
|
1008
|
+
return null;
|
|
1009
|
+
return (<h3 style={{
|
|
1010
|
+
fontSize: 15, fontWeight: 600, color: '#e6edf3',
|
|
1011
|
+
margin: '24px 0 12px 0', display: 'flex', alignItems: 'center', gap: 8,
|
|
1012
|
+
}}>
|
|
1013
|
+
<span>{icon}</span> {title}
|
|
1014
|
+
<span style={{
|
|
1015
|
+
fontSize: 12, color: '#8b949e', fontWeight: 400,
|
|
1016
|
+
background: '#161b22', padding: '1px 8px', borderRadius: 8,
|
|
1017
|
+
}}>
|
|
1018
|
+
{count}
|
|
1019
|
+
</span>
|
|
1020
|
+
</h3>);
|
|
1021
|
+
}
|
|
1022
|
+
// โโโ Search Bar โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
|
1023
|
+
function SearchBar({ value, onChange, totalCount }) {
|
|
1024
|
+
return (<div style={{
|
|
1025
|
+
display: 'flex', alignItems: 'center', gap: 12,
|
|
1026
|
+
marginBottom: 14,
|
|
1027
|
+
}}>
|
|
1028
|
+
<div style={{
|
|
1029
|
+
flex: 1, position: 'relative',
|
|
1030
|
+
display: 'flex', alignItems: 'center',
|
|
1031
|
+
background: '#161b22', borderRadius: 8,
|
|
1032
|
+
border: '1px solid #30363d',
|
|
1033
|
+
transition: 'border-color 0.2s',
|
|
1034
|
+
}}>
|
|
1035
|
+
<span style={{
|
|
1036
|
+
position: 'absolute', left: 12, fontSize: 14, color: '#6e7681',
|
|
1037
|
+
pointerEvents: 'none',
|
|
1038
|
+
}}>๐</span>
|
|
1039
|
+
<input type="text" value={value} onChange={(e) => onChange(e.target.value)} placeholder="Search models by name or provider..." style={{
|
|
1040
|
+
width: '100%', padding: '10px 12px 10px 36px',
|
|
1041
|
+
background: 'transparent', border: 'none',
|
|
1042
|
+
color: '#e6edf3', fontSize: 13,
|
|
1043
|
+
outline: 'none',
|
|
1044
|
+
fontFamily: 'inherit',
|
|
1045
|
+
}} onFocus={(e) => { e.currentTarget.parentElement.style.borderColor = '#58a6ff'; }} onBlur={(e) => { e.currentTarget.parentElement.style.borderColor = '#30363d'; }}/>
|
|
1046
|
+
{value && (<button onClick={() => onChange('')} style={{
|
|
1047
|
+
background: 'none', border: 'none', color: '#6e7681',
|
|
1048
|
+
cursor: 'pointer', padding: '8px 12px', fontSize: 14,
|
|
1049
|
+
lineHeight: 1,
|
|
1050
|
+
}}>โ</button>)}
|
|
1051
|
+
</div>
|
|
1052
|
+
<div style={{ fontSize: 12, color: '#8b949e', whiteSpace: 'nowrap' }}>
|
|
1053
|
+
{totalCount} model{totalCount !== 1 ? 's' : ''}
|
|
1054
|
+
</div>
|
|
1055
|
+
</div>);
|
|
1056
|
+
}
|
|
1057
|
+
// โโโ Column Count Toggle โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
|
1058
|
+
function ColToggle({ value, onChange }) {
|
|
1059
|
+
return (<div style={{
|
|
1060
|
+
display: 'flex', alignItems: 'center', gap: 8,
|
|
1061
|
+
fontSize: 12, color: '#8b949e', marginBottom: 14,
|
|
1062
|
+
}}>
|
|
1063
|
+
<span>Columns:</span>
|
|
1064
|
+
{COL_OPTIONS.map((c) => (<button key={c} onClick={() => onChange(c)} style={{
|
|
1065
|
+
padding: '4px 12px', borderRadius: 6,
|
|
1066
|
+
background: value === c ? '#1f6feb' : '#21262d',
|
|
1067
|
+
color: value === c ? '#fff' : '#8b949e',
|
|
1068
|
+
border: `1px solid ${value === c ? '#1f6feb' : '#30363d'}`,
|
|
1069
|
+
cursor: 'pointer', fontSize: 12, fontWeight: value === c ? 600 : 400,
|
|
1070
|
+
transition: 'all 0.15s',
|
|
1071
|
+
}}>
|
|
1072
|
+
{c}
|
|
1073
|
+
</button>))}
|
|
1074
|
+
</div>);
|
|
1075
|
+
}
|
|
1076
|
+
// โโโ Model Table โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
|
1077
|
+
function ModelCell({ model, provider }) {
|
|
1078
|
+
const s = STATUS_STYLES[model.status];
|
|
1079
|
+
const quotaText = model.rateLimitRemaining !== undefined
|
|
1080
|
+
? model.rateLimitTotal
|
|
1081
|
+
? `${model.rateLimitRemaining} / ${model.rateLimitTotal}`
|
|
1082
|
+
: `${model.rateLimitRemaining} left`
|
|
1083
|
+
: 'โ';
|
|
1084
|
+
return (<td style={{ padding: 10, verticalAlign: 'top' }}>
|
|
1085
|
+
<div style={{
|
|
1086
|
+
background: s.cardBg,
|
|
1087
|
+
border: `1px solid ${s.cardBorder}44`,
|
|
1088
|
+
borderRadius: 10,
|
|
1089
|
+
padding: 14,
|
|
1090
|
+
display: 'flex',
|
|
1091
|
+
flexDirection: 'column',
|
|
1092
|
+
gap: 8,
|
|
1093
|
+
height: '100%',
|
|
1094
|
+
transition: 'all 0.2s ease',
|
|
1095
|
+
position: 'relative',
|
|
1096
|
+
overflow: 'hidden',
|
|
1097
|
+
cursor: 'default',
|
|
1098
|
+
}} onMouseEnter={(e) => {
|
|
1099
|
+
e.currentTarget.style.borderColor = s.cardBorder;
|
|
1100
|
+
e.currentTarget.style.boxShadow = `0 2px 10px ${s.cardBorder}22`;
|
|
1101
|
+
}} onMouseLeave={(e) => {
|
|
1102
|
+
e.currentTarget.style.borderColor = `${s.cardBorder}44`;
|
|
1103
|
+
e.currentTarget.style.boxShadow = 'none';
|
|
1104
|
+
}}>
|
|
1105
|
+
{/* Color accent bar on top */}
|
|
1106
|
+
<div style={{
|
|
1107
|
+
position: 'absolute', top: 0, left: 0, right: 0, height: 3,
|
|
1108
|
+
background: s.cardBorder,
|
|
1109
|
+
opacity: 0.6,
|
|
1110
|
+
}}/>
|
|
1111
|
+
|
|
1112
|
+
{/* Line 1: Model name */}
|
|
1113
|
+
<div style={{
|
|
1114
|
+
fontSize: 13, fontWeight: 600, color: '#e6edf3',
|
|
1115
|
+
fontFamily: "'SFMono-Regular', Consolas, monospace",
|
|
1116
|
+
whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis',
|
|
1117
|
+
paddingTop: 2,
|
|
1118
|
+
}}>
|
|
1119
|
+
{model.name.length > 28 ? model.name.slice(0, 25) + 'โฆ' : model.name}
|
|
1120
|
+
</div>
|
|
1121
|
+
|
|
1122
|
+
{/* Line 2: Provider */}
|
|
1123
|
+
<div style={{ fontSize: 12, color: '#8b949e' }}>
|
|
1124
|
+
{getProviderIcon(provider)} {getProviderLabel(provider)}
|
|
1125
|
+
</div>
|
|
1126
|
+
|
|
1127
|
+
{/* Line 3: Health status โ color box */}
|
|
1128
|
+
<div style={{
|
|
1129
|
+
display: 'inline-flex', alignItems: 'center', gap: 5,
|
|
1130
|
+
background: s.bg, color: s.text,
|
|
1131
|
+
padding: '3px 8px', borderRadius: 6,
|
|
1132
|
+
fontSize: 11, fontWeight: 600,
|
|
1133
|
+
alignSelf: 'flex-start',
|
|
1134
|
+
}}>
|
|
1135
|
+
<span style={{ width: 7, height: 7, borderRadius: '50%', background: s.dot }}/>
|
|
1136
|
+
{model.status === 'available' ? 'Available' : model.status === 'limited' ? 'Limited' : 'Unavailable'}
|
|
1137
|
+
</div>
|
|
1138
|
+
|
|
1139
|
+
{/* Line 4: Token remaining */}
|
|
1140
|
+
<div style={{ fontSize: 11, color: '#6e7681' }}>
|
|
1141
|
+
<span style={{ color: '#8b949e' }}>Tokens:</span>{' '}
|
|
1142
|
+
<span style={{
|
|
1143
|
+
color: model.rateLimitRemaining !== undefined && model.rateLimitRemaining <= 10
|
|
1144
|
+
? '#d29922' : '#8b949e',
|
|
1145
|
+
fontFamily: "'SFMono-Regular', Consolas, monospace",
|
|
1146
|
+
fontWeight: 500,
|
|
1147
|
+
}}>
|
|
1148
|
+
{quotaText}
|
|
1149
|
+
</span>
|
|
1150
|
+
</div>
|
|
1151
|
+
|
|
1152
|
+
{/* Extra: reason if limited/unavailable */}
|
|
1153
|
+
{model.status !== 'available' && model.statusReason && (<div style={{ fontSize: 10, color: '#6e7681', lineHeight: 1.3, marginTop: 2 }}>
|
|
1154
|
+
{model.statusReason.length > 45 ? model.statusReason.slice(0, 42) + 'โฆ' : model.statusReason}
|
|
1155
|
+
</div>)}
|
|
1156
|
+
|
|
1157
|
+
{/* Quota reset time when parked */}
|
|
1158
|
+
{model.parked && model.resetsInMs !== undefined && model.resetsInMs > 0 && (<div style={{
|
|
1159
|
+
fontSize: 10, color: '#d29922', lineHeight: 1.3, marginTop: 4,
|
|
1160
|
+
padding: '3px 6px', borderRadius: 4,
|
|
1161
|
+
background: '#2d2200', border: '1px solid #d2992244',
|
|
1162
|
+
}}>
|
|
1163
|
+
โณ Resets in {fmtDuration(model.resetsInMs)}
|
|
1164
|
+
</div>)}
|
|
1165
|
+
</div>
|
|
1166
|
+
</td>);
|
|
1167
|
+
}
|
|
1168
|
+
// โโโ Models Table Section โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
|
1169
|
+
function ModelsGrid({ providers, colsPerRow, searchQuery }) {
|
|
1170
|
+
// Flatten all models with their provider info
|
|
1171
|
+
const allModels = [];
|
|
1172
|
+
for (const p of providers) {
|
|
1173
|
+
for (const m of p.models) {
|
|
1174
|
+
allModels.push({ model: m, provider: p.provider });
|
|
1175
|
+
}
|
|
1176
|
+
}
|
|
1177
|
+
if (allModels.length === 0)
|
|
1178
|
+
return null;
|
|
1179
|
+
// Filter by search query
|
|
1180
|
+
let filtered = allModels;
|
|
1181
|
+
if (searchQuery.trim()) {
|
|
1182
|
+
const q = searchQuery.toLowerCase().trim();
|
|
1183
|
+
filtered = allModels.filter(({ model, provider }) => model.name.toLowerCase().includes(q) ||
|
|
1184
|
+
getProviderLabel(provider).toLowerCase().includes(q) ||
|
|
1185
|
+
provider.toLowerCase().includes(q));
|
|
1186
|
+
}
|
|
1187
|
+
// Sort: available first, then limited, then unavailable
|
|
1188
|
+
const statusOrder = { available: 0, limited: 1, unavailable: 2 };
|
|
1189
|
+
filtered.sort((a, b) => statusOrder[a.model.status] - statusOrder[b.model.status]);
|
|
1190
|
+
// Build table rows
|
|
1191
|
+
const rows = [];
|
|
1192
|
+
for (let i = 0; i < filtered.length; i += colsPerRow) {
|
|
1193
|
+
rows.push(filtered.slice(i, i + colsPerRow));
|
|
1194
|
+
}
|
|
1195
|
+
return (<>
|
|
1196
|
+
<h2 className="section-title" style={{ marginTop: 36 }}>๐ Model Health Overview</h2>
|
|
1197
|
+
<p className="section-description">
|
|
1198
|
+
All models across all providers, color-coded by health status.
|
|
1199
|
+
Each cell shows: Model ยท Provider ยท Health ยท Token Remaining.
|
|
1200
|
+
</p>
|
|
1201
|
+
|
|
1202
|
+
<div style={{ overflowX: 'auto' }}>
|
|
1203
|
+
<table style={{
|
|
1204
|
+
width: '100%',
|
|
1205
|
+
borderCollapse: 'separate',
|
|
1206
|
+
borderSpacing: 10,
|
|
1207
|
+
tableLayout: 'fixed',
|
|
1208
|
+
}}>
|
|
1209
|
+
<tbody>
|
|
1210
|
+
{rows.length > 0 ? (rows.map((row, ri) => (<tr key={ri}>
|
|
1211
|
+
{row.map(({ model, provider }) => (<ModelCell key={`${provider}-${model.id}`} model={model} provider={provider}/>))}
|
|
1212
|
+
{row.length < colsPerRow && Array.from({ length: colsPerRow - row.length }).map((_, ei) => (<td key={`empty-${ei}`} style={{ padding: 10 }}/>))}
|
|
1213
|
+
</tr>))) : (<tr>
|
|
1214
|
+
<td colSpan={colsPerRow} style={{ textAlign: 'center', padding: 40, color: '#6e7681', fontSize: 13 }}>
|
|
1215
|
+
No models match your search "{searchQuery}"
|
|
1216
|
+
</td>
|
|
1217
|
+
</tr>)}
|
|
1218
|
+
</tbody>
|
|
1219
|
+
</table>
|
|
1220
|
+
</div>
|
|
1221
|
+
|
|
1222
|
+
{searchQuery.trim() && filtered.length > 0 && (<div style={{ textAlign: 'right', fontSize: 11, color: '#6e7681', marginTop: 4 }}>
|
|
1223
|
+
Showing {filtered.length} of {allModels.length} models
|
|
1224
|
+
</div>)}
|
|
1225
|
+
</>);
|
|
1226
|
+
}
|
|
1227
|
+
// โโโ Speech Provider Section โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
|
1228
|
+
function SpeechProviderSection() {
|
|
1229
|
+
return (<div style={{
|
|
1230
|
+
background: '#161b22', borderRadius: 12,
|
|
1231
|
+
border: '1px dashed #30363d',
|
|
1232
|
+
padding: '20px 24px',
|
|
1233
|
+
marginTop: 24,
|
|
1234
|
+
textAlign: 'center',
|
|
1235
|
+
}}>
|
|
1236
|
+
<div style={{ fontSize: 24, marginBottom: 8 }}>๐๏ธ</div>
|
|
1237
|
+
<div style={{ fontSize: 14, fontWeight: 600, color: '#e6edf3', marginBottom: 4 }}>
|
|
1238
|
+
Speech / TTS Provider
|
|
1239
|
+
</div>
|
|
1240
|
+
<div style={{ fontSize: 12, color: '#6e7681' }}>
|
|
1241
|
+
Speech/TTS provider support coming soon.
|
|
1242
|
+
{' '}<a href="https://github.com/imdheerajKube/agent-nuvira/issues/new" target="_blank" rel="noopener noreferrer" style={{ color: '#58a6ff', textDecoration: 'none', cursor: 'pointer' }} onMouseEnter={(e) => { e.currentTarget.style.textDecoration = 'underline'; }} onMouseLeave={(e) => { e.currentTarget.style.textDecoration = 'none'; }}>
|
|
1243
|
+
Request a provider
|
|
1244
|
+
</a>
|
|
1245
|
+
</div>
|
|
1246
|
+
</div>);
|
|
1247
|
+
}
|
|
1248
|
+
// โโโ Legend โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
|
1249
|
+
function Legend() {
|
|
1250
|
+
return (<div style={{
|
|
1251
|
+
background: '#0d1117', borderRadius: 10, padding: 14, marginBottom: 20,
|
|
1252
|
+
border: '1px solid #21262d', fontSize: 13, color: '#8b949e',
|
|
1253
|
+
display: 'flex', flexWrap: 'wrap', gap: 20,
|
|
1254
|
+
}}>
|
|
1255
|
+
<div>
|
|
1256
|
+
<div style={{ fontWeight: 600, color: '#e6edf3', marginBottom: 6 }}>Color Coding</div>
|
|
1257
|
+
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
|
|
1258
|
+
<span><span style={{ color: '#3fb950' }}>โ</span> <strong style={{ color: '#e6edf3' }}>Green</strong> โ Working with rate limit available</span>
|
|
1259
|
+
<span><span style={{ color: '#d29922' }}>โ</span> <strong style={{ color: '#e6edf3' }}>Amber</strong> โ Slow / low rate limit / needs action</span>
|
|
1260
|
+
<span><span style={{ color: '#f85149' }}>โ</span> <strong style={{ color: '#e6edf3' }}>Red</strong> โ API key missing / payment needed / unreachable</span>
|
|
1261
|
+
</div>
|
|
1262
|
+
</div>
|
|
1263
|
+
<div>
|
|
1264
|
+
<div style={{ fontWeight: 600, color: '#e6edf3', marginBottom: 6 }}>Provider Sections</div>
|
|
1265
|
+
<div style={{ display: 'flex', flexDirection: 'column', gap: 3, fontSize: 12 }}>
|
|
1266
|
+
<span>โ
<strong>Cloud</strong> โ Online providers with active API keys</span>
|
|
1267
|
+
<span>๐ <strong>Local</strong> โ Locally running inference servers</span>
|
|
1268
|
+
<span>โ <strong>Unavailable</strong> โ Missing keys or unreachable endpoints</span>
|
|
1269
|
+
<span>๐๏ธ <strong>Speech</strong> โ Text-to-speech / speech-to-text providers (coming)</span>
|
|
1270
|
+
</div>
|
|
1271
|
+
</div>
|
|
1272
|
+
</div>);
|
|
1273
|
+
}
|
|
1274
|
+
// โโโ Main Component โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
|
1275
|
+
function ModelsPanel() {
|
|
1276
|
+
const [modelsData, setModelsData] = (0, react_1.useState)(null);
|
|
1277
|
+
const [registryData, setRegistryData] = (0, react_1.useState)(null);
|
|
1278
|
+
const [loading, setLoading] = (0, react_1.useState)(true);
|
|
1279
|
+
const [error, setError] = (0, react_1.useState)(null);
|
|
1280
|
+
const [colsPerRow, setColsPerRow] = (0, react_1.useState)(4);
|
|
1281
|
+
const [searchQuery, setSearchQuery] = (0, react_1.useState)('');
|
|
1282
|
+
const mountedRef = (0, react_1.useRef)(true);
|
|
1283
|
+
const retryTimerRef = (0, react_1.useRef)(null);
|
|
1284
|
+
// True when the LAST load hit a transient (network-level) failure โ used to
|
|
1285
|
+
// decide whether a fast self-healing re-poll is warranted. Definitive errors
|
|
1286
|
+
// (stale server, persistent HTTP 5xx) fall back to the 60s cadence instead.
|
|
1287
|
+
const transientFailureRef = (0, react_1.useRef)(false);
|
|
1288
|
+
function scheduleRepoll() {
|
|
1289
|
+
if (retryTimerRef.current)
|
|
1290
|
+
clearTimeout(retryTimerRef.current);
|
|
1291
|
+
retryTimerRef.current = setTimeout(() => {
|
|
1292
|
+
if (mountedRef.current)
|
|
1293
|
+
fetchModels();
|
|
1294
|
+
}, FAILED_REPOLL_MS);
|
|
1295
|
+
}
|
|
1296
|
+
async function fetchModels() {
|
|
1297
|
+
setLoading(true);
|
|
1298
|
+
setError(null);
|
|
1299
|
+
transientFailureRef.current = false;
|
|
1300
|
+
try {
|
|
1301
|
+
// Health is REQUIRED (throws after retries); registry/telemetry are
|
|
1302
|
+
// OPTIONAL โ an older server (or a plain 404) must hide those sections,
|
|
1303
|
+
// never break the health grid.
|
|
1304
|
+
const [data, registry] = await Promise.all([
|
|
1305
|
+
fetchHealthWithRetry(() => { transientFailureRef.current = true; }),
|
|
1306
|
+
fetchRegistryBestEffort(() => { transientFailureRef.current = true; }),
|
|
1307
|
+
]);
|
|
1308
|
+
if (!mountedRef.current)
|
|
1309
|
+
return;
|
|
1310
|
+
setModelsData(data);
|
|
1311
|
+
setRegistryData(registry);
|
|
1312
|
+
// Recovered from a transient blip (or the optional registry flapped) โ
|
|
1313
|
+
// re-check shortly so the panel settles into the fresh state.
|
|
1314
|
+
if (transientFailureRef.current)
|
|
1315
|
+
scheduleRepoll();
|
|
1316
|
+
}
|
|
1317
|
+
catch (err) {
|
|
1318
|
+
if (!mountedRef.current)
|
|
1319
|
+
return;
|
|
1320
|
+
setError(err instanceof Error && err.message ? err.message : 'Failed to fetch model status');
|
|
1321
|
+
// Self-heal only on TRANSIENT failures: re-poll quickly instead of
|
|
1322
|
+
// waiting the full 60s cadence. Definitive errors (stale server, hard
|
|
1323
|
+
// HTTP failures) are NOT hammered โ they wait for the next poll.
|
|
1324
|
+
if (transientFailureRef.current)
|
|
1325
|
+
scheduleRepoll();
|
|
1326
|
+
}
|
|
1327
|
+
finally {
|
|
1328
|
+
if (mountedRef.current)
|
|
1329
|
+
setLoading(false);
|
|
1330
|
+
}
|
|
1331
|
+
}
|
|
1332
|
+
(0, react_1.useEffect)(() => {
|
|
1333
|
+
mountedRef.current = true;
|
|
1334
|
+
fetchModels();
|
|
1335
|
+
const interval = setInterval(fetchModels, POLL_INTERVAL_MS);
|
|
1336
|
+
return () => {
|
|
1337
|
+
mountedRef.current = false;
|
|
1338
|
+
clearInterval(interval);
|
|
1339
|
+
if (retryTimerRef.current)
|
|
1340
|
+
clearTimeout(retryTimerRef.current);
|
|
1341
|
+
};
|
|
1342
|
+
}, []);
|
|
1343
|
+
// Sort providers into sections
|
|
1344
|
+
function sortProviders(data) {
|
|
1345
|
+
const available = [];
|
|
1346
|
+
const local = [];
|
|
1347
|
+
const speech = [];
|
|
1348
|
+
const unavailable = [];
|
|
1349
|
+
const availabilityOrder = { available: 0, limited: 1, unavailable: 2 };
|
|
1350
|
+
for (const p of data.providers) {
|
|
1351
|
+
if (SPEECH_PROVIDERS.has(p.provider)) {
|
|
1352
|
+
speech.push(p);
|
|
1353
|
+
}
|
|
1354
|
+
else if (LOCAL_PROVIDERS.has(p.provider)) {
|
|
1355
|
+
local.push(p);
|
|
1356
|
+
}
|
|
1357
|
+
else if (p.overallStatus === 'available') {
|
|
1358
|
+
available.push(p);
|
|
1359
|
+
}
|
|
1360
|
+
else {
|
|
1361
|
+
unavailable.push(p);
|
|
1362
|
+
}
|
|
1363
|
+
}
|
|
1364
|
+
available.sort((a, b) => availabilityOrder[a.overallStatus] - availabilityOrder[b.overallStatus]);
|
|
1365
|
+
local.sort((a, b) => availabilityOrder[a.overallStatus] - availabilityOrder[b.overallStatus]);
|
|
1366
|
+
speech.sort((a, b) => availabilityOrder[a.overallStatus] - availabilityOrder[b.overallStatus]);
|
|
1367
|
+
unavailable.sort((a, b) => availabilityOrder[a.overallStatus] - availabilityOrder[b.overallStatus]);
|
|
1368
|
+
return { available, local, speech, unavailable };
|
|
1369
|
+
}
|
|
1370
|
+
return (<>
|
|
1371
|
+
<h2 className="section-title">๐ง Model Provider Status</h2>
|
|
1372
|
+
<p className="section-description">
|
|
1373
|
+
Real-time health check of all AI providers and their available models.
|
|
1374
|
+
Providers are grouped into sections: Available cloud โ Local โ Unavailable.
|
|
1375
|
+
</p>
|
|
1376
|
+
|
|
1377
|
+
<ActionBar onRefresh={fetchModels} loading={loading}/>
|
|
1378
|
+
<Legend />
|
|
1379
|
+
|
|
1380
|
+
{loading && !modelsData && (<div className="loading-state">
|
|
1381
|
+
<div className="loading-spinner"/>
|
|
1382
|
+
<p>Testing all 17 provider connections...</p>
|
|
1383
|
+
</div>)}
|
|
1384
|
+
|
|
1385
|
+
{error && (<div className="empty-state" style={{ color: '#f85149', border: '1px solid #f8514944', borderRadius: 10, padding: 16, marginBottom: 16 }}>
|
|
1386
|
+
โ ๏ธ {error}
|
|
1387
|
+
</div>)}
|
|
1388
|
+
|
|
1389
|
+
{modelsData && (<>
|
|
1390
|
+
{/* Summary stats cards */}
|
|
1391
|
+
<div className="stats-grid" style={{ marginBottom: 16 }}>
|
|
1392
|
+
<div className="stat-card">
|
|
1393
|
+
<span className="stat-icon">๐ง </span>
|
|
1394
|
+
<div className="stat-body">
|
|
1395
|
+
<div className="stat-value">{modelsData.totalModels}</div>
|
|
1396
|
+
<div className="stat-label">Total Models</div>
|
|
1397
|
+
</div>
|
|
1398
|
+
</div>
|
|
1399
|
+
<div className="stat-card">
|
|
1400
|
+
<span className="stat-icon">โ
</span>
|
|
1401
|
+
<div className="stat-body">
|
|
1402
|
+
<div className="stat-value" style={{ color: '#3fb950' }}>{modelsData.available}</div>
|
|
1403
|
+
<div className="stat-label">Available</div>
|
|
1404
|
+
</div>
|
|
1405
|
+
</div>
|
|
1406
|
+
<div className="stat-card">
|
|
1407
|
+
<span className="stat-icon">๐ก</span>
|
|
1408
|
+
<div className="stat-body">
|
|
1409
|
+
<div className="stat-value" style={{ color: '#d29922' }}>{modelsData.limited}</div>
|
|
1410
|
+
<div className="stat-label">Limited</div>
|
|
1411
|
+
</div>
|
|
1412
|
+
</div>
|
|
1413
|
+
<div className="stat-card">
|
|
1414
|
+
<span className="stat-icon">๐ด</span>
|
|
1415
|
+
<div className="stat-body">
|
|
1416
|
+
<div className="stat-value" style={{ color: '#f85149' }}>{modelsData.unavailable}</div>
|
|
1417
|
+
<div className="stat-label">Unavailable</div>
|
|
1418
|
+
</div>
|
|
1419
|
+
</div>
|
|
1420
|
+
<div className="stat-card">
|
|
1421
|
+
<span className="stat-icon">๐</span>
|
|
1422
|
+
<div className="stat-body">
|
|
1423
|
+
<div className="stat-value">{modelsData.providers.length}</div>
|
|
1424
|
+
<div className="stat-label">Providers</div>
|
|
1425
|
+
</div>
|
|
1426
|
+
</div>
|
|
1427
|
+
</div>
|
|
1428
|
+
|
|
1429
|
+
<ProgressBar data={modelsData}/>
|
|
1430
|
+
|
|
1431
|
+
{/* โโ Sectioned Provider Cards โโ */}
|
|
1432
|
+
{(() => {
|
|
1433
|
+
const { available, local, speech, unavailable } = sortProviders(modelsData);
|
|
1434
|
+
return (<>
|
|
1435
|
+
<SectionHeader icon="โ
" title="Available Cloud Providers" count={available.length}/>
|
|
1436
|
+
{available.map((provider) => (<ProviderCard key={provider.provider} provider={provider}/>))}
|
|
1437
|
+
|
|
1438
|
+
<SectionHeader icon="๐ " title="Local Providers" count={local.length}/>
|
|
1439
|
+
{local.map((provider) => (<ProviderCard key={provider.provider} provider={provider}/>))}
|
|
1440
|
+
|
|
1441
|
+
<SectionHeader icon="โ" title="Unavailable Providers" count={unavailable.length}/>
|
|
1442
|
+
{unavailable.map((provider) => (<ProviderCard key={provider.provider} provider={provider}/>))}
|
|
1443
|
+
|
|
1444
|
+
{/* Speech providers (always last) */}
|
|
1445
|
+
<SectionHeader icon="๐๏ธ" title="Speech / TTS Providers" count={speech.length}/>
|
|
1446
|
+
{speech.map((provider) => (<ProviderCard key={provider.provider} provider={provider}/>))}
|
|
1447
|
+
</>);
|
|
1448
|
+
})()}
|
|
1449
|
+
|
|
1450
|
+
{/* โโ Search + Model Grid โโ */}
|
|
1451
|
+
<SearchBar value={searchQuery} onChange={setSearchQuery} totalCount={modelsData.providers.reduce((s, p) => s + p.models.length, 0)}/>
|
|
1452
|
+
<ColToggle value={colsPerRow} onChange={setColsPerRow}/>
|
|
1453
|
+
|
|
1454
|
+
<ModelsGrid providers={modelsData.providers} colsPerRow={colsPerRow} searchQuery={searchQuery}/>
|
|
1455
|
+
|
|
1456
|
+
{/* Model Availability Registry โ the unified store routing reads */}
|
|
1457
|
+
{registryData && <ModelRegistrySection data={registryData}/>}
|
|
1458
|
+
|
|
1459
|
+
{/* Learned-from-real-usage telemetry โ per-action verified/killed visibility */}
|
|
1460
|
+
{registryData && <ActionTelemetrySection registry={registryData}/>}
|
|
1461
|
+
|
|
1462
|
+
{/* Older/mismatched server: registry data missing โ explain why the
|
|
1463
|
+
sections above are absent instead of showing a blank gap. */}
|
|
1464
|
+
{modelsData && !registryData && (<div style={{
|
|
1465
|
+
background: '#161b22', borderRadius: 12, border: '1px dashed #30363d',
|
|
1466
|
+
padding: '14px 20px', marginTop: 24, fontSize: 12, color: '#8b949e',
|
|
1467
|
+
lineHeight: 1.6,
|
|
1468
|
+
}}>
|
|
1469
|
+
๐ฆ Registry & telemetry sections are hidden โ this dashboard
|
|
1470
|
+
server did not return model-registry data (it may be an{' '}
|
|
1471
|
+
<strong style={{ color: '#e6edf3' }}>older version</strong>).
|
|
1472
|
+
Restart the dashboard from the latest install to see them.
|
|
1473
|
+
</div>)}
|
|
1474
|
+
|
|
1475
|
+
{/* Speech provider coming-soon placeholder */}
|
|
1476
|
+
<SpeechProviderSection />
|
|
1477
|
+
|
|
1478
|
+
<div style={{ textAlign: 'center', fontSize: 12, color: '#484f58', marginTop: 16 }}>
|
|
1479
|
+
Last checked: {new Date(modelsData.lastChecked).toLocaleTimeString()}
|
|
1480
|
+
{' ยท '}Auto-refreshes every 60s
|
|
1481
|
+
</div>
|
|
1482
|
+
</>)}
|
|
1483
|
+
</>);
|
|
1484
|
+
}
|