free-coding-models 0.5.0 → 0.5.4
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/README.md +40 -6
- package/bin/free-coding-models.js +10 -0
- package/changelog/v0.5.1.md +24 -0
- package/changelog/v0.5.4.md +24 -0
- package/package.json +11 -3
- package/sources.js +2 -0
- package/src/core/router-daemon.js +166 -1
- package/src/core/utils.js +2 -0
- package/src/tui/cli-help.js +2 -0
- package/src/tui/key-handler.js +1 -1
- package/src/tui/render-table.js +1 -1
- package/web/README.md +8 -5
- package/web/assets/providers/aistudio/aistudio-text.svg +1 -0
- package/web/assets/providers/aistudio/aistudio.svg +1 -0
- package/web/assets/providers/cerebras/cerebras-brand-color.svg +1 -0
- package/web/assets/providers/cerebras/cerebras-brand.svg +1 -0
- package/web/assets/providers/cerebras/cerebras-color.svg +1 -0
- package/web/assets/providers/cerebras/cerebras-text.svg +1 -0
- package/web/assets/providers/cerebras/cerebras.svg +1 -0
- package/web/assets/providers/cloudflare/cloudflare-color.svg +1 -0
- package/web/assets/providers/cloudflare/cloudflare-text.svg +1 -0
- package/web/assets/providers/cloudflare/cloudflare.svg +1 -0
- package/web/assets/providers/gemini/gemini-color.svg +1 -0
- package/web/assets/providers/gemini/gemini-text.svg +1 -0
- package/web/assets/providers/gemini/gemini.svg +1 -0
- package/web/assets/providers/github/github-text.svg +1 -0
- package/web/assets/providers/github/github.svg +1 -0
- package/web/assets/providers/groq/groq-text.svg +1 -0
- package/web/assets/providers/groq/groq.svg +1 -0
- package/web/assets/providers/mistral/mistral-color.svg +1 -0
- package/web/assets/providers/mistral/mistral-text.svg +1 -0
- package/web/assets/providers/mistral/mistral.svg +1 -0
- package/web/assets/providers/nvidia/nvidia-color.svg +1 -0
- package/web/assets/providers/nvidia/nvidia-text.svg +1 -0
- package/web/assets/providers/nvidia/nvidia.svg +1 -0
- package/web/assets/providers/opencode/opencode-text.svg +1 -0
- package/web/assets/providers/opencode/opencode.svg +1 -0
- package/web/assets/providers/openrouter/openrouter (1).svg +1 -0
- package/web/assets/providers/openrouter/openrouter-text.svg +1 -0
- package/web/assets/providers/openrouter/openrouter.svg +1 -0
- package/web/assets/providers/ovhcloud/ovhcloud.svg +12 -0
- package/web/assets/providers/qwen/qwen-color.svg +1 -0
- package/web/assets/providers/qwen/qwen-text.svg +1 -0
- package/web/assets/providers/qwen/qwen.svg +1 -0
- package/web/assets/providers/sambanova/sambanova-color.svg +1 -0
- package/web/assets/providers/sambanova/sambanova-text.svg +1 -0
- package/web/assets/providers/sambanova/sambanova.svg +1 -0
- package/web/assets/providers/scalewaylogo/ScalewayLogo.svg +13 -0
- package/web/assets/providers/zai/zai-text.svg +1 -0
- package/web/assets/providers/zai/zai.svg +1 -0
- package/web/dist/assets/index-BoWmUveV.js +39 -0
- package/web/dist/assets/index-BrpHevg4.css +1 -0
- package/web/dist/favicon.ico +0 -0
- package/web/dist/favicons/apple-touch-icon.png +0 -0
- package/web/dist/favicons/browserconfig.xml +12 -0
- package/web/dist/favicons/favicon-16x16.png +0 -0
- package/web/dist/favicons/favicon-192x192.png +0 -0
- package/web/dist/favicons/favicon-32x32.png +0 -0
- package/web/dist/favicons/favicon-48x48.png +0 -0
- package/web/dist/favicons/favicon-512x512.png +0 -0
- package/web/dist/favicons/favicon-96x96.png +0 -0
- package/web/dist/favicons/favicon.ico +0 -0
- package/web/dist/favicons/mstile-150x150.png +0 -0
- package/web/dist/favicons/mstile-310x150.png +0 -0
- package/web/dist/favicons/mstile-310x310.png +0 -0
- package/web/dist/favicons/mstile-512x512.png +0 -0
- package/web/dist/favicons/mstile-70x70.png +0 -0
- package/web/dist/favicons/site.webmanifest +25 -0
- package/web/dist/index.html +42 -6
- package/web/index.html +40 -4
- package/web/package.json +11 -0
- package/web/public/favicon.ico +0 -0
- package/web/public/favicons/apple-touch-icon.png +0 -0
- package/web/public/favicons/browserconfig.xml +12 -0
- package/web/public/favicons/favicon-16x16.png +0 -0
- package/web/public/favicons/favicon-192x192.png +0 -0
- package/web/public/favicons/favicon-32x32.png +0 -0
- package/web/public/favicons/favicon-48x48.png +0 -0
- package/web/public/favicons/favicon-512x512.png +0 -0
- package/web/public/favicons/favicon-96x96.png +0 -0
- package/web/public/favicons/favicon.ico +0 -0
- package/web/public/favicons/mstile-150x150.png +0 -0
- package/web/public/favicons/mstile-310x150.png +0 -0
- package/web/public/favicons/mstile-310x310.png +0 -0
- package/web/public/favicons/mstile-512x512.png +0 -0
- package/web/public/favicons/mstile-70x70.png +0 -0
- package/web/public/favicons/site.webmanifest +25 -0
- package/web/server.js +676 -211
- package/web/src/App.jsx +233 -71
- package/web/src/components/analytics/AnalyticsView.jsx +40 -6
- package/web/src/components/analytics/AnalyticsView.module.css +4 -0
- package/web/src/components/atoms/AILatencyCell.jsx +38 -0
- package/web/src/components/atoms/AILatencyCell.module.css +51 -0
- package/web/src/components/atoms/HealthCell.jsx +138 -0
- package/web/src/components/atoms/HealthCell.module.css +56 -0
- package/web/src/components/atoms/LastPingCell.jsx +35 -0
- package/web/src/components/atoms/LastPingCell.module.css +45 -0
- package/web/src/components/atoms/MoodCell.jsx +25 -0
- package/web/src/components/atoms/MoodCell.module.css +6 -0
- package/web/src/components/atoms/NoKeyIcon.jsx +65 -0
- package/web/src/components/atoms/NoKeyIcon.module.css +30 -0
- package/web/src/components/atoms/ProviderLogo.jsx +188 -0
- package/web/src/components/atoms/ProviderLogo.module.css +103 -0
- package/web/src/components/atoms/RankCell.jsx +9 -0
- package/web/src/components/atoms/RankCell.module.css +9 -0
- package/web/src/components/atoms/Sparkline.jsx +21 -2
- package/web/src/components/atoms/StabilityCell.module.css +5 -0
- package/web/src/components/atoms/StatusDot.module.css +7 -0
- package/web/src/components/atoms/TPSCell.jsx +36 -0
- package/web/src/components/atoms/TPSCell.module.css +44 -0
- package/web/src/components/atoms/TierBadge.module.css +11 -0
- package/web/src/components/atoms/Toast.module.css +6 -0
- package/web/src/components/atoms/VerdictBadge.jsx +30 -7
- package/web/src/components/atoms/VerdictBadge.module.css +36 -15
- package/web/src/components/dashboard/DetailPanel.jsx +63 -2
- package/web/src/components/dashboard/DetailPanel.module.css +130 -0
- package/web/src/components/dashboard/ExportModal.jsx +9 -4
- package/web/src/components/dashboard/FilterBar.jsx +228 -33
- package/web/src/components/dashboard/FilterBar.module.css +187 -22
- package/web/src/components/dashboard/ModelTable.jsx +500 -54
- package/web/src/components/dashboard/ModelTable.module.css +318 -31
- package/web/src/components/layout/Footer.jsx +1 -1
- package/web/src/components/layout/Header.jsx +171 -9
- package/web/src/components/layout/Header.module.css +223 -25
- package/web/src/components/palette/CommandPalette.jsx +146 -0
- package/web/src/components/palette/CommandPalette.module.css +121 -0
- package/web/src/components/settings/SettingsView.jsx +24 -6
- package/web/src/components/settings/SettingsView.module.css +0 -1
- package/web/src/global.css +118 -84
- package/web/src/hooks/useColumnSizing.js +211 -0
- package/web/src/hooks/useFavorites.js +150 -0
- package/web/src/hooks/useFilter.js +262 -37
- package/web/src/hooks/useSSE.js +33 -9
- package/web/src/hooks/useSocket.js +200 -0
- package/web/src/hooks/useTheme.js +72 -16
- package/web/src/hooks/useUrlState.js +93 -0
- package/web/vite.config.js +41 -0
- package/web/dist/assets/index-CGN-0_A0.css +0 -1
- package/web/dist/assets/index-CvMUM9Jr.js +0 -11
- package/web/src/components/dashboard/StatsBar.jsx +0 -40
- package/web/src/components/dashboard/StatsBar.module.css +0 -28
- package/web/src/components/layout/Sidebar.jsx +0 -42
- package/web/src/components/layout/Sidebar.module.css +0 -76
- package/web/src/components/map/MapView.jsx +0 -17
- package/web/src/components/map/MapView.module.css +0 -25
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file web/src/components/atoms/HealthCell.jsx
|
|
3
|
+
* @description Detailed health/status column — matches CLI Health column.
|
|
4
|
+
* 📖 Shows the current provider status next to a Tabler icon that matches the
|
|
5
|
+
* 📖 state. The icon is color-coded so a quick glance is enough to know if
|
|
6
|
+
* 📖 the row is healthy, slow, down, blocked, etc.
|
|
7
|
+
* 📖
|
|
8
|
+
* 📖 Icon → state mapping:
|
|
9
|
+
* 📖 ✅ up → IconChecks (green)
|
|
10
|
+
* 📖 ⏳ timeout → IconHourglassOff (yellow)
|
|
11
|
+
* 📖 ⏳ pending → IconHourglass (yellow)
|
|
12
|
+
* 📖 🔐 AUTH FAIL → IconLock (red, bold)
|
|
13
|
+
* 📖 🔑 NO KEY → NoKeyIcon (yellow key + red slash)
|
|
14
|
+
* 📖 🔥 429 → IconFlame (red)
|
|
15
|
+
* 📖 🚫 404 → IconError404 (red)
|
|
16
|
+
* 📖 💥 500 → IconAlertOctagon (red)
|
|
17
|
+
* 📖 🔌 502 → IconPlug (red)
|
|
18
|
+
* 📖 🔒 503 → IconCloudOff (red)
|
|
19
|
+
* 📖 ⏰ 504 → IconClock (red)
|
|
20
|
+
* 📖 ❌ unknown → IconBan (red)
|
|
21
|
+
*/
|
|
22
|
+
import {
|
|
23
|
+
IconChecks,
|
|
24
|
+
IconHourglass,
|
|
25
|
+
IconHourglassOff,
|
|
26
|
+
IconLock,
|
|
27
|
+
IconFlame,
|
|
28
|
+
IconError404,
|
|
29
|
+
IconAlertOctagon,
|
|
30
|
+
IconPlug,
|
|
31
|
+
IconCloudOff,
|
|
32
|
+
IconClock,
|
|
33
|
+
IconBan,
|
|
34
|
+
} from '@tabler/icons-react'
|
|
35
|
+
import NoKeyIcon from './NoKeyIcon.jsx'
|
|
36
|
+
import styles from './HealthCell.module.css'
|
|
37
|
+
|
|
38
|
+
const ICON_SIZE = 12
|
|
39
|
+
const ICON_STROKE = 1.8
|
|
40
|
+
|
|
41
|
+
// 📖 Themed icon helper: same color palette as the rest of the Health cell text,
|
|
42
|
+
// 📖 so the icon + label always match. Reading the CSS var at render time keeps
|
|
43
|
+
// 📖 the dark/light palettes aligned with the rest of the dashboard.
|
|
44
|
+
const HEALTH_ICON_COLOR = {
|
|
45
|
+
success: 'var(--color-success, #00ff88)',
|
|
46
|
+
warning: 'var(--color-warning, #ffaa00)',
|
|
47
|
+
danger: 'var(--color-danger, #ff4444)',
|
|
48
|
+
dim: 'var(--color-text-dim, #444)',
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const ERROR_LABELS = {
|
|
52
|
+
'404': '404 NOT FOUND',
|
|
53
|
+
'410': '410 GONE',
|
|
54
|
+
'429': '429 TRY LATER',
|
|
55
|
+
'500': '500 ERROR',
|
|
56
|
+
'502': '502 ERROR',
|
|
57
|
+
'503': '503 ERROR',
|
|
58
|
+
'504': '504 TIMEOUT',
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// 📖 Per-HTTP-code icon + color picker. Anything not in this map falls back to
|
|
62
|
+
// 📖 the generic down-state rendering below.
|
|
63
|
+
const ERROR_ICON = {
|
|
64
|
+
'429': { Icon: IconFlame, color: HEALTH_ICON_COLOR.danger },
|
|
65
|
+
'404': { Icon: IconError404, color: HEALTH_ICON_COLOR.danger },
|
|
66
|
+
'500': { Icon: IconAlertOctagon, color: HEALTH_ICON_COLOR.danger },
|
|
67
|
+
'502': { Icon: IconPlug, color: HEALTH_ICON_COLOR.danger },
|
|
68
|
+
'503': { Icon: IconCloudOff, color: HEALTH_ICON_COLOR.danger },
|
|
69
|
+
'504': { Icon: IconClock, color: HEALTH_ICON_COLOR.danger },
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function statusLabel(status, httpCode) {
|
|
73
|
+
if (status === 'noauth') return 'NO KEY'
|
|
74
|
+
if (status === 'auth_error') return 'AUTH FAIL'
|
|
75
|
+
if (status === 'pending') return 'wait'
|
|
76
|
+
if (status === 'timeout') return 'TIMEOUT'
|
|
77
|
+
if (status === 'down') {
|
|
78
|
+
return ERROR_LABELS[httpCode] || (httpCode || 'ERROR')
|
|
79
|
+
}
|
|
80
|
+
if (status === 'up') return 'UP'
|
|
81
|
+
return '?'
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function statusClass(status) {
|
|
85
|
+
if (status === 'noauth') return styles.noKey
|
|
86
|
+
if (status === 'up') return styles.up
|
|
87
|
+
if (status === 'down') return styles.error
|
|
88
|
+
if (status === 'timeout') return styles.warning
|
|
89
|
+
if (status === 'auth_error') return styles.errorBold
|
|
90
|
+
if (status === 'pending') return styles.warning
|
|
91
|
+
return styles.dim
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export default function HealthCell({ status, httpCode }) {
|
|
95
|
+
const text = statusLabel(status, httpCode)
|
|
96
|
+
const cls = statusClass(status)
|
|
97
|
+
const isNoKey = status === 'noauth'
|
|
98
|
+
|
|
99
|
+
// 📖 Pick the right icon + color combo for this state.
|
|
100
|
+
let Icon = null
|
|
101
|
+
let iconColor = null
|
|
102
|
+
if (status === 'up') {
|
|
103
|
+
Icon = IconChecks
|
|
104
|
+
iconColor = HEALTH_ICON_COLOR.success
|
|
105
|
+
} else if (status === 'timeout') {
|
|
106
|
+
Icon = IconHourglassOff
|
|
107
|
+
iconColor = HEALTH_ICON_COLOR.warning
|
|
108
|
+
} else if (status === 'pending') {
|
|
109
|
+
Icon = IconHourglass
|
|
110
|
+
iconColor = HEALTH_ICON_COLOR.warning
|
|
111
|
+
} else if (status === 'auth_error') {
|
|
112
|
+
Icon = IconLock
|
|
113
|
+
iconColor = HEALTH_ICON_COLOR.danger
|
|
114
|
+
} else if (status === 'down' && ERROR_ICON[httpCode]) {
|
|
115
|
+
Icon = ERROR_ICON[httpCode].Icon
|
|
116
|
+
iconColor = ERROR_ICON[httpCode].color
|
|
117
|
+
} else if (status === 'down') {
|
|
118
|
+
// 📖 Generic "down" fallback (no recognized HTTP code).
|
|
119
|
+
Icon = IconBan
|
|
120
|
+
iconColor = HEALTH_ICON_COLOR.danger
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
return (
|
|
124
|
+
<span className={`${styles.cell} ${cls}`}>
|
|
125
|
+
{isNoKey ? (
|
|
126
|
+
<NoKeyIcon size={15} title="No API key configured for this provider" />
|
|
127
|
+
) : Icon ? (
|
|
128
|
+
<Icon
|
|
129
|
+
size={ICON_SIZE}
|
|
130
|
+
stroke={ICON_STROKE}
|
|
131
|
+
style={{ color: iconColor, flexShrink: 0 }}
|
|
132
|
+
aria-hidden="true"
|
|
133
|
+
/>
|
|
134
|
+
) : null}
|
|
135
|
+
<span className={isNoKey ? styles.noKeyText : undefined}>{text}</span>
|
|
136
|
+
</span>
|
|
137
|
+
)
|
|
138
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file web/src/components/atoms/HealthCell.module.css
|
|
3
|
+
* @description Styles for the HealthCell atom — matches CLI Health column typography.
|
|
4
|
+
* 📖 "NO KEY" is rendered as a louder, bold, warning-yellow chip with the NoKeyIcon
|
|
5
|
+
* 📖 sitting inline next to the label, so a missing credential jumps out at the user.
|
|
6
|
+
*/
|
|
7
|
+
.cell {
|
|
8
|
+
font-family: var(--font-mono);
|
|
9
|
+
font-size: 11px;
|
|
10
|
+
font-weight: 600;
|
|
11
|
+
display: inline-flex;
|
|
12
|
+
align-items: center;
|
|
13
|
+
gap: 5px;
|
|
14
|
+
letter-spacing: 0.2px;
|
|
15
|
+
white-space: nowrap;
|
|
16
|
+
overflow: hidden;
|
|
17
|
+
text-overflow: ellipsis;
|
|
18
|
+
}
|
|
19
|
+
.up { color: #00ff88; }
|
|
20
|
+
.warning { color: #ffaa00; }
|
|
21
|
+
.error { color: #ff4444; }
|
|
22
|
+
.errorBold { color: #ff4444; font-weight: 800; }
|
|
23
|
+
.dim { color: var(--color-text-dim); }
|
|
24
|
+
|
|
25
|
+
/* 📖 The "NO KEY" treatment is intentionally louder than the other states:
|
|
26
|
+
📖 - yellow chip background instead of plain text
|
|
27
|
+
📖 - 13px font (vs 11px elsewhere in the column)
|
|
28
|
+
📖 - bold weight, slightly more letter-spacing
|
|
29
|
+
📖 - dark text on yellow for AA contrast in dark + light themes */
|
|
30
|
+
.noKey {
|
|
31
|
+
background: rgba(255, 170, 0, 0.18);
|
|
32
|
+
color: #ffaa00;
|
|
33
|
+
border: 1px solid rgba(255, 170, 0, 0.45);
|
|
34
|
+
padding: 1px 7px;
|
|
35
|
+
border-radius: 4px;
|
|
36
|
+
font-size: 12px;
|
|
37
|
+
font-weight: 700;
|
|
38
|
+
letter-spacing: 0.4px;
|
|
39
|
+
}
|
|
40
|
+
.noKeyText {
|
|
41
|
+
/* 📖 Inherits the surrounding font, just bumps the weight and tracking for the
|
|
42
|
+
📖 label so the chip reads as a single badge even at narrow column widths. */
|
|
43
|
+
font-weight: 700;
|
|
44
|
+
letter-spacing: 0.4px;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
:global([data-theme="light"]) .noKey {
|
|
48
|
+
background: rgba(255, 170, 0, 0.25);
|
|
49
|
+
border-color: rgba(255, 170, 0, 0.55);
|
|
50
|
+
color: #b86b00;
|
|
51
|
+
}
|
|
52
|
+
:global([data-theme="light"]) .up { color: #008f4d; }
|
|
53
|
+
:global([data-theme="light"]) .warning { color: #b86b00; }
|
|
54
|
+
:global([data-theme="light"]) .error,
|
|
55
|
+
:global([data-theme="light"]) .errorBold { color: #c02323; }
|
|
56
|
+
:global([data-theme="light"]) .dim { color: #888; }
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file web/src/components/atoms/LastPingCell.jsx
|
|
3
|
+
* @description Last ping latency number with color coding + animated spinner during pings.
|
|
4
|
+
* 📖 Values are milliseconds, displayed without the `ms` suffix to keep table cells compact.
|
|
5
|
+
* 📖 During ping rounds: animated CSS spinner shows which models are being tested.
|
|
6
|
+
*/
|
|
7
|
+
import styles from './LastPingCell.module.css'
|
|
8
|
+
|
|
9
|
+
function pingClass(ms) {
|
|
10
|
+
if (ms == null || ms === Infinity) return styles.none
|
|
11
|
+
if (ms < 500) return styles.fast
|
|
12
|
+
if (ms < 1500) return styles.medium
|
|
13
|
+
return styles.slow
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export default function LastPingCell({ ms, isPinging }) {
|
|
17
|
+
if (ms == null) {
|
|
18
|
+
return (
|
|
19
|
+
<span className={`${styles.cell} ${styles.none}`}>
|
|
20
|
+
{isPinging ? (
|
|
21
|
+
<span className={styles.spinner} title="Testing…" />
|
|
22
|
+
) : (
|
|
23
|
+
'—'
|
|
24
|
+
)}
|
|
25
|
+
</span>
|
|
26
|
+
)
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
return (
|
|
30
|
+
<span className={`${styles.cell} ${pingClass(ms)}`}>
|
|
31
|
+
<span className={styles.value}>{ms}</span>
|
|
32
|
+
{isPinging && <span className={styles.spinner} />}
|
|
33
|
+
</span>
|
|
34
|
+
)
|
|
35
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
.cell {
|
|
2
|
+
font-family: var(--font-mono);
|
|
3
|
+
font-size: 12px;
|
|
4
|
+
font-weight: 600;
|
|
5
|
+
display: inline-flex;
|
|
6
|
+
align-items: center;
|
|
7
|
+
gap: 4px;
|
|
8
|
+
min-width: 54px;
|
|
9
|
+
}
|
|
10
|
+
.value { }
|
|
11
|
+
.fast { color: #00ff88; }
|
|
12
|
+
.medium { color: #ffaa00; }
|
|
13
|
+
.slow { color: #ff4444; }
|
|
14
|
+
.none { color: var(--color-text-dim); }
|
|
15
|
+
|
|
16
|
+
/* ── Animated spinner for active pings ── */
|
|
17
|
+
@keyframes pingPulse {
|
|
18
|
+
0%, 100% { opacity: 1; }
|
|
19
|
+
50% { opacity: 0.3; }
|
|
20
|
+
}
|
|
21
|
+
@keyframes pingRing {
|
|
22
|
+
0% { transform: rotate(0deg) scale(1); opacity: 1; }
|
|
23
|
+
70% { transform: rotate(180deg) scale(0.6); opacity: 0.6; }
|
|
24
|
+
100% { transform: rotate(360deg) scale(1); opacity: 1; }
|
|
25
|
+
}
|
|
26
|
+
.spinner {
|
|
27
|
+
display: inline-block;
|
|
28
|
+
width: 10px;
|
|
29
|
+
height: 10px;
|
|
30
|
+
border: 1.5px solid rgba(0, 255, 136, 0.25);
|
|
31
|
+
border-top-color: #00ff88;
|
|
32
|
+
border-radius: 50%;
|
|
33
|
+
animation: pingRing 0.8s linear infinite;
|
|
34
|
+
flex-shrink: 0;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/* 📖 Light theme: the neon green ring is unreadable on white, swap to a deep
|
|
38
|
+
📖 green ring on a soft neutral track so the spinner stays visible. */
|
|
39
|
+
:global([data-theme="light"]) .fast { color: #008f4d; }
|
|
40
|
+
:global([data-theme="light"]) .medium { color: #b86b00; }
|
|
41
|
+
:global([data-theme="light"]) .slow { color: #c8143a; }
|
|
42
|
+
:global([data-theme="light"]) .spinner {
|
|
43
|
+
border-color: rgba(0, 0, 0, 0.12);
|
|
44
|
+
border-top-color: #008f4d;
|
|
45
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file web/src/components/atoms/MoodCell.jsx
|
|
3
|
+
* @description Tiny verdict indicator emoji (1st column) matching CLI ❔ column.
|
|
4
|
+
* 📖 Mirrors the full Verdict as a compact emoji: 🟩 Perfect, 🟢 Normal, 🟡 Spiky,
|
|
5
|
+
* 🟠 Slow, 🔴 Very Slow, 🔥 Overloaded, 🟥 Unstable, ⚫ Not Active, ⏳ Pending.
|
|
6
|
+
*/
|
|
7
|
+
import styles from './MoodCell.module.css'
|
|
8
|
+
|
|
9
|
+
const VERDICT_EMOJI = {
|
|
10
|
+
Perfect: '🟩',
|
|
11
|
+
Normal: '🟢',
|
|
12
|
+
Spiky: '🟡',
|
|
13
|
+
Slow: '🟠',
|
|
14
|
+
'Very Slow': '🔴',
|
|
15
|
+
Overloaded: '🔥',
|
|
16
|
+
Unstable: '🟥',
|
|
17
|
+
'Not Active':'⚫',
|
|
18
|
+
Pending: '⏳',
|
|
19
|
+
Usable: '🟠', // fallback
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export default function MoodCell({ verdict }) {
|
|
23
|
+
const emoji = VERDICT_EMOJI[verdict] || '❔'
|
|
24
|
+
return <span className={styles.mood}>{emoji}</span>
|
|
25
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file web/src/components/atoms/NoKeyIcon.jsx
|
|
3
|
+
* @description Compact "no API key" indicator — yellow key with a red diagonal strikethrough.
|
|
4
|
+
*
|
|
5
|
+
* 📖 Used in two places to surface the missing-key state without duplicating the
|
|
6
|
+
* 📖 verbose "NO KEY" pill that used to live next to the model label:
|
|
7
|
+
* 📖 1. Inline next to the model name in the Model cell (replaces the old orange pill)
|
|
8
|
+
* 📖 2. Inside the Health cell, sitting next to the "NO KEY" text label
|
|
9
|
+
*
|
|
10
|
+
* 📖 The strikethrough is rendered as an inline SVG `<line>` on top of the Tabler
|
|
11
|
+
* 📖 `IconKey`. Doing it in SVG (instead of CSS) keeps the line aligned with the
|
|
12
|
+
* 📖 icon at any size and avoids the CSS overflow quirks that come with rotating
|
|
13
|
+
* 📖 a pseudo-element inside a flex parent.
|
|
14
|
+
*
|
|
15
|
+
* 📖 `size` defaults to 14px which is the same as the Tabler icons used in Settings;
|
|
16
|
+
* 📖 pass a smaller value (e.g. 11) for inline use next to model names where space is tight.
|
|
17
|
+
*
|
|
18
|
+
* @functions
|
|
19
|
+
* → NoKeyIcon — renders a yellow key with a red strikethrough
|
|
20
|
+
* @exports NoKeyIcon
|
|
21
|
+
*/
|
|
22
|
+
import { IconKey } from '@tabler/icons-react'
|
|
23
|
+
import styles from './NoKeyIcon.module.css'
|
|
24
|
+
|
|
25
|
+
export default function NoKeyIcon({ size = 14, title = 'No API key configured' }) {
|
|
26
|
+
// 📖 Stroke width follows the rest of the dashboard's icon style (1.5).
|
|
27
|
+
// 📖 The key is yellow (warning palette); the slash is a bright red so it reads
|
|
28
|
+
// 📖 as a "blocked" state at a glance even in peripheral vision.
|
|
29
|
+
const strokeWidth = 1.5
|
|
30
|
+
const slashPadding = Math.max(1, Math.round(size * 0.07))
|
|
31
|
+
|
|
32
|
+
return (
|
|
33
|
+
<span
|
|
34
|
+
className={styles.wrapper}
|
|
35
|
+
style={{ width: `${size}px`, height: `${size}px` }}
|
|
36
|
+
role="img"
|
|
37
|
+
aria-label={title}
|
|
38
|
+
title={title}
|
|
39
|
+
>
|
|
40
|
+
<IconKey
|
|
41
|
+
size={size}
|
|
42
|
+
stroke={strokeWidth}
|
|
43
|
+
className={styles.key}
|
|
44
|
+
/>
|
|
45
|
+
<svg
|
|
46
|
+
className={styles.slash}
|
|
47
|
+
width={size + slashPadding * 2}
|
|
48
|
+
height={size + slashPadding * 2}
|
|
49
|
+
viewBox={`0 0 ${size + slashPadding * 2} ${size + slashPadding * 2}`}
|
|
50
|
+
aria-hidden="true"
|
|
51
|
+
focusable="false"
|
|
52
|
+
>
|
|
53
|
+
<line
|
|
54
|
+
x1={slashPadding + 1}
|
|
55
|
+
y1={slashPadding + 1}
|
|
56
|
+
x2={slashPadding + size - 1}
|
|
57
|
+
y2={slashPadding + size - 1}
|
|
58
|
+
stroke="var(--color-danger, #ff4444)"
|
|
59
|
+
strokeWidth="1.1"
|
|
60
|
+
strokeLinecap="round"
|
|
61
|
+
/>
|
|
62
|
+
</svg>
|
|
63
|
+
</span>
|
|
64
|
+
)
|
|
65
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file web/src/components/atoms/NoKeyIcon.module.css
|
|
3
|
+
* @description Styles for the NoKeyIcon atom — yellow key with a red diagonal slash.
|
|
4
|
+
* 📖 The wrapper is a fixed-size box so the absolutely-positioned slash SVG can
|
|
5
|
+
* 📖 center itself over the icon regardless of the parent's flex alignment.
|
|
6
|
+
*/
|
|
7
|
+
.wrapper {
|
|
8
|
+
display: inline-flex;
|
|
9
|
+
align-items: center;
|
|
10
|
+
justify-content: center;
|
|
11
|
+
position: relative;
|
|
12
|
+
flex-shrink: 0;
|
|
13
|
+
line-height: 0;
|
|
14
|
+
vertical-align: middle;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
.key {
|
|
18
|
+
color: #ffaa00; /* 📖 warning yellow — same family as the rest of the dashboard */
|
|
19
|
+
position: relative;
|
|
20
|
+
z-index: 1;
|
|
21
|
+
filter: drop-shadow(0 0 1px rgba(255, 170, 0, 0.35));
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
.slash {
|
|
25
|
+
position: absolute;
|
|
26
|
+
inset: 0;
|
|
27
|
+
margin: auto;
|
|
28
|
+
pointer-events: none;
|
|
29
|
+
z-index: 2;
|
|
30
|
+
}
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file web/src/components/atoms/ProviderLogo.jsx
|
|
3
|
+
* @description Renders a provider cell as [icon + text-wordmark] on a single line.
|
|
4
|
+
*
|
|
5
|
+
* @details
|
|
6
|
+
* - Looks up the provider's assets under `web/assets/providers/<folder>/`.
|
|
7
|
+
* - Prefers the **color** icon when available (recognizable + lively in both themes);
|
|
8
|
+
* falls back to the **mono** (currentColor) icon when no color variant exists.
|
|
9
|
+
* - Renders the **text** wordmark (`<id>-text.svg`) next to the icon when available.
|
|
10
|
+
* Text wordmarks use `fill="currentColor"`, so they automatically adapt to the
|
|
11
|
+
* active theme (white in dark mode, black in light mode) without any JS.
|
|
12
|
+
* - For providers without a text wordmark (legacy OVH / Scaleway SVGs, or models
|
|
13
|
+
* like Codestral that share a parent's brand), shows the graphic icon + the
|
|
14
|
+
* human-readable caption as plain text.
|
|
15
|
+
* - Inlines the SVGs as raw strings via `import.meta.glob(..., { as: 'raw' })` so
|
|
16
|
+
* `currentColor` resolves against the cell's text color. Using `<img src>` would
|
|
17
|
+
* sandbox the SVG and break the theme inheritance.
|
|
18
|
+
* - Constrained to a single row height (~22px) via `--pl-max-h` so the cell never
|
|
19
|
+
* grows taller than the rest of the table, regardless of which variant is shown.
|
|
20
|
+
*
|
|
21
|
+
* @param {object} props
|
|
22
|
+
* @param {string} props.providerKey - The kebab-case key from sources.js (e.g. 'nvidia', 'github-models', 'opencode-zen', 'googleai').
|
|
23
|
+
* @param {string} props.origin - Human-readable name from sources.js, used as fallback text / tooltip.
|
|
24
|
+
* @functions
|
|
25
|
+
* → ProviderLogo (default export)
|
|
26
|
+
* → getSvg (internal) – raw-string lookup keyed by asset path
|
|
27
|
+
* @see web/src/components/dashboard/ModelTable.jsx (consumer)
|
|
28
|
+
* @see web/assets/providers/ (asset source)
|
|
29
|
+
* @see https://lobehub.com/icons (variant inventory source)
|
|
30
|
+
*/
|
|
31
|
+
import styles from './ProviderLogo.module.css'
|
|
32
|
+
|
|
33
|
+
// 📖 Eager raw import of every provider SVG. The key is a relative path from this
|
|
34
|
+
// 📖 file; the value is the SVG markup as a string. Vite bundles it at build time.
|
|
35
|
+
// 📖 Path resolves to `web/assets/providers/**/*.svg` (3 levels up from atoms/).
|
|
36
|
+
const svgModules = import.meta.glob('../../../assets/providers/**/*.svg', {
|
|
37
|
+
query: '?raw',
|
|
38
|
+
import: 'default',
|
|
39
|
+
eager: true,
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
// 📖 Provider key → asset folder + variant availability.
|
|
43
|
+
// 📖 `folder` MUST match the directory name under web/assets/providers/.
|
|
44
|
+
// 📖 `hasColor` / `hasText` are derived from the lobe-icons static-svg inventory
|
|
45
|
+
// 📖 (see https://lobehub.com/icons). `legacy` marks providers that ship with a
|
|
46
|
+
// 📖 single graphic SVG and no wordmark — for those we render icon + caption.
|
|
47
|
+
// 📖 Keys MUST match the `providerKey` field sent by the backend (kebab-case for
|
|
48
|
+
// 📖 compound names like `github-models` / `opencode-zen` — see sources.js).
|
|
49
|
+
const PROVIDER_CONFIG = {
|
|
50
|
+
nvidia: { folder: 'nvidia', hasColor: true, hasText: true },
|
|
51
|
+
groq: { folder: 'groq', hasColor: false, hasText: true },
|
|
52
|
+
cerebras: { folder: 'cerebras', hasColor: true, hasText: true },
|
|
53
|
+
googleai: { folder: 'aistudio', hasColor: false, hasText: true },
|
|
54
|
+
'github-models': { folder: 'github', hasColor: false, hasText: true },
|
|
55
|
+
mistral: { folder: 'mistral', hasColor: true, hasText: true },
|
|
56
|
+
cloudflare: { folder: 'cloudflare', hasColor: true, hasText: true },
|
|
57
|
+
openrouter: { folder: 'openrouter', hasColor: false, hasText: true },
|
|
58
|
+
sambanova: { folder: 'sambanova', hasColor: true, hasText: true },
|
|
59
|
+
// 📖 OVH's logo is itself a full wordmark ("OVHcloud") — adding the origin
|
|
60
|
+
// 📖 name "OVHcloud AI" as a caption would be redundant. `noCaption` skips
|
|
61
|
+
// 📖 the text label and lets the wordmark stand on its own. The user pointed
|
|
62
|
+
// 📖 us to https://www.ovhcloud.com/.../OVHcloud_logo.svg#ocmscloud_logo for
|
|
63
|
+
// 📖 the international wordmark; we extract the inner <symbol> and save it as
|
|
64
|
+
// 📖 a renderable <svg> with the same viewBox.
|
|
65
|
+
ovhcloud: { folder: 'ovhcloud', hasColor: false, hasText: false, legacy: true, noCaption: true },
|
|
66
|
+
// 📖 Scaleway's pre-existing asset is `ScalewayLogo.svg` (capitalized) inside
|
|
67
|
+
// 📖 `scalewaylogo/`. The default filename lookup would look for
|
|
68
|
+
// 📖 `scalewaylogo/scalewaylogo.svg` which doesn't exist, so `iconFile`
|
|
69
|
+
// 📖 overrides it. The wordmark IS the brand name ("Scaleway") so adding
|
|
70
|
+
// 📖 the origin name as a separate caption is redundant — `noCaption` skips it.
|
|
71
|
+
// 📖 `iconClass: 'scalewayLogo'` attaches a global class so CSS can swap to
|
|
72
|
+
// 📖 a lighter purple in dark mode (the brand #521094 is barely visible on
|
|
73
|
+
// 📖 the near-black background). SVG paths were rewritten to use
|
|
74
|
+
// 📖 `fill="currentColor"` for this CSS control.
|
|
75
|
+
scaleway: { folder: 'scalewaylogo', iconFile: 'ScalewayLogo', iconClass: 'scalewayLogo', hasColor: false, hasText: false, legacy: true, noCaption: true },
|
|
76
|
+
zai: { folder: 'zai', hasColor: false, hasText: true },
|
|
77
|
+
qwen: { folder: 'qwen', hasColor: true, hasText: true },
|
|
78
|
+
gemini: { folder: 'gemini', hasColor: true, hasText: true },
|
|
79
|
+
'opencode-zen': { folder: 'opencode', hasColor: false, hasText: true },
|
|
80
|
+
// 📖 Codestral intentionally reuses Mistral's branding — the colored Mistral
|
|
81
|
+
// 📖 icon (so the brand association is visible) + the literal caption
|
|
82
|
+
// 📖 "Codestral" as plain text. `iconFile` overrides the default filename
|
|
83
|
+
// 📖 lookup (we want mistral-color, not codestral-color which doesn't exist),
|
|
84
|
+
// 📖 and `caption` overrides the origin so we don't show "Codestral" twice.
|
|
85
|
+
codestral: { folder: 'mistral', iconFile: 'mistral-color', hasColor: true, hasText: false, legacy: true, caption: 'Codestral' },
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function getSvg(folder, filename) {
|
|
89
|
+
if (!folder || !filename) return null
|
|
90
|
+
return svgModules[`../../../assets/providers/${folder}/${filename}.svg`] || null
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export default function ProviderLogo({ providerKey, origin }) {
|
|
94
|
+
const cfg = PROVIDER_CONFIG[providerKey]
|
|
95
|
+
|
|
96
|
+
// 📖 Unknown / unmapped provider: fall back to plain text. Same as the old
|
|
97
|
+
// 📖 `.providerPill` so we don't visually regress on rows we couldn't classify.
|
|
98
|
+
if (!cfg) {
|
|
99
|
+
return <span className={styles.fallback}>{origin}</span>
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// 📖 Pick the best icon: prefer the colorful variant, fall back to mono.
|
|
103
|
+
// 📖 Mono uses currentColor → inherits the cell's text color → theme-adaptive.
|
|
104
|
+
// 📖 `cfg.iconFile` overrides the default filename so providers like Codestral
|
|
105
|
+
// 📖 can borrow another provider's icon (e.g. mistral-color for brand parity).
|
|
106
|
+
const iconSvg =
|
|
107
|
+
(cfg.iconFile && getSvg(cfg.folder, cfg.iconFile)) ||
|
|
108
|
+
(cfg.hasColor && getSvg(cfg.folder, `${cfg.folder}-color`)) ||
|
|
109
|
+
getSvg(cfg.folder, cfg.folder)
|
|
110
|
+
|
|
111
|
+
// 📖 Pick the text wordmark (currentColor-based, theme-adaptive).
|
|
112
|
+
const textSvg = cfg.hasText ? getSvg(cfg.folder, `${cfg.folder}-text`) : null
|
|
113
|
+
|
|
114
|
+
// ─── Layouts ─────────────────────────────────────────────────────────────
|
|
115
|
+
// 📖 Single horizontal row: [icon | gap | wordmark], all in --pl-max-h tall.
|
|
116
|
+
// 📖 `overflow: hidden` on the row is a safety net for the rare case where
|
|
117
|
+
// 📖 a particularly wide wordmark (e.g. cloudflare ~145px) still overflows
|
|
118
|
+
// 📖 the column even at 12px height — better clipped than warping the table.
|
|
119
|
+
if (iconSvg && textSvg) {
|
|
120
|
+
return (
|
|
121
|
+
<span className={styles.row} title={origin}>
|
|
122
|
+
<span
|
|
123
|
+
className={styles.icon}
|
|
124
|
+
aria-hidden="true"
|
|
125
|
+
dangerouslySetInnerHTML={{ __html: iconSvg }}
|
|
126
|
+
/>
|
|
127
|
+
<span
|
|
128
|
+
className={styles.textLogo}
|
|
129
|
+
aria-hidden="true"
|
|
130
|
+
dangerouslySetInnerHTML={{ __html: textSvg }}
|
|
131
|
+
/>
|
|
132
|
+
</span>
|
|
133
|
+
)
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// 📖 Legacy providers (Scaleway, OVH by default): graphic only, no wordmark SVG.
|
|
137
|
+
// 📖 Show the graphic + a caption as plain text so the cell stays scannable
|
|
138
|
+
// 📖 in the table. `cfg.caption` overrides the origin name (Codestral wants
|
|
139
|
+
// 📖 its own label, not whatever sources.js returns). `cfg.noCaption` skips
|
|
140
|
+
// 📖 the caption entirely (OVH's graphic is itself a full wordmark, and
|
|
141
|
+
// 📖 Scaleway's wordmark already contains the brand name).
|
|
142
|
+
// 📖 `cfg.iconClass` (when set) is appended to the icon span — used to attach
|
|
143
|
+
// 📖 a global class for per-theme color overrides (e.g. Scaleway's lighter
|
|
144
|
+
// 📖 purple in dark mode).
|
|
145
|
+
if (iconSvg) {
|
|
146
|
+
const captionText = cfg.caption ?? origin
|
|
147
|
+
if (cfg.noCaption) {
|
|
148
|
+
const wordmarkClass = cfg.iconClass
|
|
149
|
+
? `${styles.iconLegacyWordmark} ${cfg.iconClass}`
|
|
150
|
+
: styles.iconLegacyWordmark
|
|
151
|
+
return (
|
|
152
|
+
<span className={styles.row} title={origin}>
|
|
153
|
+
<span
|
|
154
|
+
className={wordmarkClass}
|
|
155
|
+
aria-hidden="true"
|
|
156
|
+
dangerouslySetInnerHTML={{ __html: iconSvg }}
|
|
157
|
+
/>
|
|
158
|
+
</span>
|
|
159
|
+
)
|
|
160
|
+
}
|
|
161
|
+
return (
|
|
162
|
+
<span className={styles.row} title={origin}>
|
|
163
|
+
<span
|
|
164
|
+
className={styles.icon}
|
|
165
|
+
aria-hidden="true"
|
|
166
|
+
dangerouslySetInnerHTML={{ __html: iconSvg }}
|
|
167
|
+
/>
|
|
168
|
+
<span className={styles.fallback}>{captionText}</span>
|
|
169
|
+
</span>
|
|
170
|
+
)
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// 📖 Icon-only fallback (shouldn't happen with current assets, but safe).
|
|
174
|
+
if (textSvg) {
|
|
175
|
+
return (
|
|
176
|
+
<span className={styles.row} title={origin}>
|
|
177
|
+
<span
|
|
178
|
+
className={styles.textLogo}
|
|
179
|
+
aria-hidden="true"
|
|
180
|
+
dangerouslySetInnerHTML={{ __html: textSvg }}
|
|
181
|
+
/>
|
|
182
|
+
</span>
|
|
183
|
+
)
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// 📖 No assets at all (e.g. codestral without icon). Plain text fallback.
|
|
187
|
+
return <span className={styles.fallback}>{origin}</span>
|
|
188
|
+
}
|