free-coding-models 0.5.5 → 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.
Files changed (63) hide show
  1. package/README.md +19 -11
  2. package/bin/free-coding-models.js +6 -0
  3. package/changelog/v0.5.5.md +1 -0
  4. package/changelog/v0.5.6.md +24 -0
  5. package/changelog/v0.5.7.md +20 -0
  6. package/package.json +4 -4
  7. package/src/core/changelog-loader.js +5 -1
  8. package/src/core/endpoint-installer.js +3 -3
  9. package/src/core/router-daemon.js +11 -0
  10. package/src/core/tool-launchers.js +2 -2
  11. package/web/dist/assets/index-DKmmiDip.css +1 -0
  12. package/web/dist/assets/index-DzVt42Nf.js +39 -0
  13. package/web/dist/index.html +2 -2
  14. package/web/server.js +532 -2
  15. package/web/src/App.jsx +235 -73
  16. package/web/src/components/analytics/AnalyticsView.jsx +4 -0
  17. package/web/src/components/analytics/TokenUsagePanel.jsx +105 -0
  18. package/web/src/components/analytics/TokenUsagePanel.module.css +126 -0
  19. package/web/src/components/atoms/TierBadge.module.css +3 -3
  20. package/web/src/components/changelog/ChangelogView.jsx +135 -0
  21. package/web/src/components/changelog/ChangelogView.module.css +160 -0
  22. package/web/src/components/dashboard/DetailPanel.jsx +29 -4
  23. package/web/src/components/dashboard/DetailPanel.module.css +26 -0
  24. package/web/src/components/dashboard/FilterBar.jsx +17 -2
  25. package/web/src/components/dashboard/FilterBar.module.css +17 -0
  26. package/web/src/components/dashboard/ModelTable.jsx +17 -5
  27. package/web/src/components/dashboard/ModelTable.module.css +14 -1
  28. package/web/src/components/help/HelpView.jsx +188 -0
  29. package/web/src/components/help/HelpView.module.css +157 -0
  30. package/web/src/components/install/InstallEndpointsView.jsx +278 -0
  31. package/web/src/components/install/InstallEndpointsView.module.css +351 -0
  32. package/web/src/components/installed/InstalledModelsView.jsx +89 -0
  33. package/web/src/components/installed/InstalledModelsView.module.css +200 -0
  34. package/web/src/components/launch/IncompatibleFallbackModal.jsx +69 -0
  35. package/web/src/components/launch/LaunchButton.jsx +30 -0
  36. package/web/src/components/launch/LaunchButton.module.css +35 -0
  37. package/web/src/components/launch/LaunchModal.module.css +125 -0
  38. package/web/src/components/layout/Header.jsx +78 -14
  39. package/web/src/components/layout/Header.module.css +62 -2
  40. package/web/src/components/palette/CommandPalette.jsx +228 -74
  41. package/web/src/components/recommend/RecommendView.jsx +121 -0
  42. package/web/src/components/recommend/RecommendView.module.css +55 -0
  43. package/web/src/components/router/RouterView.jsx +286 -0
  44. package/web/src/components/router/RouterView.module.css +357 -0
  45. package/web/src/components/settings/SettingsView.jsx +281 -8
  46. package/web/src/components/settings/SettingsView.module.css +174 -0
  47. package/web/src/components/tools/ToolPicker.jsx +86 -0
  48. package/web/src/components/tools/ToolPicker.module.css +84 -0
  49. package/web/src/components/update/UpdateChip.jsx +104 -0
  50. package/web/src/components/update/UpdateChip.module.css +146 -0
  51. package/web/src/global.css +23 -0
  52. package/web/src/hooks/urlState.constants.js +28 -0
  53. package/web/src/hooks/useChangelog.js +51 -0
  54. package/web/src/hooks/useInstalledModels.js +42 -0
  55. package/web/src/hooks/useRecommend.js +67 -0
  56. package/web/src/hooks/useRouterDashboard.js +133 -0
  57. package/web/src/hooks/useTokenUsage.js +103 -0
  58. package/web/src/hooks/useToolMode.js +77 -0
  59. package/web/src/hooks/useUpdateChecker.js +91 -0
  60. package/web/src/hooks/useUrlState.js +123 -62
  61. package/web/src/utils/m3.js +55 -0
  62. package/web/dist/assets/index-uD3faN3G.js +0 -39
  63. package/web/dist/assets/index-uTifVKX1.css +0 -1
