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.
Files changed (145) hide show
  1. package/README.md +40 -6
  2. package/bin/free-coding-models.js +10 -0
  3. package/changelog/v0.5.1.md +24 -0
  4. package/changelog/v0.5.4.md +24 -0
  5. package/package.json +11 -3
  6. package/sources.js +2 -0
  7. package/src/core/router-daemon.js +166 -1
  8. package/src/core/utils.js +2 -0
  9. package/src/tui/cli-help.js +2 -0
  10. package/src/tui/key-handler.js +1 -1
  11. package/src/tui/render-table.js +1 -1
  12. package/web/README.md +8 -5
  13. package/web/assets/providers/aistudio/aistudio-text.svg +1 -0
  14. package/web/assets/providers/aistudio/aistudio.svg +1 -0
  15. package/web/assets/providers/cerebras/cerebras-brand-color.svg +1 -0
  16. package/web/assets/providers/cerebras/cerebras-brand.svg +1 -0
  17. package/web/assets/providers/cerebras/cerebras-color.svg +1 -0
  18. package/web/assets/providers/cerebras/cerebras-text.svg +1 -0
  19. package/web/assets/providers/cerebras/cerebras.svg +1 -0
  20. package/web/assets/providers/cloudflare/cloudflare-color.svg +1 -0
  21. package/web/assets/providers/cloudflare/cloudflare-text.svg +1 -0
  22. package/web/assets/providers/cloudflare/cloudflare.svg +1 -0
  23. package/web/assets/providers/gemini/gemini-color.svg +1 -0
  24. package/web/assets/providers/gemini/gemini-text.svg +1 -0
  25. package/web/assets/providers/gemini/gemini.svg +1 -0
  26. package/web/assets/providers/github/github-text.svg +1 -0
  27. package/web/assets/providers/github/github.svg +1 -0
  28. package/web/assets/providers/groq/groq-text.svg +1 -0
  29. package/web/assets/providers/groq/groq.svg +1 -0
  30. package/web/assets/providers/mistral/mistral-color.svg +1 -0
  31. package/web/assets/providers/mistral/mistral-text.svg +1 -0
  32. package/web/assets/providers/mistral/mistral.svg +1 -0
  33. package/web/assets/providers/nvidia/nvidia-color.svg +1 -0
  34. package/web/assets/providers/nvidia/nvidia-text.svg +1 -0
  35. package/web/assets/providers/nvidia/nvidia.svg +1 -0
  36. package/web/assets/providers/opencode/opencode-text.svg +1 -0
  37. package/web/assets/providers/opencode/opencode.svg +1 -0
  38. package/web/assets/providers/openrouter/openrouter (1).svg +1 -0
  39. package/web/assets/providers/openrouter/openrouter-text.svg +1 -0
  40. package/web/assets/providers/openrouter/openrouter.svg +1 -0
  41. package/web/assets/providers/ovhcloud/ovhcloud.svg +12 -0
  42. package/web/assets/providers/qwen/qwen-color.svg +1 -0
  43. package/web/assets/providers/qwen/qwen-text.svg +1 -0
  44. package/web/assets/providers/qwen/qwen.svg +1 -0
  45. package/web/assets/providers/sambanova/sambanova-color.svg +1 -0
  46. package/web/assets/providers/sambanova/sambanova-text.svg +1 -0
  47. package/web/assets/providers/sambanova/sambanova.svg +1 -0
  48. package/web/assets/providers/scalewaylogo/ScalewayLogo.svg +13 -0
  49. package/web/assets/providers/zai/zai-text.svg +1 -0
  50. package/web/assets/providers/zai/zai.svg +1 -0
  51. package/web/dist/assets/index-BoWmUveV.js +39 -0
  52. package/web/dist/assets/index-BrpHevg4.css +1 -0
  53. package/web/dist/favicon.ico +0 -0
  54. package/web/dist/favicons/apple-touch-icon.png +0 -0
  55. package/web/dist/favicons/browserconfig.xml +12 -0
  56. package/web/dist/favicons/favicon-16x16.png +0 -0
  57. package/web/dist/favicons/favicon-192x192.png +0 -0
  58. package/web/dist/favicons/favicon-32x32.png +0 -0
  59. package/web/dist/favicons/favicon-48x48.png +0 -0
  60. package/web/dist/favicons/favicon-512x512.png +0 -0
  61. package/web/dist/favicons/favicon-96x96.png +0 -0
  62. package/web/dist/favicons/favicon.ico +0 -0
  63. package/web/dist/favicons/mstile-150x150.png +0 -0
  64. package/web/dist/favicons/mstile-310x150.png +0 -0
  65. package/web/dist/favicons/mstile-310x310.png +0 -0
  66. package/web/dist/favicons/mstile-512x512.png +0 -0
  67. package/web/dist/favicons/mstile-70x70.png +0 -0
  68. package/web/dist/favicons/site.webmanifest +25 -0
  69. package/web/dist/index.html +42 -6
  70. package/web/index.html +40 -4
  71. package/web/package.json +11 -0
  72. package/web/public/favicon.ico +0 -0
  73. package/web/public/favicons/apple-touch-icon.png +0 -0
  74. package/web/public/favicons/browserconfig.xml +12 -0
  75. package/web/public/favicons/favicon-16x16.png +0 -0
  76. package/web/public/favicons/favicon-192x192.png +0 -0
  77. package/web/public/favicons/favicon-32x32.png +0 -0
  78. package/web/public/favicons/favicon-48x48.png +0 -0
  79. package/web/public/favicons/favicon-512x512.png +0 -0
  80. package/web/public/favicons/favicon-96x96.png +0 -0
  81. package/web/public/favicons/favicon.ico +0 -0
  82. package/web/public/favicons/mstile-150x150.png +0 -0
  83. package/web/public/favicons/mstile-310x150.png +0 -0
  84. package/web/public/favicons/mstile-310x310.png +0 -0
  85. package/web/public/favicons/mstile-512x512.png +0 -0
  86. package/web/public/favicons/mstile-70x70.png +0 -0
  87. package/web/public/favicons/site.webmanifest +25 -0
  88. package/web/server.js +676 -211
  89. package/web/src/App.jsx +233 -71
  90. package/web/src/components/analytics/AnalyticsView.jsx +40 -6
  91. package/web/src/components/analytics/AnalyticsView.module.css +4 -0
  92. package/web/src/components/atoms/AILatencyCell.jsx +38 -0
  93. package/web/src/components/atoms/AILatencyCell.module.css +51 -0
  94. package/web/src/components/atoms/HealthCell.jsx +138 -0
  95. package/web/src/components/atoms/HealthCell.module.css +56 -0
  96. package/web/src/components/atoms/LastPingCell.jsx +35 -0
  97. package/web/src/components/atoms/LastPingCell.module.css +45 -0
  98. package/web/src/components/atoms/MoodCell.jsx +25 -0
  99. package/web/src/components/atoms/MoodCell.module.css +6 -0
  100. package/web/src/components/atoms/NoKeyIcon.jsx +65 -0
  101. package/web/src/components/atoms/NoKeyIcon.module.css +30 -0
  102. package/web/src/components/atoms/ProviderLogo.jsx +188 -0
  103. package/web/src/components/atoms/ProviderLogo.module.css +103 -0
  104. package/web/src/components/atoms/RankCell.jsx +9 -0
  105. package/web/src/components/atoms/RankCell.module.css +9 -0
  106. package/web/src/components/atoms/Sparkline.jsx +21 -2
  107. package/web/src/components/atoms/StabilityCell.module.css +5 -0
  108. package/web/src/components/atoms/StatusDot.module.css +7 -0
  109. package/web/src/components/atoms/TPSCell.jsx +36 -0
  110. package/web/src/components/atoms/TPSCell.module.css +44 -0
  111. package/web/src/components/atoms/TierBadge.module.css +11 -0
  112. package/web/src/components/atoms/Toast.module.css +6 -0
  113. package/web/src/components/atoms/VerdictBadge.jsx +30 -7
  114. package/web/src/components/atoms/VerdictBadge.module.css +36 -15
  115. package/web/src/components/dashboard/DetailPanel.jsx +63 -2
  116. package/web/src/components/dashboard/DetailPanel.module.css +130 -0
  117. package/web/src/components/dashboard/ExportModal.jsx +9 -4
  118. package/web/src/components/dashboard/FilterBar.jsx +228 -33
  119. package/web/src/components/dashboard/FilterBar.module.css +187 -22
  120. package/web/src/components/dashboard/ModelTable.jsx +500 -54
  121. package/web/src/components/dashboard/ModelTable.module.css +318 -31
  122. package/web/src/components/layout/Footer.jsx +1 -1
  123. package/web/src/components/layout/Header.jsx +171 -9
  124. package/web/src/components/layout/Header.module.css +223 -25
  125. package/web/src/components/palette/CommandPalette.jsx +146 -0
  126. package/web/src/components/palette/CommandPalette.module.css +121 -0
  127. package/web/src/components/settings/SettingsView.jsx +24 -6
  128. package/web/src/components/settings/SettingsView.module.css +0 -1
  129. package/web/src/global.css +118 -84
  130. package/web/src/hooks/useColumnSizing.js +211 -0
  131. package/web/src/hooks/useFavorites.js +150 -0
  132. package/web/src/hooks/useFilter.js +262 -37
  133. package/web/src/hooks/useSSE.js +33 -9
  134. package/web/src/hooks/useSocket.js +200 -0
  135. package/web/src/hooks/useTheme.js +72 -16
  136. package/web/src/hooks/useUrlState.js +93 -0
  137. package/web/vite.config.js +41 -0
  138. package/web/dist/assets/index-CGN-0_A0.css +0 -1
  139. package/web/dist/assets/index-CvMUM9Jr.js +0 -11
  140. package/web/src/components/dashboard/StatsBar.jsx +0 -40
  141. package/web/src/components/dashboard/StatsBar.module.css +0 -28
  142. package/web/src/components/layout/Sidebar.jsx +0 -42
  143. package/web/src/components/layout/Sidebar.module.css +0 -76
  144. package/web/src/components/map/MapView.jsx +0 -17
  145. 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,6 @@
1
+ .mood {
2
+ display: inline-block;
3
+ font-size: 14px;
4
+ text-align: center;
5
+ width: 20px;
6
+ }
@@ -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
+ }