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.
- package/CHANGELOG.md +10 -1798
- package/README.md +4 -1
- package/bin/free-coding-models.js +1 -1
- package/package.json +11 -2
- package/src/app.js +3 -0
- package/src/cli-help.js +2 -0
- package/src/command-palette.js +3 -0
- package/src/endpoint-installer.js +1 -1
- package/src/tool-bootstrap.js +34 -0
- package/src/tool-launchers.js +137 -1
- package/src/tool-metadata.js +9 -0
- package/src/utils.js +8 -2
- package/web/index.html +2 -300
- package/web/server.js +80 -15
- package/web/src/App.jsx +150 -0
- package/web/src/components/analytics/AnalyticsView.jsx +109 -0
- package/web/src/components/analytics/AnalyticsView.module.css +186 -0
- package/web/src/components/atoms/Sparkline.jsx +44 -0
- package/web/src/components/atoms/StabilityCell.jsx +18 -0
- package/web/src/components/atoms/StabilityCell.module.css +8 -0
- package/web/src/components/atoms/StatusDot.jsx +10 -0
- package/web/src/components/atoms/StatusDot.module.css +17 -0
- package/web/src/components/atoms/TierBadge.jsx +10 -0
- package/web/src/components/atoms/TierBadge.module.css +18 -0
- package/web/src/components/atoms/Toast.jsx +25 -0
- package/web/src/components/atoms/Toast.module.css +35 -0
- package/web/src/components/atoms/ToastContainer.jsx +16 -0
- package/web/src/components/atoms/ToastContainer.module.css +10 -0
- package/web/src/components/atoms/VerdictBadge.jsx +13 -0
- package/web/src/components/atoms/VerdictBadge.module.css +19 -0
- package/web/src/components/dashboard/DetailPanel.jsx +131 -0
- package/web/src/components/dashboard/DetailPanel.module.css +99 -0
- package/web/src/components/dashboard/ExportModal.jsx +79 -0
- package/web/src/components/dashboard/ExportModal.module.css +99 -0
- package/web/src/components/dashboard/FilterBar.jsx +73 -0
- package/web/src/components/dashboard/FilterBar.module.css +43 -0
- package/web/src/components/dashboard/ModelTable.jsx +86 -0
- package/web/src/components/dashboard/ModelTable.module.css +46 -0
- package/web/src/components/dashboard/StatsBar.jsx +40 -0
- package/web/src/components/dashboard/StatsBar.module.css +28 -0
- package/web/src/components/layout/Footer.jsx +19 -0
- package/web/src/components/layout/Footer.module.css +10 -0
- package/web/src/components/layout/Header.jsx +38 -0
- package/web/src/components/layout/Header.module.css +73 -0
- package/web/src/components/layout/Sidebar.jsx +41 -0
- package/web/src/components/layout/Sidebar.module.css +76 -0
- package/web/src/components/settings/SettingsView.jsx +264 -0
- package/web/src/components/settings/SettingsView.module.css +377 -0
- package/web/src/global.css +199 -0
- package/web/src/hooks/useFilter.js +83 -0
- package/web/src/hooks/useSSE.js +49 -0
- package/web/src/hooks/useTheme.js +27 -0
- package/web/src/main.jsx +15 -0
- package/web/src/utils/download.js +15 -0
- package/web/src/utils/format.js +42 -0
- package/web/src/utils/ranks.js +37 -0
- /package/web/{app.js → app.legacy.js} +0 -0
- /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
|
+
}
|