free-coding-models 0.3.36 → 0.3.40

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 (58) hide show
  1. package/CHANGELOG.md +10 -1798
  2. package/README.md +4 -1
  3. package/bin/free-coding-models.js +1 -1
  4. package/package.json +11 -2
  5. package/src/app.js +3 -0
  6. package/src/cli-help.js +2 -0
  7. package/src/command-palette.js +3 -0
  8. package/src/endpoint-installer.js +1 -1
  9. package/src/tool-bootstrap.js +34 -0
  10. package/src/tool-launchers.js +137 -1
  11. package/src/tool-metadata.js +9 -0
  12. package/src/utils.js +8 -2
  13. package/web/index.html +2 -300
  14. package/web/server.js +80 -15
  15. package/web/src/App.jsx +150 -0
  16. package/web/src/components/analytics/AnalyticsView.jsx +109 -0
  17. package/web/src/components/analytics/AnalyticsView.module.css +186 -0
  18. package/web/src/components/atoms/Sparkline.jsx +44 -0
  19. package/web/src/components/atoms/StabilityCell.jsx +18 -0
  20. package/web/src/components/atoms/StabilityCell.module.css +8 -0
  21. package/web/src/components/atoms/StatusDot.jsx +10 -0
  22. package/web/src/components/atoms/StatusDot.module.css +17 -0
  23. package/web/src/components/atoms/TierBadge.jsx +10 -0
  24. package/web/src/components/atoms/TierBadge.module.css +18 -0
  25. package/web/src/components/atoms/Toast.jsx +25 -0
  26. package/web/src/components/atoms/Toast.module.css +35 -0
  27. package/web/src/components/atoms/ToastContainer.jsx +16 -0
  28. package/web/src/components/atoms/ToastContainer.module.css +10 -0
  29. package/web/src/components/atoms/VerdictBadge.jsx +13 -0
  30. package/web/src/components/atoms/VerdictBadge.module.css +19 -0
  31. package/web/src/components/dashboard/DetailPanel.jsx +131 -0
  32. package/web/src/components/dashboard/DetailPanel.module.css +99 -0
  33. package/web/src/components/dashboard/ExportModal.jsx +79 -0
  34. package/web/src/components/dashboard/ExportModal.module.css +99 -0
  35. package/web/src/components/dashboard/FilterBar.jsx +73 -0
  36. package/web/src/components/dashboard/FilterBar.module.css +43 -0
  37. package/web/src/components/dashboard/ModelTable.jsx +86 -0
  38. package/web/src/components/dashboard/ModelTable.module.css +46 -0
  39. package/web/src/components/dashboard/StatsBar.jsx +40 -0
  40. package/web/src/components/dashboard/StatsBar.module.css +28 -0
  41. package/web/src/components/layout/Footer.jsx +19 -0
  42. package/web/src/components/layout/Footer.module.css +10 -0
  43. package/web/src/components/layout/Header.jsx +38 -0
  44. package/web/src/components/layout/Header.module.css +73 -0
  45. package/web/src/components/layout/Sidebar.jsx +41 -0
  46. package/web/src/components/layout/Sidebar.module.css +76 -0
  47. package/web/src/components/settings/SettingsView.jsx +264 -0
  48. package/web/src/components/settings/SettingsView.module.css +377 -0
  49. package/web/src/global.css +199 -0
  50. package/web/src/hooks/useFilter.js +83 -0
  51. package/web/src/hooks/useSSE.js +49 -0
  52. package/web/src/hooks/useTheme.js +27 -0
  53. package/web/src/main.jsx +15 -0
  54. package/web/src/utils/download.js +15 -0
  55. package/web/src/utils/format.js +42 -0
  56. package/web/src/utils/ranks.js +37 -0
  57. /package/web/{app.js → app.legacy.js} +0 -0
  58. /package/web/{styles.css → styles.legacy.css} +0 -0