@@ -1,52 +1,37 @@
1
1
  /**
2
2
  * @file web/src/hooks/useUrlState.js
3
3
  * @description URL deep-linking hook — the Web's answer to TUI CLI flags (--tier, --sort, etc.).
4
- * 📖 M1 ships read-only hydration: on mount, parses the query string and hydrates the Web view.
5
- * 📖 M2 will add write-back (push filter/sort/view changes back to the URL via history.replaceState).
4
+ * 📖 M1 = read-only hydration on mount.
5
+ * 📖 M2 = adds write-back: every filter / sort / view / tool-mode / palette
6
+ * 📖 change pushes to the URL via `history.replaceState` so the URL stays
7
+ * 📖 in sync with the visible Web state and any URL is shareable.
6
8
  *
7
- * 📖 Supported query params (M1 = read-only):
8
- * 📖 ?tier=S+|S|A+|A|A-|B+|B|C|all — sets the tier filter
9
- * 📖 ?status=up|down|pending|all — sets the health/status filter
10
- * 📖 ?provider=<providerKey>|all — sets the provider filter
11
- * 📖 ?verdict=<verdict>|all — sets the verdict filter
12
- * 📖 ?health=<health>|all — sets the health filter
13
- * 📖 ?sort=<col>&dir=asc|desc — sets the sort column + direction
14
- * 📖 ?view=dashboard|settings|analytics — sets the active view
15
- * 📖 ?q=<text> — sets the search query
16
- * 📖 ?tier=S&sort=verdict&dir=asc — compose freely; the same URL is shareable
9
+ * 📖 URL params (all optional, all shareable):
10
+ * 📖 ?view=dashboard|settings|analytics|recommend|router|help|changelog
11
+ * 📖 ?tier=S+|S|A+|A|A-|B+|B|C|all
12
+ * 📖 ?status=up|down|pending|all
13
+ * 📖 ?provider=<providerKey>|all
14
+ * 📖 ?verdict=<verdict>|all
15
+ * 📖 ?health=<health>|all
16
+ * 📖 ?sort=<col>&dir=asc|desc
17
+ * 📖 ?q=<searchText>
18
+ * 📖 ?toolMode=<toolKey>
19
+ * 📖 ?palette=open (when the palette should be open on load)
17
20
  *
18
21
  * @functions
19
- * → useUrlState({ currentView, setCurrentView, filterState }) — hydrates Web state from URL
20
- *
21
- * @see ideas/tui-web-feature-parity.md §5.4 — full CLI-flag ↔ URL-param mapping
22
+ * → useUrlState({ currentView, setCurrentView, filterState, paletteOpen, setPaletteOpen })
23
+ * → buildUrlParams(state) — pure helper exposed for tests
22
24
  */
23
- import { useEffect } from 'react'
24
-
25
- // 📖 Valid enum values for cycle-style params. Keys here determine which params
26
- // 📖 get accepted silently vs ignored. Anything not in the list is dropped.
27
- const VALID_TIERS = new Set(['S+', 'S', 'A+', 'A', 'A-', 'B+', 'B', 'C', 'all'])
28
- const VALID_STATUS = new Set(['up', 'down', 'pending', 'all'])
29
- const VALID_SORTS = new Set([
30
- 'mood', 'idx', 'tier', 'sweScore', 'ctx', 'label', 'origin',
31
- 'latestPing', 'avg', 'condition', 'verdict', 'stability', 'uptime',
32
- 'aiLatency', 'tps', 'trend',
33
- ])
34
- const VALID_VIEWS = new Set(['dashboard', 'settings', 'analytics'])
35
- const VALID_DIRS = new Set(['asc', 'desc'])
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'
36
27
 
