free-coding-models 0.5.6 → 0.5.9
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/changelog/v0.5.8.md +23 -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-27IYGKp2.js +39 -0
- package/web/dist/assets/index-DKmmiDip.css +1 -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,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file web/src/components/tools/ToolPicker.jsx
|
|
3
|
+
* @description Header/dropdown control for the active Web endpoint-install target.
|
|
4
|
+
* Mirrors the TUI install target flow with a mouse-first dropdown and cycle button.
|
|
5
|
+
*
|
|
6
|
+
* @functions
|
|
7
|
+
* → ToolPicker — renders active tool, cycle control, and selectable tool list
|
|
8
|
+
* @exports ToolPicker
|
|
9
|
+
*/
|
|
10
|
+
import { useEffect, useRef, useState } from 'react'
|
|
11
|
+
import { IconChevronDown, IconRefresh } from '@tabler/icons-react'
|
|
12
|
+
import { TOOL_METADATA, getToolMeta } from '../../../../src/core/tool-metadata.js'
|
|
13
|
+
import { INSTALL_ENDPOINT_TOOL_MODES } from '../../utils/m3.js'
|
|
14
|
+
import styles from './ToolPicker.module.css'
|
|
15
|
+
|
|
16
|
+
export default function ToolPicker({ toolMode = 'opencode', onSetToolMode, onCycleToolMode, compact = false, tools = INSTALL_ENDPOINT_TOOL_MODES }) {
|
|
17
|
+
const [open, setOpen] = useState(false)
|
|
18
|
+
const wrapRef = useRef(null)
|
|
19
|
+
const active = getToolMeta(toolMode)
|
|
20
|
+
|
|
21
|
+
useEffect(() => {
|
|
22
|
+
if (!open) return
|
|
23
|
+
const onPointer = (event) => {
|
|
24
|
+
if (wrapRef.current && !wrapRef.current.contains(event.target)) setOpen(false)
|
|
25
|
+
}
|
|
26
|
+
const onKey = (event) => { if (event.key === 'Escape') setOpen(false) }
|
|
27
|
+
document.addEventListener('mousedown', onPointer)
|
|
28
|
+
document.addEventListener('keydown', onKey)
|
|
29
|
+
return () => {
|
|
30
|
+
document.removeEventListener('mousedown', onPointer)
|
|
31
|
+
document.removeEventListener('keydown', onKey)
|
|
32
|
+
}
|
|
33
|
+
}, [open])
|
|
34
|
+
|
|
35
|
+
return (
|
|
36
|
+
<div className={`${styles.wrap} ${compact ? styles.compact : ''}`} ref={wrapRef}>
|
|
37
|
+
<button
|
|
38
|
+
type="button"
|
|
39
|
+
className={styles.trigger}
|
|
40
|
+
onClick={() => setOpen((value) => !value)}
|
|
41
|
+
aria-haspopup="listbox"
|
|
42
|
+
aria-expanded={open}
|
|
43
|
+
title={`Install endpoint into: ${active.label}`}
|
|
44
|
+
>
|
|
45
|
+
<span className={styles.emoji}>{active.emoji}</span>
|
|
46
|
+
<span className={styles.label}>{compact ? active.label.replace(/ CLI$/, '') : active.label}</span>
|
|
47
|
+
<IconChevronDown size={13} stroke={1.7} />
|
|
48
|
+
</button>
|
|
49
|
+
<button
|
|
50
|
+
type="button"
|
|
51
|
+
className={styles.cycle}
|
|
52
|
+
onClick={onCycleToolMode}
|
|
53
|
+
title="Cycle endpoint target"
|
|
54
|
+
aria-label="Cycle endpoint target"
|
|
55
|
+
>
|
|
56
|
+
<IconRefresh size={13} stroke={1.7} />
|
|
57
|
+
</button>
|
|
58
|
+
|
|
59
|
+
{open && (
|
|
60
|
+
<div className={styles.menu} role="listbox" aria-label="Endpoint install target">
|
|
61
|
+
{tools.map((mode) => {
|
|
62
|
+
const meta = TOOL_METADATA[mode]
|
|
63
|
+
const activeMode = mode === toolMode
|
|
64
|
+
return (
|
|
65
|
+
<button
|
|
66
|
+
type="button"
|
|
67
|
+
key={mode}
|
|
68
|
+
className={`${styles.item} ${activeMode ? styles.itemActive : ''}`}
|
|
69
|
+
onClick={() => { onSetToolMode?.(mode); setOpen(false) }}
|
|
70
|
+
role="option"
|
|
71
|
+
aria-selected={activeMode}
|
|
72
|
+
>
|
|
73
|
+
<span className={styles.itemEmoji}>{meta.emoji}</span>
|
|
74
|
+
<span className={styles.itemText}>
|
|
75
|
+
<strong>{meta.label}</strong>
|
|
76
|
+
<small>{meta.flag}</small>
|
|
77
|
+
</span>
|
|
78
|
+
{activeMode && <span className={styles.activeDot}>●</span>}
|
|
79
|
+
</button>
|
|
80
|
+
)
|
|
81
|
+
})}
|
|
82
|
+
</div>
|
|
83
|
+
)}
|
|
84
|
+
</div>
|
|
85
|
+
)
|
|
86
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file web/src/components/tools/ToolPicker.module.css
|
|
3
|
+
* @description Compact header dropdown styles for the M3 tool-mode picker.
|
|
4
|
+
*/
|
|
5
|
+
.wrap {
|
|
6
|
+
position: relative;
|
|
7
|
+
display: inline-flex;
|
|
8
|
+
align-items: center;
|
|
9
|
+
gap: 4px;
|
|
10
|
+
}
|
|
11
|
+
.trigger,
|
|
12
|
+
.cycle {
|
|
13
|
+
height: 30px;
|
|
14
|
+
display: inline-flex;
|
|
15
|
+
align-items: center;
|
|
16
|
+
justify-content: center;
|
|
17
|
+
border: 1px solid var(--color-border);
|
|
18
|
+
background: var(--color-surface);
|
|
19
|
+
color: var(--color-text);
|
|
20
|
+
cursor: pointer;
|
|
21
|
+
transition: background 150ms, border-color 150ms, color 150ms;
|
|
22
|
+
}
|
|
23
|
+
.trigger {
|
|
24
|
+
gap: 6px;
|
|
25
|
+
padding: 0 9px;
|
|
26
|
+
border-radius: 6px;
|
|
27
|
+
font-size: 12px;
|
|
28
|
+
font-weight: 700;
|
|
29
|
+
}
|
|
30
|
+
.cycle {
|
|
31
|
+
width: 30px;
|
|
32
|
+
border-radius: 6px;
|
|
33
|
+
}
|
|
34
|
+
.trigger:hover,
|
|
35
|
+
.cycle:hover {
|
|
36
|
+
background: var(--color-bg-hover);
|
|
37
|
+
border-color: var(--color-accent);
|
|
38
|
+
color: var(--color-accent);
|
|
39
|
+
}
|
|
40
|
+
.emoji { font-size: 14px; line-height: 1; }
|
|
41
|
+
.label { white-space: nowrap; }
|
|
42
|
+
.menu {
|
|
43
|
+
position: absolute;
|
|
44
|
+
right: 0;
|
|
45
|
+
top: calc(100% + 7px);
|
|
46
|
+
width: 280px;
|
|
47
|
+
max-height: min(520px, calc(100vh - 90px));
|
|
48
|
+
overflow: auto;
|
|
49
|
+
padding: 5px;
|
|
50
|
+
border: 1px solid var(--color-border);
|
|
51
|
+
border-radius: 10px;
|
|
52
|
+
background: var(--color-bg-elevated);
|
|
53
|
+
box-shadow: 0 14px 36px rgba(0, 0, 0, 0.38);
|
|
54
|
+
z-index: 250;
|
|
55
|
+
}
|
|
56
|
+
.item {
|
|
57
|
+
width: 100%;
|
|
58
|
+
display: flex;
|
|
59
|
+
align-items: center;
|
|
60
|
+
gap: 9px;
|
|
61
|
+
padding: 8px 9px;
|
|
62
|
+
border: 0;
|
|
63
|
+
border-radius: 7px;
|
|
64
|
+
color: var(--color-text);
|
|
65
|
+
background: transparent;
|
|
66
|
+
cursor: pointer;
|
|
67
|
+
text-align: left;
|
|
68
|
+
}
|
|
69
|
+
.item:hover { background: var(--color-bg-hover); }
|
|
70
|
+
.itemActive {
|
|
71
|
+
background: var(--color-accent-dim);
|
|
72
|
+
color: var(--color-accent);
|
|
73
|
+
}
|
|
74
|
+
.itemEmoji { width: 22px; text-align: center; font-size: 16px; }
|
|
75
|
+
.itemText { display: flex; flex-direction: column; gap: 2px; flex: 1; min-width: 0; }
|
|
76
|
+
.itemText strong { font-size: 12px; font-weight: 800; }
|
|
77
|
+
.itemText small { color: var(--color-text-dim); font-family: var(--font-mono); font-size: 10px; }
|
|
78
|
+
.activeDot { color: var(--color-accent); font-size: 10px; }
|
|
79
|
+
.compact .trigger { width: 100%; justify-content: flex-start; }
|
|
80
|
+
.compact .menu { left: 0; right: auto; }
|
|
81
|
+
@media (max-width: 1024px) {
|
|
82
|
+
.label { display: none; }
|
|
83
|
+
.trigger { padding: 0 8px; }
|
|
84
|
+
}
|
package/web/src/global.css
CHANGED
|
@@ -136,6 +136,29 @@
|
|
|
136
136
|
/* ─── Reset & Base ─── */
|
|
137
137
|
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
|
138
138
|
|
|
139
|
+
/* 📖 M5: Global :focus-visible outline for keyboard navigation (a11y).
|
|
140
|
+
📖 Only shows for keyboard users, not mouse clicks. Uses the accent color
|
|
141
|
+
📖 with a subtle glow ring so focus is always visible but not jarring. */
|
|
142
|
+
:focus-visible {
|
|
143
|
+
outline: 2px solid var(--color-accent);
|
|
144
|
+
outline-offset: 2px;
|
|
145
|
+
border-radius: var(--radius-sm);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/* 📖 Remove default browser :focus ring — we only want :focus-visible */
|
|
149
|
+
:focus:not(:focus-visible) {
|
|
150
|
+
outline: none;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/* 📖 Inputs keep their own focus styling via component CSS, but keyboard
|
|
154
|
+
📖 users still see the global ring as a fallback safety net. */
|
|
155
|
+
input:focus-visible,
|
|
156
|
+
select:focus-visible,
|
|
157
|
+
textarea:focus-visible {
|
|
158
|
+
outline: 2px solid var(--color-accent);
|
|
159
|
+
outline-offset: 1px;
|
|
160
|
+
}
|
|
161
|
+
|
|
139
162
|
html {
|
|
140
163
|
font-size: 14px;
|
|
141
164
|
scroll-behavior: smooth;
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
* 📖 HEALTH_CYCLE constants so the URL reflects the same filter universe
|
|
6
6
|
* 📖 the TUI uses.
|
|
7
7
|
*/
|
|
8
|
+
import { TOOL_MODE_ORDER } from '../../../src/core/tool-metadata.js'
|
|
8
9
|
|
|
9
10
|
const TIER_VALUES = new Set(['S+', 'S', 'A+', 'A', 'A-', 'B+', 'B', 'C', 'all'])
|
|
10
11
|
const STATUS_VALUES = new Set(['up', 'down', 'pending', 'all'])
|
|
@@ -15,6 +16,7 @@ const SORT_VALUES = new Set([
|
|
|
15
16
|
])
|
|
16
17
|
const VIEW_VALUES = new Set(['dashboard', 'settings', 'analytics', 'recommend', 'router', 'help', 'changelog'])
|
|
17
18
|
const DIR_VALUES = new Set(['asc', 'desc'])
|
|
19
|
+
const TOOL_MODE_VALUES = new Set(TOOL_MODE_ORDER)
|
|
18
20
|
|
|
19
21
|
export {
|
|
20
22
|
TIER_VALUES as VALID_TIERS,
|
|
@@ -22,4 +24,5 @@ export {
|
|
|
22
24
|
SORT_VALUES as VALID_SORTS,
|
|
23
25
|
VIEW_VALUES as VALID_VIEWS,
|
|
24
26
|
DIR_VALUES as VALID_DIRS,
|
|
27
|
+
TOOL_MODE_VALUES as VALID_TOOL_MODES,
|
|
25
28
|
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file web/src/hooks/useInstalledModels.js
|
|
3
|
+
* @description Hook for Installed Models — scans tool configs and soft-deletes models.
|
|
4
|
+
* 📖 M4: Fetches /api/installed-models, provides disable(action) for soft-delete.
|
|
5
|
+
*
|
|
6
|
+
* @functions useInstalledModels → { results, loading, refresh, disableModel }
|
|
7
|
+
*/
|
|
8
|
+
import { useState, useEffect, useCallback } from 'react'
|
|
9
|
+
|
|
10
|
+
export function useInstalledModels() {
|
|
11
|
+
const [results, setResults] = useState([])
|
|
12
|
+
const [loading, setLoading] = useState(true)
|
|
13
|
+
|
|
14
|
+
const refresh = useCallback(async () => {
|
|
15
|
+
setLoading(true)
|
|
16
|
+
try {
|
|
17
|
+
const resp = await fetch('/api/installed-models')
|
|
18
|
+
const data = await resp.json()
|
|
19
|
+
setResults(data.results || [])
|
|
20
|
+
} catch {
|
|
21
|
+
setResults([])
|
|
22
|
+
} finally {
|
|
23
|
+
setLoading(false)
|
|
24
|
+
}
|
|
25
|
+
}, [])
|
|
26
|
+
|
|
27
|
+
useEffect(() => { void refresh() }, [refresh])
|
|
28
|
+
|
|
29
|
+
const disableModel = useCallback(async (toolMode, modelId) => {
|
|
30
|
+
const resp = await fetch(`/api/installed-models/${encodeURIComponent(toolMode)}/${encodeURIComponent(modelId)}/disable`, {
|
|
31
|
+
method: 'POST',
|
|
32
|
+
})
|
|
33
|
+
const result = await resp.json()
|
|
34
|
+
if (result.success) {
|
|
35
|
+
// 📖 Refresh after successful delete
|
|
36
|
+
await refresh()
|
|
37
|
+
}
|
|
38
|
+
return result
|
|
39
|
+
}, [refresh])
|
|
40
|
+
|
|
41
|
+
return { results, loading, refresh, disableModel }
|
|
42
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file web/src/hooks/useRecommend.js
|
|
3
|
+
* @description Smart Recommend hook for M3 Web parity. Owns the 10 second
|
|
4
|
+
* analysis phase and calls `/api/recommend`, which uses the shared TUI scoring
|
|
5
|
+
* engine in `src/core/utils.js`.
|
|
6
|
+
*
|
|
7
|
+
* @functions useRecommend → run/cancel recommendation analysis
|
|
8
|
+
* @exports useRecommend
|
|
9
|
+
*/
|
|
10
|
+
import { useCallback, useEffect, useRef, useState } from 'react'
|
|
11
|
+
|
|
12
|
+
export function useRecommend({ onToast } = {}) {
|
|
13
|
+
const [loading, setLoading] = useState(false)
|
|
14
|
+
const [progress, setProgress] = useState(0)
|
|
15
|
+
const [results, setResults] = useState([])
|
|
16
|
+
const [error, setError] = useState(null)
|
|
17
|
+
const timerRef = useRef(null)
|
|
18
|
+
|
|
19
|
+
useEffect(() => () => { if (timerRef.current) clearInterval(timerRef.current) }, [])
|
|
20
|
+
|
|
21
|
+
const recommend = useCallback(async (answers) => {
|
|
22
|
+
if (timerRef.current) clearInterval(timerRef.current)
|
|
23
|
+
setLoading(true)
|
|
24
|
+
setProgress(0)
|
|
25
|
+
setResults([])
|
|
26
|
+
setError(null)
|
|
27
|
+
|
|
28
|
+
const started = Date.now()
|
|
29
|
+
timerRef.current = setInterval(() => {
|
|
30
|
+
const pct = Math.min(98, Math.round(((Date.now() - started) / 10_000) * 100))
|
|
31
|
+
setProgress(pct)
|
|
32
|
+
}, 250)
|
|
33
|
+
|
|
34
|
+
try {
|
|
35
|
+
await new Promise((resolve) => setTimeout(resolve, 10_000))
|
|
36
|
+
const resp = await fetch('/api/recommend', {
|
|
37
|
+
method: 'POST',
|
|
38
|
+
headers: { 'Content-Type': 'application/json' },
|
|
39
|
+
body: JSON.stringify({ answers }),
|
|
40
|
+
})
|
|
41
|
+
const payload = await resp.json().catch(() => ({}))
|
|
42
|
+
if (!resp.ok) throw new Error(payload.error || `HTTP ${resp.status}`)
|
|
43
|
+
setResults(Array.isArray(payload.top3) ? payload.top3 : [])
|
|
44
|
+
setProgress(100)
|
|
45
|
+
return { ok: true, top3: payload.top3 || [] }
|
|
46
|
+
} catch (err) {
|
|
47
|
+
setError(err.message)
|
|
48
|
+
onToast?.(`Recommend failed: ${err.message}`, 'error')
|
|
49
|
+
return { ok: false, error: err.message }
|
|
50
|
+
} finally {
|
|
51
|
+
if (timerRef.current) clearInterval(timerRef.current)
|
|
52
|
+
timerRef.current = null
|
|
53
|
+
setLoading(false)
|
|
54
|
+
}
|
|
55
|
+
}, [onToast])
|
|
56
|
+
|
|
57
|
+
const reset = useCallback(() => {
|
|
58
|
+
if (timerRef.current) clearInterval(timerRef.current)
|
|
59
|
+
timerRef.current = null
|
|
60
|
+
setLoading(false)
|
|
61
|
+
setProgress(0)
|
|
62
|
+
setResults([])
|
|
63
|
+
setError(null)
|
|
64
|
+
}, [])
|
|
65
|
+
|
|
66
|
+
return { recommend, loading, progress, results, error, reset }
|
|
67
|
+
}
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file web/src/hooks/useRouterDashboard.js
|
|
3
|
+
* @description Hook for Router Dashboard — polls daemon status, proxies actions.
|
|
4
|
+
* 📖 M4: Provides status, stats, tokens, sets, start/stop, probe-mode control.
|
|
5
|
+
* 📖 Polls /api/router/status every 5s; fetches full stats on demand.
|
|
6
|
+
*
|
|
7
|
+
* @functions useRouterDashboard → { status, stats, tokens, start, stop, setProbeMode, refresh }
|
|
8
|
+
*/
|
|
9
|
+
import { useState, useEffect, useCallback, useRef } from 'react'
|
|
10
|
+
|
|
11
|
+
const POLL_INTERVAL_MS = 5000
|
|
12
|
+
|
|
13
|
+
export function useRouterDashboard() {
|
|
14
|
+
const [status, setStatus] = useState(null)
|
|
15
|
+
const [stats, setStats] = useState(null)
|
|
16
|
+
const [tokens, setTokens] = useState(null)
|
|
17
|
+
const [sets, setSets] = useState(null)
|
|
18
|
+
const [loading, setLoading] = useState(true)
|
|
19
|
+
const [actionLoading, setActionLoading] = useState(false)
|
|
20
|
+
const pollRef = useRef(null)
|
|
21
|
+
|
|
22
|
+
const fetchStatus = useCallback(async () => {
|
|
23
|
+
try {
|
|
24
|
+
const resp = await fetch('/api/router/status')
|
|
25
|
+
const data = await resp.json()
|
|
26
|
+
setStatus(data)
|
|
27
|
+
return data
|
|
28
|
+
} catch {
|
|
29
|
+
setStatus({ ok: false, running: false })
|
|
30
|
+
return null
|
|
31
|
+
}
|
|
32
|
+
}, [])
|
|
33
|
+
|
|
34
|
+
const fetchStats = useCallback(async () => {
|
|
35
|
+
try {
|
|
36
|
+
const resp = await fetch('/api/router/stats')
|
|
37
|
+
const data = await resp.json()
|
|
38
|
+
if (data.ok) setStats(data)
|
|
39
|
+
return data
|
|
40
|
+
} catch { return null }
|
|
41
|
+
}, [])
|
|
42
|
+
|
|
43
|
+
const fetchTokens = useCallback(async () => {
|
|
44
|
+
try {
|
|
45
|
+
const resp = await fetch('/api/router/tokens')
|
|
46
|
+
const data = await resp.json()
|
|
47
|
+
setTokens(data)
|
|
48
|
+
return data
|
|
49
|
+
} catch { return null }
|
|
50
|
+
}, [])
|
|
51
|
+
|
|
52
|
+
const fetchSets = useCallback(async () => {
|
|
53
|
+
try {
|
|
54
|
+
const resp = await fetch('/api/router/sets')
|
|
55
|
+
const data = await resp.json()
|
|
56
|
+
setSets(data)
|
|
57
|
+
return data
|
|
58
|
+
} catch { return null }
|
|
59
|
+
}, [])
|
|
60
|
+
|
|
61
|
+
// 📖 Poll daemon status + refresh stats when running
|
|
62
|
+
useEffect(() => {
|
|
63
|
+
let mounted = true
|
|
64
|
+
|
|
65
|
+
const poll = async () => {
|
|
66
|
+
const s = await fetchStatus()
|
|
67
|
+
if (s?.ok && mounted) {
|
|
68
|
+
await fetchStats()
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
poll().then(() => { if (mounted) setLoading(false) })
|
|
73
|
+
pollRef.current = setInterval(poll, POLL_INTERVAL_MS)
|
|
74
|
+
|
|
75
|
+
return () => {
|
|
76
|
+
mounted = false
|
|
77
|
+
clearInterval(pollRef.current)
|
|
78
|
+
}
|
|
79
|
+
}, [fetchStatus, fetchStats])
|
|
80
|
+
|
|
81
|
+
const start = useCallback(async () => {
|
|
82
|
+
setActionLoading(true)
|
|
83
|
+
try {
|
|
84
|
+
const resp = await fetch('/api/router/start', { method: 'POST' })
|
|
85
|
+
const data = await resp.json()
|
|
86
|
+
await fetchStatus()
|
|
87
|
+
if (data.ok) await fetchStats()
|
|
88
|
+
return data
|
|
89
|
+
} finally { setActionLoading(false) }
|
|
90
|
+
}, [fetchStatus, fetchStats])
|
|
91
|
+
|
|
92
|
+
const stop = useCallback(async () => {
|
|
93
|
+
setActionLoading(true)
|
|
94
|
+
try {
|
|
95
|
+
const resp = await fetch('/api/router/stop', { method: 'POST' })
|
|
96
|
+
const data = await resp.json()
|
|
97
|
+
setStatus({ ok: false, running: false })
|
|
98
|
+
setStats(null)
|
|
99
|
+
return data
|
|
100
|
+
} finally { setActionLoading(false) }
|
|
101
|
+
}, [])
|
|
102
|
+
|
|
103
|
+
const setProbeMode = useCallback(async (mode) => {
|
|
104
|
+
try {
|
|
105
|
+
await fetch('/api/router/probe-mode', {
|
|
106
|
+
method: 'POST',
|
|
107
|
+
headers: { 'Content-Type': 'application/json' },
|
|
108
|
+
body: JSON.stringify({ probeMode: mode }),
|
|
109
|
+
})
|
|
110
|
+
await fetchStats()
|
|
111
|
+
} catch {}
|
|
112
|
+
}, [fetchStats])
|
|
113
|
+
|
|
114
|
+
const refresh = useCallback(async () => {
|
|
115
|
+
setLoading(true)
|
|
116
|
+
await Promise.all([fetchStatus(), fetchTokens(), fetchSets()])
|
|
117
|
+
setLoading(false)
|
|
118
|
+
}, [fetchStatus, fetchTokens, fetchSets])
|
|
119
|
+
|
|
120
|
+
const getQuickSetup = useCallback(async () => {
|
|
121
|
+
try {
|
|
122
|
+
const resp = await fetch('/api/router/quick-setup')
|
|
123
|
+
return await resp.json()
|
|
124
|
+
} catch { return null }
|
|
125
|
+
}, [])
|
|
126
|
+
|
|
127
|
+
return {
|
|
128
|
+
status, stats, tokens, sets,
|
|
129
|
+
loading, actionLoading,
|
|
130
|
+
start, stop, setProbeMode, refresh,
|
|
131
|
+
fetchTokens, fetchSets, getQuickSetup,
|
|
132
|
+
}
|
|
133
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file web/src/hooks/useTokenUsage.js
|
|
3
|
+
* @description Hook for Token Usage panel — fetches token data from daemon or file.
|
|
4
|
+
* 📖 M4: Returns today's usage, 7-day breakdown, top models, top providers.
|
|
5
|
+
*
|
|
6
|
+
* @functions useTokenUsage → { data, loading, refresh }
|
|
7
|
+
*/
|
|
8
|
+
import { useState, useEffect, useCallback } from 'react'
|
|
9
|
+
|
|
10
|
+
export function useTokenUsage() {
|
|
11
|
+
const [data, setData] = useState(null)
|
|
12
|
+
const [loading, setLoading] = useState(true)
|
|
13
|
+
|
|
14
|
+
const refresh = useCallback(async () => {
|
|
15
|
+
setLoading(true)
|
|
16
|
+
try {
|
|
17
|
+
const resp = await fetch('/api/router/tokens')
|
|
18
|
+
const raw = await resp.json()
|
|
19
|
+
setData(processTokenData(raw))
|
|
20
|
+
} catch {
|
|
21
|
+
setData(null)
|
|
22
|
+
} finally {
|
|
23
|
+
setLoading(false)
|
|
24
|
+
}
|
|
25
|
+
}, [])
|
|
26
|
+
|
|
27
|
+
useEffect(() => { void refresh() }, [refresh])
|
|
28
|
+
|
|
29
|
+
return { data, loading, refresh }
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function processTokenData(raw) {
|
|
33
|
+
if (!raw) return null
|
|
34
|
+
|
|
35
|
+
const daily = raw.daily || {}
|
|
36
|
+
const allTime = raw.all_time || {}
|
|
37
|
+
|
|
38
|
+
// 📖 Build 7-day chart data
|
|
39
|
+
const days = []
|
|
40
|
+
const today = new Date()
|
|
41
|
+
for (let i = 6; i >= 0; i--) {
|
|
42
|
+
const d = new Date(today)
|
|
43
|
+
d.setDate(d.getDate() - i)
|
|
44
|
+
const key = d.toISOString().slice(0, 10)
|
|
45
|
+
const entry = daily[key] || { total_tokens: 0, requests: 0 }
|
|
46
|
+
days.push({
|
|
47
|
+
date: key,
|
|
48
|
+
label: d.toLocaleDateString('en', { weekday: 'short' }),
|
|
49
|
+
totalTokens: entry.total_tokens || 0,
|
|
50
|
+
requests: entry.requests || 0,
|
|
51
|
+
})
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// 📖 Top models across all tracked days
|
|
55
|
+
const modelTotals = {}
|
|
56
|
+
for (const entry of Object.values(daily)) {
|
|
57
|
+
const byModel = entry.by_model || {}
|
|
58
|
+
for (const [key, val] of Object.entries(byModel)) {
|
|
59
|
+
if (!modelTotals[key]) modelTotals[key] = { total: 0, requests: 0 }
|
|
60
|
+
modelTotals[key].total += val.total || 0
|
|
61
|
+
modelTotals[key].requests += val.requests || 0
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
const topModels = Object.entries(modelTotals)
|
|
65
|
+
.sort((a, b) => b[1].total - a[1].total)
|
|
66
|
+
.slice(0, 10)
|
|
67
|
+
.map(([key, val]) => ({ key, ...val }))
|
|
68
|
+
|
|
69
|
+
// 📖 Top providers (aggregated from model keys)
|
|
70
|
+
const providerTotals = {}
|
|
71
|
+
for (const [key, val] of Object.entries(modelTotals)) {
|
|
72
|
+
const provider = key.split('/')[0]
|
|
73
|
+
if (!providerTotals[provider]) providerTotals[provider] = { total: 0, requests: 0 }
|
|
74
|
+
providerTotals[provider].total += val.total
|
|
75
|
+
providerTotals[provider].requests += val.requests
|
|
76
|
+
}
|
|
77
|
+
const topProviders = Object.entries(providerTotals)
|
|
78
|
+
.sort((a, b) => b[1].total - a[1].total)
|
|
79
|
+
.slice(0, 10)
|
|
80
|
+
.map(([key, val]) => ({ key, ...val }))
|
|
81
|
+
|
|
82
|
+
const todayKey = today.toISOString().slice(0, 10)
|
|
83
|
+
const todayData = daily[todayKey] || {}
|
|
84
|
+
|
|
85
|
+
return {
|
|
86
|
+
today: {
|
|
87
|
+
totalTokens: todayData.total_tokens || 0,
|
|
88
|
+
promptTokens: todayData.prompt_tokens || 0,
|
|
89
|
+
completionTokens: todayData.completion_tokens || 0,
|
|
90
|
+
requests: todayData.requests || 0,
|
|
91
|
+
},
|
|
92
|
+
allTime: {
|
|
93
|
+
totalTokens: allTime.total_tokens || 0,
|
|
94
|
+
promptTokens: allTime.prompt_tokens || 0,
|
|
95
|
+
completionTokens: allTime.completion_tokens || 0,
|
|
96
|
+
requests: allTime.requests || 0,
|
|
97
|
+
},
|
|
98
|
+
sevenDays: days,
|
|
99
|
+
topModels,
|
|
100
|
+
topProviders,
|
|
101
|
+
hasData: allTime.total_tokens > 0 || Object.keys(daily).length > 0,
|
|
102
|
+
}
|
|
103
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file web/src/hooks/useToolMode.js
|
|
3
|
+
* @description M3 tool-mode hook for the Web Dashboard. It cycles endpoint install
|
|
4
|
+
* targets by loading and persisting `settings.preferredToolMode` through
|
|
5
|
+
* `/api/tool-mode`, while keeping URL hydration safe from late API responses.
|
|
6
|
+
*
|
|
7
|
+
* @functions
|
|
8
|
+
* → useToolMode — loads, sets, and cycles the active endpoint target
|
|
9
|
+
* @exports useToolMode
|
|
10
|
+
*/
|
|
11
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
|
12
|
+
import { INSTALL_ENDPOINT_TOOL_MODES } from '../utils/m3.js'
|
|
13
|
+
|
|
14
|
+
const DEFAULT_TOOL_MODE = 'opencode'
|
|
15
|
+
|
|
16
|
+
function normalizeToolMode(mode) {
|
|
17
|
+
return INSTALL_ENDPOINT_TOOL_MODES.includes(mode) ? mode : DEFAULT_TOOL_MODE
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function useToolMode({ onToast } = {}) {
|
|
21
|
+
const [toolModeState, setToolModeState] = useState(DEFAULT_TOOL_MODE)
|
|
22
|
+
const userSetRef = useRef(false)
|
|
23
|
+
|
|
24
|
+
useEffect(() => {
|
|
25
|
+
let cancelled = false
|
|
26
|
+
async function loadToolMode() {
|
|
27
|
+
try {
|
|
28
|
+
const resp = await fetch('/api/tool-mode')
|
|
29
|
+
const payload = await resp.json().catch(() => ({}))
|
|
30
|
+
if (!cancelled && !userSetRef.current && resp.ok) {
|
|
31
|
+
setToolModeState(normalizeToolMode(payload.mode))
|
|
32
|
+
}
|
|
33
|
+
} catch (err) {
|
|
34
|
+
if (!cancelled) onToast?.(`Tool mode load failed: ${err.message}`, 'error')
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
void loadToolMode()
|
|
38
|
+
return () => { cancelled = true }
|
|
39
|
+
}, [onToast])
|
|
40
|
+
|
|
41
|
+
const persistToolMode = useCallback(async (mode) => {
|
|
42
|
+
const normalized = normalizeToolMode(mode)
|
|
43
|
+
userSetRef.current = true
|
|
44
|
+
setToolModeState(normalized)
|
|
45
|
+
try {
|
|
46
|
+
const resp = await fetch('/api/tool-mode', {
|
|
47
|
+
method: 'POST',
|
|
48
|
+
headers: { 'Content-Type': 'application/json' },
|
|
49
|
+
body: JSON.stringify({ mode: normalized }),
|
|
50
|
+
})
|
|
51
|
+
const payload = await resp.json().catch(() => ({}))
|
|
52
|
+
if (!resp.ok) {
|
|
53
|
+
throw new Error(payload.error || `HTTP ${resp.status}`)
|
|
54
|
+
}
|
|
55
|
+
setToolModeState(normalizeToolMode(payload.mode))
|
|
56
|
+
return { ok: true, mode: normalizeToolMode(payload.mode) }
|
|
57
|
+
} catch (err) {
|
|
58
|
+
onToast?.(`Tool mode save failed: ${err.message}`, 'error')
|
|
59
|
+
return { ok: false, error: err.message, mode: normalized }
|
|
60
|
+
}
|
|
61
|
+
}, [onToast])
|
|
62
|
+
|
|
63
|
+
const cycleToolMode = useCallback(() => {
|
|
64
|
+
const currentIndex = TOOL_MODE_ORDER.indexOf(toolModeState)
|
|
65
|
+
const next = INSTALL_ENDPOINT_TOOL_MODES[(currentIndex + 1) % INSTALL_ENDPOINT_TOOL_MODES.length] || DEFAULT_TOOL_MODE
|
|
66
|
+
return persistToolMode(next)
|
|
67
|
+
}, [persistToolMode, toolModeState])
|
|
68
|
+
|
|
69
|
+
const tools = useMemo(() => [...INSTALL_ENDPOINT_TOOL_MODES], [])
|
|
70
|
+
|
|
71
|
+
return {
|
|
72
|
+
toolMode: toolModeState,
|
|
73
|
+
tools,
|
|
74
|
+
setToolMode: persistToolMode,
|
|
75
|
+
cycleToolMode,
|
|
76
|
+
}
|
|
77
|
+
}
|
|
@@ -22,8 +22,8 @@
|
|
|
22
22
|
* → useUrlState({ currentView, setCurrentView, filterState, paletteOpen, setPaletteOpen })
|
|
23
23
|
* → buildUrlParams(state) — pure helper exposed for tests
|
|
24
24
|
*/
|
|
25
|
-
import { useEffect, useRef
|
|
26
|
-
import { VALID_TIERS, VALID_STATUS, VALID_SORTS, VALID_VIEWS, VALID_DIRS } from './urlState.constants.js'
|
|
25
|
+
import { useEffect, useRef } from 'react'
|
|
26
|
+
import { VALID_TIERS, VALID_STATUS, VALID_SORTS, VALID_VIEWS, VALID_DIRS, VALID_TOOL_MODES } from './urlState.constants.js'
|
|
27
27
|
|
|
28
28
|
// 📖 Read the current URL params as a normalized object. Returns null on SSR
|
|
29
29
|
// 📖 or when the URL is invalid.
|
|
@@ -40,7 +40,8 @@ function parseUrlParams() {
|
|
|
40
40
|
if (params.has('sort') && VALID_SORTS.has(params.get('sort'))) out.sort = params.get('sort')
|
|
41
41
|
if (params.has('dir') && VALID_DIRS.has(params.get('dir'))) out.dir = params.get('dir')
|
|
42
42
|
if (params.has('q')) out.q = params.get('q')
|
|
43
|
-
if (params.has('toolMode')) out.toolMode = params.get('toolMode')
|
|
43
|
+
if (params.has('toolMode') && VALID_TOOL_MODES.has(params.get('toolMode'))) out.toolMode = params.get('toolMode')
|
|
44
|
+
if (params.has('recommend') && ['1', 'true', 'open'].includes(params.get('recommend'))) out.view = 'recommend'
|
|
44
45
|
if (params.has('palette') && params.get('palette') === 'open') out.palette = 'open'
|
|
45
46
|
return out
|
|
46
47
|
}
|