free-coding-models 0.5.6 → 0.5.8
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 +14 -9
- package/changelog/v0.5.7.md +20 -0
- package/package.json +1 -1
- package/src/core/endpoint-installer.js +3 -3
- package/src/core/tool-launchers.js +2 -2
- package/web/dist/assets/index-DKmmiDip.css +1 -0
- package/web/dist/assets/index-DzVt42Nf.js +39 -0
- package/web/dist/index.html +2 -2
- package/web/server.js +386 -1
- package/web/src/App.jsx +145 -26
- package/web/src/components/analytics/AnalyticsView.jsx +4 -0
- package/web/src/components/analytics/TokenUsagePanel.jsx +105 -0
- package/web/src/components/analytics/TokenUsagePanel.module.css +126 -0
- package/web/src/components/atoms/TierBadge.module.css +3 -3
- package/web/src/components/dashboard/DetailPanel.jsx +29 -4
- package/web/src/components/dashboard/DetailPanel.module.css +26 -0
- package/web/src/components/dashboard/FilterBar.jsx +17 -2
- package/web/src/components/dashboard/FilterBar.module.css +17 -0
- package/web/src/components/dashboard/ModelTable.jsx +17 -5
- package/web/src/components/dashboard/ModelTable.module.css +14 -1
- package/web/src/components/help/HelpView.jsx +1 -1
- package/web/src/components/install/InstallEndpointsView.jsx +278 -0
- package/web/src/components/install/InstallEndpointsView.module.css +351 -0
- package/web/src/components/installed/InstalledModelsView.jsx +89 -0
- package/web/src/components/installed/InstalledModelsView.module.css +200 -0
- package/web/src/components/launch/IncompatibleFallbackModal.jsx +69 -0
- package/web/src/components/launch/LaunchButton.jsx +30 -0
- package/web/src/components/launch/LaunchButton.module.css +35 -0
- package/web/src/components/launch/LaunchModal.module.css +125 -0
- package/web/src/components/layout/Header.jsx +70 -11
- package/web/src/components/layout/Header.module.css +62 -2
- package/web/src/components/recommend/RecommendView.jsx +121 -0
- package/web/src/components/recommend/RecommendView.module.css +55 -0
- package/web/src/components/router/RouterView.jsx +286 -0
- package/web/src/components/router/RouterView.module.css +357 -0
- package/web/src/components/tools/ToolPicker.jsx +86 -0
- package/web/src/components/tools/ToolPicker.module.css +84 -0
- package/web/src/global.css +23 -0
- package/web/src/hooks/urlState.constants.js +3 -0
- package/web/src/hooks/useInstalledModels.js +42 -0
- package/web/src/hooks/useRecommend.js +67 -0
- package/web/src/hooks/useRouterDashboard.js +133 -0
- package/web/src/hooks/useTokenUsage.js +103 -0
- package/web/src/hooks/useToolMode.js +77 -0
- package/web/src/hooks/useUrlState.js +4 -3
- package/web/src/utils/m3.js +55 -0
- package/web/dist/assets/index-Blp9QJev.js +0 -39
- package/web/dist/assets/index-Cz_aCLTR.css +0 -1
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file web/src/components/launch/LaunchButton.jsx
|
|
3
|
+
* @description Reusable M3 endpoint install action for table rows, DetailPanel, and Recommend results.
|
|
4
|
+
* @functions LaunchButton → renders a plug button that installs the selected model endpoint
|
|
5
|
+
* @exports LaunchButton
|
|
6
|
+
*/
|
|
7
|
+
import { IconPlugConnected } from '@tabler/icons-react'
|
|
8
|
+
import { getToolMeta } from '../../../../src/core/tool-metadata.js'
|
|
9
|
+
import styles from './LaunchButton.module.css'
|
|
10
|
+
|
|
11
|
+
export default function LaunchButton({ model, toolMode = 'opencode', onLaunch, variant = 'default', disabled = false }) {
|
|
12
|
+
const meta = getToolMeta(toolMode)
|
|
13
|
+
const label = variant === 'icon' ? 'Install endpoint' : `Install endpoint in ${meta.emoji} ${meta.label}`
|
|
14
|
+
return (
|
|
15
|
+
<button
|
|
16
|
+
type="button"
|
|
17
|
+
className={`${styles.button} ${styles[variant] || ''}`}
|
|
18
|
+
disabled={disabled || !model}
|
|
19
|
+
onClick={(event) => {
|
|
20
|
+
event.stopPropagation()
|
|
21
|
+
if (model) onLaunch?.(model)
|
|
22
|
+
}}
|
|
23
|
+
title={model ? `Install ${model.label} endpoint in ${meta.label}` : 'Select a model first'}
|
|
24
|
+
aria-label={model ? `Install ${model.label} endpoint in ${meta.label}` : 'Install selected model endpoint'}
|
|
25
|
+
>
|
|
26
|
+
<IconPlugConnected size={variant === 'icon' ? 13 : 14} stroke={1.8} />
|
|
27
|
+
{variant !== 'icon' && <span>{label}</span>}
|
|
28
|
+
</button>
|
|
29
|
+
)
|
|
30
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/** @file web/src/components/launch/LaunchButton.module.css */
|
|
2
|
+
.button {
|
|
3
|
+
display: inline-flex;
|
|
4
|
+
align-items: center;
|
|
5
|
+
justify-content: center;
|
|
6
|
+
gap: 7px;
|
|
7
|
+
padding: 8px 12px;
|
|
8
|
+
border: 1px solid rgba(61, 220, 132, 0.45);
|
|
9
|
+
border-radius: 6px;
|
|
10
|
+
background: rgba(61, 220, 132, 0.12);
|
|
11
|
+
color: #3ddc84;
|
|
12
|
+
cursor: pointer;
|
|
13
|
+
font-size: 12px;
|
|
14
|
+
font-weight: 800;
|
|
15
|
+
font-family: var(--font-mono);
|
|
16
|
+
transition: transform 120ms, background 120ms, border-color 120ms;
|
|
17
|
+
}
|
|
18
|
+
.button:hover:not(:disabled) {
|
|
19
|
+
transform: translateY(-1px);
|
|
20
|
+
background: rgba(61, 220, 132, 0.20);
|
|
21
|
+
border-color: rgba(61, 220, 132, 0.75);
|
|
22
|
+
}
|
|
23
|
+
.button:disabled { opacity: 0.45; cursor: not-allowed; }
|
|
24
|
+
.icon {
|
|
25
|
+
width: 26px;
|
|
26
|
+
height: 24px;
|
|
27
|
+
padding: 0;
|
|
28
|
+
opacity: 0.72;
|
|
29
|
+
}
|
|
30
|
+
.icon:hover:not(:disabled) { opacity: 1; }
|
|
31
|
+
:global([data-theme="light"]) .button {
|
|
32
|
+
color: #087d42;
|
|
33
|
+
border-color: rgba(8, 125, 66, 0.35);
|
|
34
|
+
background: rgba(8, 125, 66, 0.08);
|
|
35
|
+
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
/** @file web/src/components/launch/LaunchModal.module.css */
|
|
2
|
+
.backdrop {
|
|
3
|
+
position: fixed;
|
|
4
|
+
inset: 0;
|
|
5
|
+
z-index: 500;
|
|
6
|
+
display: flex;
|
|
7
|
+
align-items: center;
|
|
8
|
+
justify-content: center;
|
|
9
|
+
padding: 24px;
|
|
10
|
+
background: rgba(0, 0, 0, 0.62);
|
|
11
|
+
backdrop-filter: blur(4px);
|
|
12
|
+
}
|
|
13
|
+
.modal,
|
|
14
|
+
.modalWide {
|
|
15
|
+
width: min(560px, 96vw);
|
|
16
|
+
max-height: min(760px, 92vh);
|
|
17
|
+
overflow: auto;
|
|
18
|
+
border: 1px solid var(--color-border);
|
|
19
|
+
border-radius: 14px;
|
|
20
|
+
background: var(--color-bg-elevated);
|
|
21
|
+
color: var(--color-text);
|
|
22
|
+
box-shadow: 0 18px 48px rgba(0, 0, 0, 0.45);
|
|
23
|
+
}
|
|
24
|
+
.modalWide { width: min(920px, 96vw); }
|
|
25
|
+
.header {
|
|
26
|
+
display: flex;
|
|
27
|
+
align-items: flex-start;
|
|
28
|
+
justify-content: space-between;
|
|
29
|
+
gap: 18px;
|
|
30
|
+
padding: 20px 22px;
|
|
31
|
+
border-bottom: 1px solid var(--color-border);
|
|
32
|
+
}
|
|
33
|
+
.header h2 {
|
|
34
|
+
display: flex;
|
|
35
|
+
align-items: center;
|
|
36
|
+
gap: 8px;
|
|
37
|
+
margin: 4px 0 0;
|
|
38
|
+
font-size: 18px;
|
|
39
|
+
}
|
|
40
|
+
.eyebrow {
|
|
41
|
+
margin: 0;
|
|
42
|
+
color: var(--color-accent);
|
|
43
|
+
font-size: 11px;
|
|
44
|
+
font-weight: 900;
|
|
45
|
+
text-transform: uppercase;
|
|
46
|
+
letter-spacing: .9px;
|
|
47
|
+
}
|
|
48
|
+
.close {
|
|
49
|
+
display: inline-flex;
|
|
50
|
+
align-items: center;
|
|
51
|
+
justify-content: center;
|
|
52
|
+
width: 30px;
|
|
53
|
+
height: 30px;
|
|
54
|
+
border: 1px solid var(--color-border);
|
|
55
|
+
border-radius: 7px;
|
|
56
|
+
background: var(--color-surface);
|
|
57
|
+
color: var(--color-text-muted);
|
|
58
|
+
cursor: pointer;
|
|
59
|
+
}
|
|
60
|
+
.close:hover { color: var(--color-text); background: var(--color-bg-hover); }
|
|
61
|
+
.body { padding: 20px 22px; display: flex; flex-direction: column; gap: 12px; }
|
|
62
|
+
.command {
|
|
63
|
+
display: block;
|
|
64
|
+
padding: 12px;
|
|
65
|
+
border: 1px solid var(--color-border);
|
|
66
|
+
border-radius: 8px;
|
|
67
|
+
background: var(--color-surface);
|
|
68
|
+
color: var(--color-accent);
|
|
69
|
+
white-space: pre-wrap;
|
|
70
|
+
word-break: break-word;
|
|
71
|
+
}
|
|
72
|
+
.note,
|
|
73
|
+
.muted { color: var(--color-text-muted); font-size: 13px; line-height: 1.45; }
|
|
74
|
+
.docs { display: inline-flex; align-items: center; gap: 5px; color: var(--color-accent); font-weight: 700; text-decoration: none; }
|
|
75
|
+
.error { color: #ff5a7a; font-weight: 700; }
|
|
76
|
+
.footer {
|
|
77
|
+
display: flex;
|
|
78
|
+
justify-content: flex-end;
|
|
79
|
+
gap: 10px;
|
|
80
|
+
padding: 16px 22px 20px;
|
|
81
|
+
border-top: 1px solid var(--color-border);
|
|
82
|
+
}
|
|
83
|
+
.primary,
|
|
84
|
+
.secondary,
|
|
85
|
+
.option {
|
|
86
|
+
border: 1px solid var(--color-border);
|
|
87
|
+
border-radius: 8px;
|
|
88
|
+
cursor: pointer;
|
|
89
|
+
font-weight: 800;
|
|
90
|
+
}
|
|
91
|
+
.primary {
|
|
92
|
+
padding: 9px 14px;
|
|
93
|
+
background: var(--color-accent);
|
|
94
|
+
color: var(--color-bg);
|
|
95
|
+
border-color: var(--color-accent);
|
|
96
|
+
}
|
|
97
|
+
.primary:disabled { opacity: .55; cursor: not-allowed; }
|
|
98
|
+
.secondary {
|
|
99
|
+
padding: 9px 14px;
|
|
100
|
+
background: var(--color-surface);
|
|
101
|
+
color: var(--color-text);
|
|
102
|
+
}
|
|
103
|
+
.grid { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; padding: 18px; }
|
|
104
|
+
.card {
|
|
105
|
+
padding: 16px;
|
|
106
|
+
border: 1px solid var(--color-border);
|
|
107
|
+
border-radius: 12px;
|
|
108
|
+
background: var(--color-surface);
|
|
109
|
+
}
|
|
110
|
+
.card h3 { display: flex; align-items: center; gap: 7px; margin: 0 0 6px; font-size: 14px; }
|
|
111
|
+
.stack { display: flex; flex-direction: column; gap: 8px; margin-top: 12px; }
|
|
112
|
+
.option {
|
|
113
|
+
width: 100%;
|
|
114
|
+
display: grid;
|
|
115
|
+
grid-template-columns: auto 1fr auto;
|
|
116
|
+
align-items: center;
|
|
117
|
+
gap: 10px;
|
|
118
|
+
padding: 10px;
|
|
119
|
+
background: var(--color-bg-card);
|
|
120
|
+
color: var(--color-text);
|
|
121
|
+
text-align: left;
|
|
122
|
+
}
|
|
123
|
+
.option:hover { border-color: var(--color-accent); background: var(--color-bg-hover); }
|
|
124
|
+
.option small { color: var(--color-text-dim); font-family: var(--font-mono); font-size: 10px; }
|
|
125
|
+
@media (max-width: 720px) { .grid { grid-template-columns: 1fr; } }
|
|
@@ -13,8 +13,9 @@ import {
|
|
|
13
13
|
IconBolt, IconSearch, IconDownload, IconSettings, IconMoon, IconSun,
|
|
14
14
|
IconPlayerPlay, IconCommand, IconLayoutDashboard, IconActivity,
|
|
15
15
|
IconSparkles, IconRoute, IconDots, IconQuestionMark, IconHistory,
|
|
16
|
-
IconPlug, IconFolders,
|
|
16
|
+
IconPlug, IconFolders, IconMenu2,
|
|
17
17
|
} from '@tabler/icons-react'
|
|
18
|
+
import ToolPicker from '../tools/ToolPicker.jsx'
|
|
18
19
|
import styles from './Header.module.css'
|
|
19
20
|
|
|
20
21
|
// 📖 Top-level nav items — always visible as buttons. Inlined here so the
|
|
@@ -24,8 +25,8 @@ const NAV_ITEMS = [
|
|
|
24
25
|
{ id: 'dashboard', label: 'Dashboard', icon: IconLayoutDashboard },
|
|
25
26
|
{ id: 'settings', label: 'Settings', icon: IconSettings },
|
|
26
27
|
{ id: 'analytics', label: 'Analytics', icon: IconActivity },
|
|
27
|
-
{ id: 'recommend', label: 'Recommend', icon: IconSparkles
|
|
28
|
-
{ id: 'router', label: 'Router', icon: IconRoute
|
|
28
|
+
{ id: 'recommend', label: 'Recommend', icon: IconSparkles },
|
|
29
|
+
{ id: 'router', label: 'Router', icon: IconRoute },
|
|
29
30
|
]
|
|
30
31
|
|
|
31
32
|
// 📖 Overflow menu items — Help + Changelog shipped in M2; Install Endpoints
|
|
@@ -33,8 +34,8 @@ const NAV_ITEMS = [
|
|
|
33
34
|
const MENU_ITEMS = [
|
|
34
35
|
{ id: 'help', label: 'Help', icon: IconQuestionMark },
|
|
35
36
|
{ id: 'changelog', label: 'Changelog', icon: IconHistory },
|
|
36
|
-
{ id: 'install-endpoints', label: 'Install Endpoints', icon: IconPlug
|
|
37
|
-
{ id: 'installed-models', label: 'Installed Models', icon: IconFolders
|
|
37
|
+
{ id: 'install-endpoints', label: 'Install Endpoints', icon: IconPlug },
|
|
38
|
+
{ id: 'installed-models', label: 'Installed Models', icon: IconFolders },
|
|
38
39
|
]
|
|
39
40
|
|
|
40
41
|
export default function Header({
|
|
@@ -43,25 +44,29 @@ export default function Header({
|
|
|
43
44
|
onToggleTheme, onOpenExport, onOpenCommandPalette,
|
|
44
45
|
onBenchmark, benchmarkRunning, benchmarkTotal, benchmarkCompleted,
|
|
45
46
|
modelsCount, theme, onToast,
|
|
47
|
+
toolMode = 'opencode', onSetToolMode, onCycleToolMode,
|
|
46
48
|
updateSlot = null,
|
|
47
49
|
}) {
|
|
48
50
|
const [menuOpen, setMenuOpen] = useState(false)
|
|
51
|
+
const [mobileNavOpen, setMobileNavOpen] = useState(false)
|
|
49
52
|
const menuRef = useRef(null)
|
|
50
53
|
|
|
51
54
|
// 📖 Close the overflow menu on outside click or Esc.
|
|
52
55
|
useEffect(() => {
|
|
53
|
-
if (!menuOpen) return
|
|
56
|
+
if (!menuOpen && !mobileNavOpen) return
|
|
54
57
|
const onClick = (e) => {
|
|
55
58
|
if (menuRef.current && !menuRef.current.contains(e.target)) setMenuOpen(false)
|
|
56
59
|
}
|
|
57
|
-
const onKey = (e) => {
|
|
60
|
+
const onKey = (e) => {
|
|
61
|
+
if (e.key === 'Escape') { setMenuOpen(false); setMobileNavOpen(false) }
|
|
62
|
+
}
|
|
58
63
|
document.addEventListener('mousedown', onClick)
|
|
59
64
|
document.addEventListener('keydown', onKey)
|
|
60
65
|
return () => {
|
|
61
66
|
document.removeEventListener('mousedown', onClick)
|
|
62
67
|
document.removeEventListener('keydown', onKey)
|
|
63
68
|
}
|
|
64
|
-
}, [menuOpen])
|
|
69
|
+
}, [menuOpen, mobileNavOpen])
|
|
65
70
|
|
|
66
71
|
const handleNavClick = (item) => {
|
|
67
72
|
if (item.comingIn) {
|
|
@@ -69,6 +74,7 @@ export default function Header({
|
|
|
69
74
|
return
|
|
70
75
|
}
|
|
71
76
|
onNavigate(item.id)
|
|
77
|
+
setMobileNavOpen(false)
|
|
72
78
|
}
|
|
73
79
|
|
|
74
80
|
const handleMenuClick = (item) => {
|
|
@@ -93,6 +99,51 @@ export default function Header({
|
|
|
93
99
|
</div>
|
|
94
100
|
<span className={styles.version}>v{__APP_VERSION__}</span>
|
|
95
101
|
|
|
102
|
+
{/* 📖 M5: Hamburger for narrow viewports (never a sidebar). Shows a
|
|
103
|
+
dropdown with all nav + overflow items on mobile. */}
|
|
104
|
+
<button
|
|
105
|
+
className={styles.hamburgerBtn}
|
|
106
|
+
onClick={() => setMobileNavOpen((o) => !o)}
|
|
107
|
+
aria-label="Navigation menu"
|
|
108
|
+
aria-expanded={mobileNavOpen}
|
|
109
|
+
aria-haspopup="true"
|
|
110
|
+
>
|
|
111
|
+
<IconMenu2 size={18} stroke={1.5} />
|
|
112
|
+
</button>
|
|
113
|
+
{mobileNavOpen && (
|
|
114
|
+
<div className={styles.mobileNav} role="menu">
|
|
115
|
+
{NAV_ITEMS.map((item) => {
|
|
116
|
+
const Icon = item.icon
|
|
117
|
+
return (
|
|
118
|
+
<button
|
|
119
|
+
key={item.id}
|
|
120
|
+
className={`${styles.mobileNavItem} ${currentView === item.id ? styles.mobileNavActive : ''}`}
|
|
121
|
+
onClick={() => handleNavClick(item)}
|
|
122
|
+
role="menuitem"
|
|
123
|
+
>
|
|
124
|
+
<Icon size={16} stroke={1.5} />
|
|
125
|
+
<span>{item.label}</span>
|
|
126
|
+
</button>
|
|
127
|
+
)
|
|
128
|
+
})}
|
|
129
|
+
<div className={styles.mobileNavDivider} />
|
|
130
|
+
{MENU_ITEMS.map((item) => {
|
|
131
|
+
const Icon = item.icon
|
|
132
|
+
return (
|
|
133
|
+
<button
|
|
134
|
+
key={item.id}
|
|
135
|
+
className={styles.mobileNavItem}
|
|
136
|
+
onClick={() => handleMenuClick(item)}
|
|
137
|
+
role="menuitem"
|
|
138
|
+
>
|
|
139
|
+
<Icon size={16} stroke={1.5} />
|
|
140
|
+
<span>{item.label}</span>
|
|
141
|
+
</button>
|
|
142
|
+
)
|
|
143
|
+
})}
|
|
144
|
+
</div>
|
|
145
|
+
)}
|
|
146
|
+
|
|
96
147
|
{/* Always-visible primary nav (replaces the old left sidebar) */}
|
|
97
148
|
<nav className={styles.nav} aria-label="Primary">
|
|
98
149
|
{NAV_ITEMS.map((item) => {
|
|
@@ -119,6 +170,7 @@ export default function Header({
|
|
|
119
170
|
className={`${styles.navBtn} ${styles.menuTrigger}`}
|
|
120
171
|
onClick={() => setMenuOpen((o) => !o)}
|
|
121
172
|
title="More features"
|
|
173
|
+
aria-label="More features"
|
|
122
174
|
aria-haspopup="true"
|
|
123
175
|
aria-expanded={menuOpen}
|
|
124
176
|
>
|
|
@@ -162,12 +214,18 @@ export default function Header({
|
|
|
162
214
|
</div>
|
|
163
215
|
|
|
164
216
|
<div className={styles.right}>
|
|
217
|
+
<ToolPicker
|
|
218
|
+
toolMode={toolMode}
|
|
219
|
+
onSetToolMode={onSetToolMode}
|
|
220
|
+
onCycleToolMode={onCycleToolMode}
|
|
221
|
+
/>
|
|
222
|
+
|
|
165
223
|
{/* ⌘K — the only global keyboard shortcut, opens the command palette */}
|
|
166
224
|
<button
|
|
167
225
|
className={styles.cmdkBtn}
|
|
168
226
|
onClick={onOpenCommandPalette}
|
|
169
227
|
title="Command palette (⌘K / Ctrl+P)"
|
|
170
|
-
aria-label="Open command palette"
|
|
228
|
+
aria-label="Open command palette (⌘K)"
|
|
171
229
|
>
|
|
172
230
|
<IconCommand size={14} stroke={1.5} />
|
|
173
231
|
<span className={styles.cmdkLabel}>⌘K</span>
|
|
@@ -178,6 +236,7 @@ export default function Header({
|
|
|
178
236
|
onClick={onBenchmark}
|
|
179
237
|
disabled={benchmarkRunning}
|
|
180
238
|
title={benchmarkRunning ? `AI Speed Test running — ${benchmarkCompleted}/${benchmarkTotal}` : `Run AI Latency benchmark on ${modelsCount} visible models`}
|
|
239
|
+
aria-label={benchmarkRunning ? `AI Speed Test running — ${benchmarkCompleted} of ${benchmarkTotal} complete` : 'AI Latency benchmark'}
|
|
181
240
|
>
|
|
182
241
|
<IconPlayerPlay size={14} stroke={1.5} />
|
|
183
242
|
{benchmarkRunning ? (
|
|
@@ -193,10 +252,10 @@ export default function Header({
|
|
|
193
252
|
{/* 📖 M2: update chip slot. Hidden when no update is available. */}
|
|
194
253
|
{updateSlot}
|
|
195
254
|
|
|
196
|
-
<button className={styles.iconBtn} onClick={onToggleTheme} title={`Theme: ${theme} (click to cycle auto / dark / light)`}>
|
|
255
|
+
<button className={styles.iconBtn} onClick={onToggleTheme} title={`Theme: ${theme} (click to cycle auto / dark / light)`} aria-label={`Theme: ${theme}. Click to cycle.`}>
|
|
197
256
|
{theme === 'light' ? <IconMoon size={16} stroke={1.5} /> : <IconSun size={16} stroke={1.5} />}
|
|
198
257
|
</button>
|
|
199
|
-
<button className={styles.iconBtn} onClick={onOpenExport} title="Export Data">
|
|
258
|
+
<button className={styles.iconBtn} onClick={onOpenExport} title="Export Data" aria-label="Export model data">
|
|
200
259
|
<IconDownload size={16} stroke={1.5} />
|
|
201
260
|
</button>
|
|
202
261
|
</div>
|
|
@@ -64,7 +64,7 @@
|
|
|
64
64
|
.logoTextHighlight { color: var(--color-brand); }
|
|
65
65
|
.version {
|
|
66
66
|
font-size: 10px; font-weight: 500; font-family: var(--font-mono);
|
|
67
|
-
color: var(--color-text-
|
|
67
|
+
color: var(--color-text-muted); background: var(--color-surface);
|
|
68
68
|
padding: 2px 6px; border-radius: 999px; border: 1px solid var(--color-border);
|
|
69
69
|
}
|
|
70
70
|
|
|
@@ -264,8 +264,68 @@
|
|
|
264
264
|
.logoText { display: none; }
|
|
265
265
|
.cmdkLabel { display: none; }
|
|
266
266
|
.benchmarkBtn > span:not(.benchmarkRunning) { display: none; }
|
|
267
|
+
.nav { display: none; }
|
|
268
|
+
.hamburgerBtn { display: flex; }
|
|
267
269
|
}
|
|
268
270
|
@media (max-width: 560px) {
|
|
269
271
|
.version { display: none; }
|
|
270
|
-
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/* ─── Hamburger + mobile nav (M5) ─── */
|
|
275
|
+
.hamburgerBtn {
|
|
276
|
+
display: none;
|
|
277
|
+
align-items: center;
|
|
278
|
+
justify-content: center;
|
|
279
|
+
width: 34px;
|
|
280
|
+
height: 34px;
|
|
281
|
+
background: var(--color-surface);
|
|
282
|
+
border: 1px solid var(--color-border);
|
|
283
|
+
border-radius: var(--radius-md);
|
|
284
|
+
color: var(--color-text-muted);
|
|
285
|
+
cursor: pointer;
|
|
286
|
+
margin-left: 8px;
|
|
287
|
+
&:hover { color: var(--color-text); background: var(--color-bg-hover); }
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
.mobileNav {
|
|
291
|
+
position: absolute;
|
|
292
|
+
top: var(--header-h, 60px);
|
|
293
|
+
left: 0;
|
|
294
|
+
right: 0;
|
|
295
|
+
background: var(--color-bg-elevated);
|
|
296
|
+
border-bottom: 1px solid var(--color-border);
|
|
297
|
+
padding: 8px 16px 16px;
|
|
298
|
+
display: flex;
|
|
299
|
+
flex-direction: column;
|
|
300
|
+
gap: 2px;
|
|
301
|
+
z-index: 150;
|
|
302
|
+
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.3);
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
.mobileNavItem {
|
|
306
|
+
display: flex;
|
|
307
|
+
align-items: center;
|
|
308
|
+
gap: 10px;
|
|
309
|
+
padding: 10px 12px;
|
|
310
|
+
font-size: 13px;
|
|
311
|
+
font-weight: 600;
|
|
312
|
+
color: var(--color-text-muted);
|
|
313
|
+
background: transparent;
|
|
314
|
+
border: none;
|
|
315
|
+
border-radius: var(--radius-md);
|
|
316
|
+
cursor: pointer;
|
|
317
|
+
text-align: left;
|
|
318
|
+
width: 100%;
|
|
319
|
+
&:hover { background: var(--color-bg-hover); color: var(--color-text); }
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
.mobileNavActive {
|
|
323
|
+
color: var(--color-accent);
|
|
324
|
+
background: var(--color-accent-dim);
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
.mobileNavDivider {
|
|
328
|
+
height: 1px;
|
|
329
|
+
background: var(--color-border);
|
|
330
|
+
margin: 6px 0;
|
|
271
331
|
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file web/src/components/recommend/RecommendView.jsx
|
|
3
|
+
* @description Smart Recommend modal for Web M3 parity: 3-question wizard,
|
|
4
|
+
* 10-second analysis phase, then Top 3 results from the shared scoring engine.
|
|
5
|
+
*
|
|
6
|
+
* @functions RecommendView → full-screen recommendation wizard
|
|
7
|
+
* @exports RecommendView
|
|
8
|
+
*/
|
|
9
|
+
import { useMemo, useState } from 'react'
|
|
10
|
+
import { IconArrowLeft, IconSparkles, IconX } from '@tabler/icons-react'
|
|
11
|
+
import { TASK_TYPES, PRIORITY_TYPES, CONTEXT_BUDGETS } from '../../../../src/core/utils.js'
|
|
12
|
+
import LaunchButton from '../launch/LaunchButton.jsx'
|
|
13
|
+
import { useRecommend } from '../../hooks/useRecommend.js'
|
|
14
|
+
import styles from './RecommendView.module.css'
|
|
15
|
+
|
|
16
|
+
const QUESTIONS = [
|
|
17
|
+
{ key: 'taskType', title: 'What are you working on?', options: TASK_TYPES },
|
|
18
|
+
{ key: 'priority', title: 'What matters most?', options: PRIORITY_TYPES },
|
|
19
|
+
{ key: 'contextBudget', title: 'How big is your context?', options: CONTEXT_BUDGETS },
|
|
20
|
+
]
|
|
21
|
+
|
|
22
|
+
export default function RecommendView({ onClose, onLaunch, onPinAndLaunch, toolMode, onToast }) {
|
|
23
|
+
const [step, setStep] = useState(0)
|
|
24
|
+
const [answers, setAnswers] = useState({ taskType: null, priority: null, contextBudget: null })
|
|
25
|
+
const { recommend, loading, progress, results, error, reset } = useRecommend({ onToast })
|
|
26
|
+
const question = QUESTIONS[step]
|
|
27
|
+
const complete = results.length > 0
|
|
28
|
+
|
|
29
|
+
const summary = useMemo(() => QUESTIONS
|
|
30
|
+
.map((q) => q.options[answers[q.key]]?.label)
|
|
31
|
+
.filter(Boolean)
|
|
32
|
+
.join(' · '), [answers])
|
|
33
|
+
|
|
34
|
+
async function choose(value) {
|
|
35
|
+
const nextAnswers = { ...answers, [question.key]: value }
|
|
36
|
+
setAnswers(nextAnswers)
|
|
37
|
+
if (step < QUESTIONS.length - 1) {
|
|
38
|
+
setStep(step + 1)
|
|
39
|
+
return
|
|
40
|
+
}
|
|
41
|
+
await recommend(nextAnswers)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function back() {
|
|
45
|
+
if (complete || loading) {
|
|
46
|
+
reset()
|
|
47
|
+
setStep(0)
|
|
48
|
+
setAnswers({ taskType: null, priority: null, contextBudget: null })
|
|
49
|
+
return
|
|
50
|
+
}
|
|
51
|
+
setStep((value) => Math.max(0, value - 1))
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return (
|
|
55
|
+
<div className={styles.backdrop} onClick={onClose}>
|
|
56
|
+
<div className={styles.modal} onClick={(event) => event.stopPropagation()}>
|
|
57
|
+
<header className={styles.header}>
|
|
58
|
+
<div>
|
|
59
|
+
<p className={styles.eyebrow}>Smart Recommend</p>
|
|
60
|
+
<h2><IconSparkles size={20} /> Find the best model for this task</h2>
|
|
61
|
+
{summary && <p className={styles.summary}>{summary}</p>}
|
|
62
|
+
</div>
|
|
63
|
+
<button className={styles.close} onClick={onClose} aria-label="Close"><IconX size={18} /></button>
|
|
64
|
+
</header>
|
|
65
|
+
|
|
66
|
+
{!loading && !complete && (
|
|
67
|
+
<main className={styles.body}>
|
|
68
|
+
<div className={styles.steps}>Question {step + 1} / {QUESTIONS.length}</div>
|
|
69
|
+
<h3>{question.title}</h3>
|
|
70
|
+
<div className={styles.options}>
|
|
71
|
+
{Object.entries(question.options).map(([key, option]) => (
|
|
72
|
+
<button key={key} className={styles.option} onClick={() => choose(key)}>
|
|
73
|
+
<strong>{option.label}</strong>
|
|
74
|
+
<span>{key}</span>
|
|
75
|
+
</button>
|
|
76
|
+
))}
|
|
77
|
+
</div>
|
|
78
|
+
</main>
|
|
79
|
+
)}
|
|
80
|
+
|
|
81
|
+
{loading && (
|
|
82
|
+
<main className={styles.body}>
|
|
83
|
+
<h3>Analyzing live catalog…</h3>
|
|
84
|
+
<p className={styles.muted}>10 seconds · roughly 2 checks/sec · same scoring as the TUI.</p>
|
|
85
|
+
<div className={styles.progress}><span style={{ width: `${progress}%` }} /></div>
|
|
86
|
+
<p className={styles.percent}>{progress}%</p>
|
|
87
|
+
</main>
|
|
88
|
+
)}
|
|
89
|
+
|
|
90
|
+
{complete && (
|
|
91
|
+
<main className={styles.body}>
|
|
92
|
+
<h3>Top 3 recommendations</h3>
|
|
93
|
+
<div className={styles.results}>
|
|
94
|
+
{results.map((item, index) => (
|
|
95
|
+
<article key={`${item.result.providerKey}/${item.result.modelId}`} className={styles.resultCard}>
|
|
96
|
+
<div className={styles.medal}>{['🥇', '🥈', '🥉'][index]}</div>
|
|
97
|
+
<div className={styles.resultMain}>
|
|
98
|
+
<strong>{item.result.label}</strong>
|
|
99
|
+
<span>{item.result.origin} · {item.result.tier} · {item.result.ctx}</span>
|
|
100
|
+
<p>{item.reason}</p>
|
|
101
|
+
</div>
|
|
102
|
+
<div className={styles.score}>{item.score}</div>
|
|
103
|
+
<LaunchButton model={item.result} toolMode={toolMode} onLaunch={() => onPinAndLaunch?.(item.result)} />
|
|
104
|
+
</article>
|
|
105
|
+
))}
|
|
106
|
+
</div>
|
|
107
|
+
</main>
|
|
108
|
+
)}
|
|
109
|
+
|
|
110
|
+
{error && <p className={styles.error}>{error}</p>}
|
|
111
|
+
|
|
112
|
+
<footer className={styles.footer}>
|
|
113
|
+
<button className={styles.secondary} onClick={back} disabled={step === 0 && !loading && !complete}>
|
|
114
|
+
<IconArrowLeft size={14} /> {complete || loading ? 'Restart' : 'Back'}
|
|
115
|
+
</button>
|
|
116
|
+
<button className={styles.secondary} onClick={onClose}>Close</button>
|
|
117
|
+
</footer>
|
|
118
|
+
</div>
|
|
119
|
+
</div>
|
|
120
|
+
)
|
|
121
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/** @file web/src/components/recommend/RecommendView.module.css */
|
|
2
|
+
.backdrop {
|
|
3
|
+
position: fixed;
|
|
4
|
+
inset: 0;
|
|
5
|
+
z-index: 480;
|
|
6
|
+
display: flex;
|
|
7
|
+
align-items: center;
|
|
8
|
+
justify-content: center;
|
|
9
|
+
padding: 24px;
|
|
10
|
+
background: rgba(0, 0, 0, .64);
|
|
11
|
+
backdrop-filter: blur(4px);
|
|
12
|
+
}
|
|
13
|
+
.modal {
|
|
14
|
+
width: min(980px, 96vw);
|
|
15
|
+
max-height: min(820px, 94vh);
|
|
16
|
+
overflow: auto;
|
|
17
|
+
border: 1px solid var(--color-border);
|
|
18
|
+
border-radius: 16px;
|
|
19
|
+
background: var(--color-bg-elevated);
|
|
20
|
+
color: var(--color-text);
|
|
21
|
+
box-shadow: 0 20px 60px rgba(0,0,0,.48);
|
|
22
|
+
}
|
|
23
|
+
.header,
|
|
24
|
+
.footer { display: flex; align-items: center; justify-content: space-between; gap: 14px; padding: 20px 24px; border-bottom: 1px solid var(--color-border); }
|
|
25
|
+
.footer { border-top: 1px solid var(--color-border); border-bottom: 0; justify-content: flex-end; }
|
|
26
|
+
.header h2 { display: flex; align-items: center; gap: 9px; margin: 4px 0; font-size: 21px; }
|
|
27
|
+
.eyebrow { margin: 0; color: var(--color-accent); font-size: 11px; font-weight: 900; text-transform: uppercase; letter-spacing: 1px; }
|
|
28
|
+
.summary,
|
|
29
|
+
.muted { color: var(--color-text-muted); margin: 0; }
|
|
30
|
+
.close,
|
|
31
|
+
.secondary { display: inline-flex; align-items: center; gap: 6px; border: 1px solid var(--color-border); border-radius: 8px; background: var(--color-surface); color: var(--color-text); cursor: pointer; }
|
|
32
|
+
.close { width: 32px; height: 32px; justify-content: center; }
|
|
33
|
+
.secondary { padding: 8px 12px; font-weight: 800; }
|
|
34
|
+
.secondary:disabled { opacity: .45; cursor: not-allowed; }
|
|
35
|
+
.body { padding: 26px 28px; }
|
|
36
|
+
.steps { color: var(--color-accent); font-family: var(--font-mono); font-size: 12px; font-weight: 800; margin-bottom: 10px; }
|
|
37
|
+
.body h3 { margin: 0 0 18px; font-size: 20px; }
|
|
38
|
+
.options { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 14px; }
|
|
39
|
+
.option { min-height: 120px; padding: 18px; border: 1px solid var(--color-border); border-radius: 14px; background: var(--color-surface); color: var(--color-text); cursor: pointer; text-align: left; transition: transform 120ms, border-color 120ms, background 120ms; }
|
|
40
|
+
.option:hover { transform: translateY(-2px); border-color: var(--color-accent); background: var(--color-bg-hover); }
|
|
41
|
+
.option strong { display: block; font-size: 16px; margin-bottom: 8px; }
|
|
42
|
+
.option span { color: var(--color-text-dim); font-family: var(--font-mono); font-size: 11px; }
|
|
43
|
+
.progress { height: 14px; border: 1px solid var(--color-border); border-radius: 999px; overflow: hidden; background: var(--color-surface); }
|
|
44
|
+
.progress span { display: block; height: 100%; background: linear-gradient(90deg, var(--color-accent), #b400ff); transition: width 200ms; }
|
|
45
|
+
.percent { font-family: var(--font-mono); color: var(--color-accent); font-weight: 900; }
|
|
46
|
+
.results { display: flex; flex-direction: column; gap: 12px; }
|
|
47
|
+
.resultCard { display: grid; grid-template-columns: 38px 1fr 60px auto; align-items: center; gap: 14px; padding: 14px; border: 1px solid var(--color-border); border-radius: 14px; background: var(--color-surface); }
|
|
48
|
+
.medal { font-size: 26px; }
|
|
49
|
+
.resultMain { display: flex; flex-direction: column; gap: 3px; min-width: 0; }
|
|
50
|
+
.resultMain strong { font-size: 15px; }
|
|
51
|
+
.resultMain span { color: var(--color-text-muted); font-size: 12px; }
|
|
52
|
+
.resultMain p { margin: 5px 0 0; color: var(--color-text-dim); font-size: 12px; }
|
|
53
|
+
.score { font-family: var(--font-mono); color: var(--color-accent); font-size: 24px; font-weight: 900; text-align: center; }
|
|
54
|
+
.error { margin: 0 24px 16px; color: #ff5a7a; font-weight: 800; }
|
|
55
|
+
@media (max-width: 760px) { .options { grid-template-columns: 1fr; } .resultCard { grid-template-columns: 32px 1fr; } .score { text-align: left; } }
|