37
- /**
38
- * 📖 parseUrlParams: extract a normalized set of params from the current URL.
39
- * 📖 Returns a flat object with camelCase keys, only including recognized params.
40
- * @returns {{
41
- * tier: string|null, status: string|null, provider: string|null,
42
- * verdict: string|null, health: string|null,
43
- * sort: string|null, dir: string|null, view: string|null, q: string,
44
- * }}
45
- */
28
+ // 📖 Read the current URL params as a normalized object. Returns null on SSR
29
+ // 📖 or when the URL is invalid.
46
30
  function parseUrlParams() {
47
31
  if (typeof window === 'undefined') return null
48
32
  const params = new URLSearchParams(window.location.search)
49
33
  const out = {}
34
+ if (params.has('view') && VALID_VIEWS.has(params.get('view'))) out.view = params.get('view')
50
35
  if (params.has('tier') && VALID_TIERS.has(params.get('tier'))) out.tier = params.get('tier')
51
36
  if (params.has('status') && VALID_STATUS.has(params.get('status'))) out.status = params.get('status')
52
37
  if (params.has('provider')) out.provider = params.get('provider')
@@ -54,40 +39,116 @@ function parseUrlParams() {
54
39
  if (params.has('health')) out.health = params.get('health')
55
40
  if (params.has('sort') && VALID_SORTS.has(params.get('sort'))) out.sort = params.get('sort')
56
41
  if (params.has('dir') && VALID_DIRS.has(params.get('dir'))) out.dir = params.get('dir')
57
- if (params.has('view') && VALID_VIEWS.has(params.get('view'))) out.view = params.get('view')
58
42
  if (params.has('q')) out.q = params.get('q')
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'
45
+ if (params.has('palette') && params.get('palette') === 'open') out.palette = 'open'
59
46
  return out
60
47
  }
61
48
 
62
- /**
63
- * 📖 useUrlState: hydrate the Web view from the current URL on mount.
64
- * 📖 M1 = read-only. Pass setters; the hook calls them once on mount with
65
- * 📖 whatever the URL declares, then leaves the URL alone.
66
- *
67
- * 📖 The hook is intentionally non-intrusive: if the URL has no params, it does
68
- * 📖 nothing. The caller is free to call the setters independently — the URL
69
- * 📖 simply becomes the *initial* state.
70
- *
71
- * @param {{
72
- * currentView: string,
73
- * setCurrentView: (view: string) => void,
74
- * filterState: object | null, // wired in M2
75
- * }} opts
76
- */
77
- export function useUrlState({ currentView, setCurrentView, filterState }) {
49
+ // 📖 Build a URLSearchParams object from the live state. Pure for testing.
50
+ export function buildUrlParams(state) {
51
+ const params = new URLSearchParams()
52
+ if (!state) return params
53
+ if (state.currentView && state.currentView !== 'dashboard') params.set('view', state.currentView)
54
+ if (state.filterTier && state.filterTier !== 'all') params.set('tier', state.filterTier)
55
+ if (state.filterStatus && state.filterStatus !== 'all') params.set('status', state.filterStatus)
56
+ if (state.filterProvider && state.filterProvider !== 'all') params.set('provider', state.filterProvider)
57
+ if (state.filterVerdict && state.filterVerdict !== 'all') params.set('verdict', state.filterVerdict)
58
+ if (state.filterHealth && state.filterHealth !== 'all') params.set('health', state.filterHealth)
59
+ if (state.sortColumn) {
60
+ params.set('sort', state.sortColumn)
61
+ if (state.sortDirection) params.set('dir', state.sortDirection)
62
+ }
63
+ if (state.searchQuery) params.set('q', state.searchQuery)
64
+ if (state.toolMode) params.set('toolMode', state.toolMode)
65
+ if (state.paletteOpen) params.set('palette', 'open')
66
+ return params
67
+ }
68
+
69
+ // 📖 Debounced write-back helper. We don't want to push 5 history entries per
70
+ // 📖 second when the user is typing in the search box.
71
+ function writeUrl(state) {
72
+ if (typeof window === 'undefined') return
73
+ const params = buildUrlParams(state)
74
+ const search = params.toString()
75
+ const newUrl = search
76
+ ? `${window.location.pathname}?${search}${window.location.hash}`
77
+ : `${window.location.pathname}${window.location.hash}`
78
+ // 📖 replaceState (not pushState) so back/forward don't fill up with
79
+ // 📖 every keystroke. The current URL still updates visibly.
80
+ window.history.replaceState({}, '', newUrl)
81
+ }
82
+
83
+ export function useUrlState({
84
+ currentView, setCurrentView,
85
+ filterState = null,
86
+ paletteOpen = false, setPaletteOpen = () => {},
87
+ toolMode = null, setToolMode = () => {},
88
+ }) {
89
+ // 📖 Track a debounce handle so we can coalesce rapid filter / sort changes.
90
+ const writeTimerRef = useRef(null)
91
+
92
+ // 📖 Hydrate from URL on mount, exactly once. We don't want to re-hydrate
93
+ // 📖 on every render — the user's actions should drive the URL, not vice versa.
94
+ const hydratedRef = useRef(false)
78
95
  useEffect(() => {
96
+ if (hydratedRef.current) return
97
+ hydratedRef.current = true
98
+
79
99
  const params = parseUrlParams()
80
100
  if (!params) return
81
101
 
82
- if (params.view && params.view !== currentView) {
102
+ if (params.view && setCurrentView && params.view !== currentView) {
83
103
  setCurrentView(params.view)
84
104
  }
105
+ if (params.tier && filterState?.setFilterTier) filterState.setFilterTier(params.tier)
106
+ if (params.status && filterState?.setFilterStatus) filterState.setFilterStatus(params.status)
107
+ if (params.provider && filterState?.setFilterProvider) filterState.setFilterProvider(params.provider)
108
+ if (params.verdict && filterState?.setFilterVerdict) filterState.setFilterVerdict(params.verdict)
109
+ if (params.health && filterState?.setFilterHealth) filterState.setFilterHealth(params.health)
110
+ if (params.sort && filterState?.toggleSort) {
111
+ // 📖 Re-trigger sort: pass the column to setSortColumn + setSortDirection
112
+ // 📖 The hook exposes toggleSort which is a 3-state cycle; for hydration
113
+ // 📖 we want a deterministic state, so we use the lower-level setters.
114
+ if (filterState.setSortColumn) filterState.setSortColumn(params.sort)
115
+ if (params.dir && filterState.setSortDirection) filterState.setSortDirection(params.dir)
116
+ }
117
+ if (params.q !== undefined && filterState?.setSearchQuery) filterState.setSearchQuery(params.q)
118
+ if (params.toolMode && setToolMode) setToolMode(params.toolMode)
119
+ if (params.palette === 'open' && setPaletteOpen) setPaletteOpen(true)
120
+ // eslint-disable-next-line react-hooks/exhaustive-deps
121
+ }, [])
85
122
 
86
- // 📖 Filter hydration is wired through the filterState prop in M2. M1 just
87
- // 📖 sets the view; the rest of the URL params act as documentation for
88
- // 📖 the user and get the write-back loop fully closed in M2.
89
- }, [currentView, setCurrentView, filterState])
123
+ // 📖 Write-back: any change to currentView / filter / toolMode / palette
124
+ // 📖 updates the URL. Debounced at 80ms so rapid filter typing doesn't
125
+ // 📖 thrash the history stack.
126
+ useEffect(() => {
127
+ if (!hydratedRef.current) return
128
+ if (writeTimerRef.current) clearTimeout(writeTimerRef.current)
129
+ writeTimerRef.current = setTimeout(() => {
130
+ writeUrl({
131
+ currentView,
132
+ filterTier: filterState?.filterTier,
133
+ filterStatus: filterState?.filterStatus,
134
+ filterProvider: filterState?.filterProvider,
135
+ filterVerdict: filterState?.filterVerdict,
136
+ filterHealth: filterState?.filterHealth,
137
+ sortColumn: filterState?.sortColumn,
138
+ sortDirection: filterState?.sortDirection,
139
+ searchQuery: filterState?.searchQuery,
140
+ toolMode,
141
+ paletteOpen,
142
+ })
143
+ }, 80)
144
+ return () => {
145
+ if (writeTimerRef.current) clearTimeout(writeTimerRef.current)
146
+ }
147
+ }, [
148
+ currentView, paletteOpen, toolMode,
149
+ filterState?.filterTier, filterState?.filterStatus, filterState?.filterProvider,
150
+ filterState?.filterVerdict, filterState?.filterHealth,
151
+ filterState?.sortColumn, filterState?.sortDirection,
152
+ filterState?.searchQuery,
153
+ ])
90
154
  }
91
-
92
- // 📖 Re-export the parser so M2's write-back path can use the same validator.
93
- export { parseUrlParams, VALID_TIERS, VALID_STATUS, VALID_SORTS, VALID_VIEWS, VALID_DIRS }
@@ -0,0 +1,55 @@
1
+ /**
2
+ * @file web/src/utils/m3.js
3
+ * @description Small pure helpers for M3 Web parity tests. They keep payload
4
+ * validation/formatting deterministic without importing React components.
5
+ *
6
+ * @functions
7
+ * → recommendScoreShape — validates `/api/recommend` Top 3 payload shape
8
+ * → toolInstallSummary — formats a tool install plan for display
9
+ * @exports INSTALL_ENDPOINT_TOOL_MODES, recommendScoreShape, toolInstallSummary
10
+ */
11
+
12
+ export const INSTALL_ENDPOINT_TOOL_MODES = [
13
+ 'opencode',
14
+ 'opencode-desktop',
15
+ 'opencode-web',
16
+ 'openclaw',
17
+ 'crush',
18
+ 'goose',
19
+ 'pi',
20
+ 'aider',
21
+ 'qwen',
22
+ 'openhands',
23
+ 'amp',
24
+ 'forgecode',
25
+ 'fcm_router',
26
+ ]
27
+
28
+ export function recommendScoreShape(payload) {
29
+ const top3 = Array.isArray(payload?.top3) ? payload.top3 : []
30
+ return top3.every((entry) => {
31
+ const result = entry?.result
32
+ return Boolean(result)
33
+ && typeof result.providerKey === 'string'
34
+ && typeof result.modelId === 'string'
35
+ && typeof result.label === 'string'
36
+ && typeof entry.score === 'number'
37
+ && entry.score >= 0
38
+ && entry.score <= 100
39
+ && typeof entry.reason === 'string'
40
+ && entry.reason.length > 0
41
+ })
42
+ }
43
+
44
+ export function toolInstallSummary(plan) {
45
+ if (!plan || typeof plan !== 'object') {
46
+ return { supported: false, title: 'Unknown tool', command: null, docsUrl: null, note: null }
47
+ }
48
+ return {
49
+ supported: plan.supported === true,
50
+ title: plan.summary || plan.reason || `Install ${plan.mode || 'tool'}`,
51
+ command: typeof plan.shellCommand === 'string' ? plan.shellCommand : null,
52
+ docsUrl: typeof plan.docsUrl === 'string' ? plan.docsUrl : null,
53
+ note: typeof plan.note === 'string' ? plan.note : null,
54
+ }
55
+ }