@@ -0,0 +1,40 @@
1
+ /**
2
+ * @file web/src/components/dashboard/StatsBar.jsx
3
+ * @description Stats cards row showing total models, online count, avg latency, fastest model, providers.
4
+ */
5
+ import { useMemo } from 'react'
6
+ import styles from './StatsBar.module.css'
7
+
8
+ export default function StatsBar({ models }) {
9
+ const stats = useMemo(() => {
10
+ const total = models.length
11
+ const online = models.filter(m => m.status === 'up').length
12
+ const onlineWithPing = models.filter(m => m.status === 'up' && m.avg !== Infinity && m.avg < 99000)
13
+ const avgLatency = onlineWithPing.length > 0
14
+ ? Math.round(onlineWithPing.reduce((s, m) => s + m.avg, 0) / onlineWithPing.length)
15
+ : null
16
+ const fastest = [...onlineWithPing].sort((a, b) => a.avg - b.avg)[0]
17
+ const providers = new Set(models.map(m => m.providerKey)).size
18
+ return [
19
+ { icon: '📊', value: total, label: 'Total Models' },
20
+ { icon: '🟢', value: online, label: 'Online' },
21
+ { icon: '⚡', value: avgLatency != null ? `${avgLatency}ms` : '—', label: 'Avg Latency' },
22
+ { icon: '🏆', value: fastest ? fastest.label : '—', label: 'Fastest Model' },
23
+ { icon: '🌐', value: providers, label: 'Providers' },
24
+ ]
25
+ }, [models])
26
+
27
+ return (
28
+ <section className={styles.statsBar}>
29
+ {stats.map(s => (
30
+ <div key={s.label} className={styles.card}>
31
+ <div className={styles.icon}>{s.icon}</div>
32
+ <div className={styles.body}>
33
+ <div className={styles.value}>{s.value}</div>
34
+ <div className={styles.label}>{s.label}</div>
35
+ </div>
36
+ </div>
37
+ ))}
38
+ </section>
39
+ )
40
+ }
@@ -0,0 +1,28 @@
1
+ .statsBar {
2
+ position: relative; z-index: 1;
3
+ display: grid; grid-template-columns: repeat(5, 1fr); gap: 14px;
4
+ padding: 20px 24px 0;
5
+ }
6
+ .card {
7
+ display: flex; align-items: center; gap: 12px;
8
+ background: var(--color-bg-card);
9
+ backdrop-filter: blur(12px);
10
+ -webkit-backdrop-filter: blur(12px);
11
+ border: 1px solid var(--color-border);
12
+ border-radius: 16px;
13
+ padding: 16px 18px;
14
+ transition: all 250ms cubic-bezier(0.16, 1, 0.3, 1);
15
+ }
16
+ .card:hover {
17
+ border-color: var(--color-border-hover);
18
+ transform: translateY(-2px);
19
+ box-shadow: var(--shadow-md);
20
+ }
21
+ .icon { font-size: 28px; }
22
+ .body { flex: 1; }
23
+ .value { font-size: 22px; font-weight: 800; font-family: var(--font-mono); line-height: 1; }
24
+ .label { font-size: 11px; color: var(--color-text-muted); text-transform: uppercase; letter-spacing: 0.5px; font-weight: 500; margin-top: 2px; }
25
+
26
+ @media (max-width: 1024px) { .statsBar { grid-template-columns: repeat(3, 1fr); } }
27
+ @media (max-width: 768px) { .statsBar { grid-template-columns: repeat(2, 1fr); } }
28
+ @media (max-width: 480px) { .statsBar { grid-template-columns: 1fr; } }
@@ -0,0 +1,19 @@
1
+ /**
2
+ * @file web/src/components/layout/Footer.jsx
3
+ * @description Simple footer with author credit and links.
4
+ */
5
+ import styles from './Footer.module.css'
6
+
7
+ export default function Footer() {
8
+ return (
9
+ <footer className={styles.footer}>
10
+ <div className={styles.left}>
11
+ Made with ❤️ by <a href="https://vavanessa.dev" target="_blank" rel="noopener">Vava-Nessa</a>
12
+ </div>
13
+ <div className={styles.right}>
14
+ <a href="https://github.com/vava-nessa/free-coding-models" target="_blank" rel="noopener">GitHub</a>
15
+ <a href="https://discord.gg/ZTNFHvvCkU" target="_blank" rel="noopener">Discord</a>
16
+ </div>
17
+ </footer>
18
+ )
19
+ }
@@ -0,0 +1,10 @@
1
+ .footer {
2
+ position: relative; z-index: 1;
3
+ display: flex; align-items: center; justify-content: space-between;
4
+ padding: 16px 24px;
5
+ border-top: 1px solid var(--color-border);
6
+ font-size: 12px; color: var(--color-text-dim);
7
+ }
8
+ .left a, .right a { color: var(--color-text-dim); text-decoration: none; }
9
+ .left a:hover, .right a:hover { color: var(--color-accent); }
10
+ .right { display: flex; gap: 16px; }
@@ -0,0 +1,38 @@
1
+ /**
2
+ * @file web/src/components/layout/Header.jsx
3
+ * @description Top header bar with search, export button, settings shortcut, and theme toggle.
4
+ */
5
+ import styles from './Header.module.css'
6
+
7
+ export default function Header({ searchQuery, onSearchChange, onToggleTheme, onOpenSettings, onOpenExport }) {
8
+ return (
9
+ <header className={styles.header}>
10
+ <div className={styles.left}>
11
+ <div className={styles.logo}>
12
+ <span className={styles.logoIcon}>⚡</span>
13
+ <span className={styles.logoText}>free-coding-models</span>
14
+ </div>
15
+ <span className={styles.version}>v{__APP_VERSION__}</span>
16
+ </div>
17
+ <div className={styles.center}>
18
+ <div className={styles.searchBar}>
19
+ <span className={styles.searchIcon}>🔍</span>
20
+ <input
21
+ type="text"
22
+ className={styles.searchInput}
23
+ placeholder="Search models, providers, tiers..."
24
+ value={searchQuery}
25
+ onChange={(e) => onSearchChange(e.target.value)}
26
+ autoComplete="off"
27
+ />
28
+ <kbd className={styles.kbd}>Ctrl+K</kbd>
29
+ </div>
30
+ </div>
31
+ <div className={styles.right}>
32
+ <button className={styles.iconBtn} onClick={onToggleTheme} title="Toggle theme">☽</button>
33
+ <button className={styles.iconBtn} onClick={onOpenExport} title="Export Data">↓</button>
34
+ <button className={styles.primaryBtn} onClick={onOpenSettings}>⚙ Settings</button>
35
+ </div>
36
+ </header>
37
+ )
38
+ }
@@ -0,0 +1,73 @@
1
+ .header {
2
+ position: sticky;
3
+ top: 0;
4
+ z-index: 100;
5
+ height: 60px;
6
+ display: flex;
7
+ align-items: center;
8
+ justify-content: space-between;
9
+ padding: 0 24px;
10
+ gap: 20px;
11
+ background: var(--color-bg-card);
12
+ backdrop-filter: blur(20px) saturate(1.5);
13
+ -webkit-backdrop-filter: blur(20px) saturate(1.5);
14
+ border-bottom: 1px solid var(--color-border);
15
+ }
16
+ .left { display: flex; align-items: center; gap: 12px; }
17
+ .center { flex: 1; max-width: 480px; }
18
+ .right { display: flex; align-items: center; gap: 10px; }
19
+
20
+ .logo { display: flex; align-items: center; gap: 8px; }
21
+ .logoIcon { font-size: 22px; }
22
+ .logoText {
23
+ font-size: 16px; font-weight: 700;
24
+ background: linear-gradient(135deg, var(--color-accent), #06b6d4);
25
+ -webkit-background-clip: text; -webkit-text-fill-color: transparent; background-clip: text;
26
+ }
27
+ .version {
28
+ font-size: 11px; font-weight: 500; font-family: var(--font-mono);
29
+ color: var(--color-text-dim); background: var(--color-accent-dim);
30
+ padding: 2px 8px; border-radius: 999px; border: 1px solid var(--color-border);
31
+ }
32
+
33
+ .searchBar {
34
+ display: flex; align-items: center; gap: 8px;
35
+ background: var(--color-surface); border: 1px solid var(--color-border);
36
+ border-radius: 10px; padding: 0 12px; height: 36px;
37
+ transition: border-color 150ms, box-shadow 150ms;
38
+ }
39
+ .searchBar:focus-within {
40
+ border-color: var(--color-accent);
41
+ box-shadow: 0 0 0 3px var(--color-accent-glow);
42
+ }
43
+ .searchIcon { font-size: 14px; flex-shrink: 0; }
44
+ .searchInput {
45
+ flex: 1; background: none; border: none; outline: none;
46
+ color: var(--color-text); font-size: 13px; font-family: var(--font-sans);
47
+ }
48
+ .searchInput::placeholder { color: var(--color-text-dim); }
49
+ .kbd {
50
+ font-size: 10px; font-family: var(--font-mono); color: var(--color-text-dim);
51
+ background: var(--color-bg); border: 1px solid var(--color-border);
52
+ padding: 2px 6px; border-radius: 4px; flex-shrink: 0;
53
+ }
54
+
55
+ .iconBtn {
56
+ display: inline-flex; align-items: center; justify-content: center;
57
+ padding: 6px 8px; border: 1px solid var(--color-border); border-radius: 6px;
58
+ background: var(--color-surface); color: var(--color-text);
59
+ cursor: pointer; font-size: 16px; transition: all 150ms;
60
+ }
61
+ .iconBtn:hover { background: var(--color-bg-hover); border-color: var(--color-border-hover); }
62
+ .primaryBtn {
63
+ display: inline-flex; align-items: center; gap: 6px;
64
+ padding: 6px 14px; font-size: 13px; font-weight: 600;
65
+ border: 1px solid var(--color-accent); border-radius: 6px;
66
+ background: var(--color-accent); color: #000; cursor: pointer;
67
+ font-family: var(--font-sans); transition: all 150ms;
68
+ }
69
+ .primaryBtn:hover { background: var(--color-accent-hover); }
70
+
71
+ @media (max-width: 768px) {
72
+ .center { display: none; }
73
+ }
@@ -0,0 +1,41 @@
1
+ /**
2
+ * @file web/src/components/layout/Sidebar.jsx
3
+ * @description Collapsible sidebar navigation with Dashboard / Settings / Analytics links + theme toggle.
4
+ */
5
+ import styles from './Sidebar.module.css'
6
+
7
+ const NAV_ITEMS = [
8
+ { id: 'dashboard', icon: '▤', label: 'Dashboard' },
9
+ { id: 'settings', icon: '⚙', label: 'Settings' },
10
+ { id: 'analytics', icon: '▌▌', label: 'Analytics' },
11
+ ]
12
+
13
+ export default function Sidebar({ currentView, onNavigate, onToggleTheme }) {
14
+ return (
15
+ <aside className={styles.sidebar}>
16
+ <div className={styles.logo}>
17
+ <span className={styles.logoIcon}>⚡</span>
18
+ <span className={styles.logoText}>FCM</span>
19
+ </div>
20
+ <nav className={styles.nav}>
21
+ {NAV_ITEMS.map(({ id, icon, label }) => (
22
+ <button
23
+ key={id}
24
+ className={`${styles.navItem} ${currentView === id ? styles.active : ''}`}
25
+ onClick={() => onNavigate(id)}
26
+ title={label}
27
+ >
28
+ <span className={styles.navIcon}>{icon}</span>
29
+ <span className={styles.navLabel}>{label}</span>
30
+ </button>
31
+ ))}
32
+ </nav>
33
+ <div className={styles.bottom}>
34
+ <button className={styles.navItem} onClick={onToggleTheme} title="Toggle Theme">
35
+ <span className={styles.navIcon}>☽</span>
36
+ <span className={styles.navLabel}>Theme</span>
37
+ </button>
38
+ </div>
39
+ </aside>
40
+ )
41
+ }
@@ -0,0 +1,76 @@
1
+ .sidebar {
2
+ position: fixed;
3
+ left: 0; top: 0; bottom: 0;
4
+ width: 64px;
5
+ z-index: 200;
6
+ background: var(--color-bg-elevated);
7
+ border-right: 1px solid var(--color-border);
8
+ display: flex;
9
+ flex-direction: column;
10
+ padding: 12px 0;
11
+ transition: width 250ms cubic-bezier(0.16, 1, 0.3, 1);
12
+ overflow: hidden;
13
+ }
14
+ .sidebar:hover { width: 200px; }
15
+
16
+ .logo {
17
+ display: flex;
18
+ align-items: center;
19
+ gap: 10px;
20
+ padding: 8px 18px;
21
+ margin-bottom: 16px;
22
+ white-space: nowrap;
23
+ overflow: hidden;
24
+ }
25
+ .logoIcon { font-size: 24px; flex-shrink: 0; }
26
+ .logoText {
27
+ font-size: 16px;
28
+ font-weight: 800;
29
+ background: linear-gradient(135deg, var(--color-accent), #06b6d4);
30
+ -webkit-background-clip: text;
31
+ -webkit-text-fill-color: transparent;
32
+ background-clip: text;
33
+ opacity: 0;
34
+ transition: opacity 250ms cubic-bezier(0.16, 1, 0.3, 1);
35
+ }
36
+ .sidebar:hover .logoText { opacity: 1; }
37
+
38
+ .nav { flex: 1; display: flex; flex-direction: column; gap: 4px; }
39
+ .bottom { display: flex; flex-direction: column; gap: 4px; }
40
+
41
+ .navItem {
42
+ display: flex;
43
+ align-items: center;
44
+ gap: 12px;
45
+ padding: 10px 20px;
46
+ border: none;
47
+ background: none;
48
+ color: var(--color-text-muted);
49
+ cursor: pointer;
50
+ white-space: nowrap;
51
+ overflow: hidden;
52
+ font-family: var(--font-sans);
53
+ font-size: 13px;
54
+ font-weight: 500;
55
+ transition: all 150ms cubic-bezier(0.16, 1, 0.3, 1);
56
+ }
57
+ .navItem:hover {
58
+ color: var(--color-text);
59
+ background: var(--color-bg-hover);
60
+ }
61
+ .navIcon {
62
+ width: 20px;
63
+ text-align: center;
64
+ flex-shrink: 0;
65
+ font-size: 16px;
66
+ }
67
+ .navLabel {
68
+ opacity: 0;
69
+ transition: opacity 250ms cubic-bezier(0.16, 1, 0.3, 1);
70
+ }
71
+ .sidebar:hover .navLabel { opacity: 1; }
72
+ .active {
73
+ color: var(--color-accent) !important;
74
+ background: var(--color-accent-dim) !important;
75
+ border-right: 3px solid var(--color-accent);
76
+ }
@@ -0,0 +1,264 @@
1
+ /**
2
+ * @file web/src/components/settings/SettingsView.jsx
3
+ * @description Full settings page for managing API keys and provider configurations.
4
+ * 📖 Fetches config from /api/config, renders expandable provider cards with
5
+ * key display (masked/revealed), save/delete/toggle actions, and search filter.
6
+ * @functions SettingsView → main settings page component
7
+ */
8
+ import { useState, useEffect, useCallback } from 'react'
9
+ import styles from './SettingsView.module.css'
10
+ import { maskKey } from '../../utils/format.js'
11
+
12
+ export default function SettingsView({ onToast }) {
13
+ const [config, setConfig] = useState(null)
14
+ const [searchQuery, setSearchQuery] = useState('')
15
+ const [expandedCards, setExpandedCards] = useState(new Set())
16
+ const [revealedKeys, setRevealedKeys] = useState(new Set())
17
+ const [keyInputs, setKeyInputs] = useState({})
18
+
19
+ const loadConfig = useCallback(async () => {
20
+ try {
21
+ const resp = await fetch('/api/config')
22
+ const data = await resp.json()
23
+ setConfig(data)
24
+ } catch {
25
+ onToast?.('Failed to load settings', 'error')
26
+ }
27
+ }, [onToast])
28
+
29
+ useEffect(() => { loadConfig() }, [loadConfig])
30
+
31
+ const toggleCard = (key) => {
32
+ setExpandedCards((prev) => {
33
+ const next = new Set(prev)
34
+ if (next.has(key)) next.delete(key)
35
+ else next.add(key)
36
+ return next
37
+ })
38
+ }
39
+
40
+ const expandAll = () => {
41
+ if (!config) return
42
+ setExpandedCards(new Set(Object.keys(config.providers)))
43
+ }
44
+
45
+ const collapseAll = () => setExpandedCards(new Set())
46
+
47
+ const toggleRevealKey = async (key) => {
48
+ if (revealedKeys.has(key)) {
49
+ setRevealedKeys((prev) => { const n = new Set(prev); n.delete(key); return n })
50
+ return
51
+ }
52
+ try {
53
+ const resp = await fetch(`/api/key/${key}`)
54
+ const data = await resp.json()
55
+ if (data.key) {
56
+ setRevealedKeys((prev) => new Set(prev).add(key))
57
+ }
58
+ } catch {
59
+ onToast?.('Failed to reveal key', 'error')
60
+ }
61
+ }
62
+
63
+ const copyKey = async (key) => {
64
+ try {
65
+ const resp = await fetch(`/api/key/${key}`)
66
+ const data = await resp.json()
67
+ if (data.key) {
68
+ await navigator.clipboard.writeText(data.key)
69
+ onToast?.('API key copied to clipboard', 'success')
70
+ } else {
71
+ onToast?.('No key to copy', 'warning')
72
+ }
73
+ } catch {
74
+ onToast?.('Failed to copy key', 'error')
75
+ }
76
+ }
77
+
78
+ const saveKey = async (key) => {
79
+ const value = keyInputs[key]?.trim()
80
+ if (!value) {
81
+ onToast?.('Please enter an API key', 'warning')
82
+ return
83
+ }
84
+ try {
85
+ const resp = await fetch('/api/settings', {
86
+ method: 'POST',
87
+ headers: { 'Content-Type': 'application/json' },
88
+ body: JSON.stringify({ apiKeys: { [key]: value } }),
89
+ })
90
+ const result = await resp.json()
91
+ if (result.success) {
92
+ onToast?.(`API key for ${key} saved successfully!`, 'success')
93
+ setKeyInputs((prev) => ({ ...prev, [key]: '' }))
94
+ setRevealedKeys((prev) => { const n = new Set(prev); n.delete(key); return n })
95
+ await loadConfig()
96
+ setExpandedCards((prev) => new Set(prev).add(key))
97
+ } else {
98
+ onToast?.(result.error || 'Failed to save', 'error')
99
+ }
100
+ } catch {
101
+ onToast?.('Network error while saving', 'error')
102
+ }
103
+ }
104
+
105
+ const deleteKey = async (key) => {
106
+ if (!confirm(`Are you sure you want to delete the API key for "${key}"?`)) return
107
+ try {
108
+ const resp = await fetch('/api/settings', {
109
+ method: 'POST',
110
+ headers: { 'Content-Type': 'application/json' },
111
+ body: JSON.stringify({ apiKeys: { [key]: '' } }),
112
+ })
113
+ const result = await resp.json()
114
+ if (result.success) {
115
+ onToast?.(`API key for ${key} deleted`, 'info')
116
+ setRevealedKeys((prev) => { const n = new Set(prev); n.delete(key); return n })
117
+ await loadConfig()
118
+ } else {
119
+ onToast?.(result.error || 'Failed to delete', 'error')
120
+ }
121
+ } catch {
122
+ onToast?.('Network error while deleting', 'error')
123
+ }
124
+ }
125
+
126
+ const toggleProvider = async (key, enabled) => {
127
+ try {
128
+ const resp = await fetch('/api/settings', {
129
+ method: 'POST',
130
+ headers: { 'Content-Type': 'application/json' },
131
+ body: JSON.stringify({ providers: { [key]: { enabled } } }),
132
+ })
133
+ const result = await resp.json()
134
+ if (result.success) {
135
+ onToast?.(`${key} ${enabled ? 'enabled' : 'disabled'}`, 'success')
136
+ } else {
137
+ onToast?.(result.error || 'Failed to toggle', 'error')
138
+ }
139
+ } catch {
140
+ onToast?.('Network error', 'error')
141
+ }
142
+ }
143
+
144
+ if (!config) {
145
+ return (
146
+ <div className={styles.page}>
147
+ <div className={styles.loading}>Loading settings...</div>
148
+ </div>
149
+ )
150
+ }
151
+
152
+ const entries = Object.entries(config.providers)
153
+ .filter(([, p]) => {
154
+ if (!searchQuery) return true
155
+ const q = searchQuery.toLowerCase()
156
+ return p.name.toLowerCase().includes(q)
157
+ })
158
+ .sort((a, b) => a[1].name.localeCompare(b[1].name))
159
+
160
+ return (
161
+ <div className={styles.page}>
162
+ <div className={styles.pageHeader}>
163
+ <h1 className={styles.pageTitle}>⚙️ Provider Settings</h1>
164
+ <p className={styles.pageSubtitle}>
165
+ Manage your API keys and provider configurations. Keys are stored locally in{' '}
166
+ <code>~/.free-coding-models.json</code>
167
+ </p>
168
+ </div>
169
+
170
+ <div className={styles.toolbar}>
171
+ <div className={styles.toolbarSearch}>
172
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
173
+ <circle cx="11" cy="11" r="8" /><line x1="21" y1="21" x2="16.65" y2="16.65" />
174
+ </svg>
175
+ <input
176
+ type="text"
177
+ placeholder="Search providers..."
178
+ value={searchQuery}
179
+ onChange={(e) => setSearchQuery(e.target.value)}
180
+ autoComplete="off"
181
+ />
182
+ </div>
183
+ <div className={styles.toolbarActions}>
184
+ <button className={styles.toolbarBtn} onClick={expandAll}>Expand All</button>
185
+ <button className={styles.toolbarBtn} onClick={collapseAll}>Collapse All</button>
186
+ </div>
187
+ </div>
188
+
189
+ <div className={styles.providers}>
190
+ {entries.map(([key, p]) => {
191
+ const isExpanded = expandedCards.has(key)
192
+ const isRevealed = revealedKeys.has(key)
193
+
194
+ return (
195
+ <div key={key} className={`${styles.card} ${isExpanded ? styles.cardExpanded : ''}`}>
196
+ <div className={styles.cardHeader} onClick={() => toggleCard(key)}>
197
+ <div className={styles.cardIcon}>🔌</div>
198
+ <div className={styles.cardInfo}>
199
+ <div className={styles.cardName}>{p.name}</div>
200
+ <div className={styles.cardMeta}>{p.modelCount} models · {key}</div>
201
+ </div>
202
+ <span className={`${styles.cardStatus} ${p.hasKey ? styles.statusConfigured : styles.statusMissing}`}>
203
+ {p.hasKey ? '✅ Active' : '🔑 No Key'}
204
+ </span>
205
+ <span className={`${styles.toggleIcon} ${isExpanded ? styles.toggleIconExpanded : ''}`}>▼</span>
206
+ </div>
207
+
208
+ <div className={styles.cardBody}>
209
+ <div className={styles.cardContent}>
210
+ {p.hasKey && (
211
+ <div className={styles.keyGroup}>
212
+ <label className={styles.keyLabel}>Current API Key</label>
213
+ <div className={styles.keyDisplay}>
214
+ <span className={styles.keyDisplayValue}>
215
+ {isRevealed ? (p.maskedKey || '••••••••') : maskKey(p.maskedKey || '')}
216
+ </span>
217
+ <div className={styles.keyDisplayActions}>
218
+ <button className={styles.actionBtn} onClick={() => toggleRevealKey(key)} title={isRevealed ? 'Hide' : 'Reveal'}>
219
+ {isRevealed ? '🙈' : '👁️'}
220
+ </button>
221
+ <button className={styles.actionBtn} onClick={() => copyKey(key)} title="Copy">📋</button>
222
+ <button className={`${styles.actionBtn} ${styles.actionBtnDanger}`} onClick={() => deleteKey(key)} title="Delete Key">🗑️</button>
223
+ </div>
224
+ </div>
225
+ </div>
226
+ )}
227
+
228
+ <div className={styles.keyGroup}>
229
+ <label className={styles.keyLabel}>{p.hasKey ? 'Update API Key' : 'Add API Key'}</label>
230
+ <div className={styles.keyInputRow}>
231
+ <input
232
+ type="password"
233
+ className={styles.keyInput}
234
+ placeholder="Enter your API key..."
235
+ value={keyInputs[key] || ''}
236
+ onChange={(e) => setKeyInputs((prev) => ({ ...prev, [key]: e.target.value }))}
237
+ autoComplete="off"
238
+ />
239
+ <button className={styles.saveBtn} onClick={() => saveKey(key)}>
240
+ {p.hasKey ? 'Update' : 'Save'}
241
+ </button>
242
+ </div>
243
+ </div>
244
+
245
+ <div className={styles.enabledRow}>
246
+ <span className={styles.enabledLabel}>Provider Enabled</span>
247
+ <label className={styles.toggleSwitch}>
248
+ <input
249
+ type="checkbox"
250
+ defaultChecked={p.enabled !== false}
251
+ onChange={(e) => toggleProvider(key, e.target.checked)}
252
+ />
253
+ <span className={styles.toggleSlider} />
254
+ </label>
255
+ </div>
256
+ </div>
257
+ </div>
258
+ </div>
259
+ )
260
+ })}
261
+ </div>
262
+ </div>
263
+ )
264
+ }