free-coding-models 0.5.86 → 0.5.88

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 (165) hide show
  1. package/README.md +109 -888
  2. package/bin/free-coding-models.js +4 -1
  3. package/changelog/v0.5.87.md +48 -0
  4. package/changelog/v0.5.88.md +16 -0
  5. package/package.json +13 -5
  6. package/sources.js +4 -4
  7. package/src/core/cache.js +3 -1
  8. package/src/core/config.js +43 -5
  9. package/src/core/endpoint-installer.js +23 -12
  10. package/src/core/installed-models-manager.js +43 -20
  11. package/src/core/kilo-config.js +51 -5
  12. package/src/core/kilo.js +5 -3
  13. package/src/core/legacy-proxy-cleanup.js +22 -5
  14. package/src/core/model-merger.js +4 -0
  15. package/src/core/models-drift.js +9 -0
  16. package/src/core/opencode-config.js +57 -5
  17. package/src/core/opencode.js +67 -41
  18. package/src/core/playground.js +7 -0
  19. package/src/core/probe-cache.js +35 -4
  20. package/src/core/provider-key-tester.js +10 -2
  21. package/src/core/provider-metadata.js +10 -0
  22. package/src/core/provider-quota-fetchers.js +14 -7
  23. package/src/core/router-daemon.js +208 -26
  24. package/src/core/runtime-telemetry.js +27 -1
  25. package/src/core/security.js +170 -24
  26. package/src/core/shared-helpers.js +12 -1
  27. package/src/core/shell-env.js +3 -1
  28. package/src/core/telemetry.js +18 -5
  29. package/src/core/tool-launchers.js +9 -6
  30. package/src/core/updater.js +28 -2
  31. package/src/core/utils.js +173 -71
  32. package/src/tui/app.js +38 -14
  33. package/src/tui/command-palette.js +22 -2
  34. package/src/tui/key-handler.js +123 -179
  35. package/src/tui/overlays.js +21 -71
  36. package/src/tui/render-helpers.js +62 -4
  37. package/src/tui/render-table.js +34 -3
  38. package/src/tui/theme.js +36 -5
  39. package/src/tui/tui-filters.js +28 -8
  40. package/src/tui/tui-state.js +22 -6
  41. package/web/dist/assets/index-CAzFIt8P.css +1 -0
  42. package/web/dist/assets/index-DFg1h0Nd.js +44 -0
  43. package/web/dist/index.html +5 -9
  44. package/web/index.html +3 -7
  45. package/web/server.js +97 -23
  46. package/web/vite.config.js +8 -0
  47. package/web/app.legacy.js +0 -900
  48. package/web/dist/assets/index-C5-Ywvv3.css +0 -1
  49. package/web/dist/assets/index-Dm-Xfby9.js +0 -40
  50. package/web/public/favicon.ico +0 -0
  51. package/web/public/favicons/apple-touch-icon.png +0 -0
  52. package/web/public/favicons/browserconfig.xml +0 -12
  53. package/web/public/favicons/favicon-16x16.png +0 -0
  54. package/web/public/favicons/favicon-192x192.png +0 -0
  55. package/web/public/favicons/favicon-32x32.png +0 -0
  56. package/web/public/favicons/favicon-48x48.png +0 -0
  57. package/web/public/favicons/favicon-512x512.png +0 -0
  58. package/web/public/favicons/favicon-96x96.png +0 -0
  59. package/web/public/favicons/favicon.ico +0 -0
  60. package/web/public/favicons/mstile-150x150.png +0 -0
  61. package/web/public/favicons/mstile-310x150.png +0 -0
  62. package/web/public/favicons/mstile-310x310.png +0 -0
  63. package/web/public/favicons/mstile-512x512.png +0 -0
  64. package/web/public/favicons/mstile-70x70.png +0 -0
  65. package/web/public/favicons/site.webmanifest +0 -25
  66. package/web/src/App.jsx +0 -521
  67. package/web/src/components/analytics/AnalyticsView.jsx +0 -147
  68. package/web/src/components/analytics/AnalyticsView.module.css +0 -190
  69. package/web/src/components/analytics/TokenUsagePanel.jsx +0 -105
  70. package/web/src/components/analytics/TokenUsagePanel.module.css +0 -126
  71. package/web/src/components/atoms/AILatencyCell.jsx +0 -38
  72. package/web/src/components/atoms/AILatencyCell.module.css +0 -51
  73. package/web/src/components/atoms/HealthCell.jsx +0 -143
  74. package/web/src/components/atoms/HealthCell.module.css +0 -60
  75. package/web/src/components/atoms/LastPingCell.jsx +0 -35
  76. package/web/src/components/atoms/LastPingCell.module.css +0 -45
  77. package/web/src/components/atoms/MoodCell.jsx +0 -25
  78. package/web/src/components/atoms/MoodCell.module.css +0 -6
  79. package/web/src/components/atoms/NoKeyIcon.jsx +0 -65
  80. package/web/src/components/atoms/NoKeyIcon.module.css +0 -30
  81. package/web/src/components/atoms/ProviderLogo.jsx +0 -187
  82. package/web/src/components/atoms/ProviderLogo.module.css +0 -103
  83. package/web/src/components/atoms/RankCell.jsx +0 -9
  84. package/web/src/components/atoms/RankCell.module.css +0 -9
  85. package/web/src/components/atoms/Sparkline.jsx +0 -63
  86. package/web/src/components/atoms/StabilityCell.jsx +0 -18
  87. package/web/src/components/atoms/StabilityCell.module.css +0 -13
  88. package/web/src/components/atoms/StatusDot.jsx +0 -13
  89. package/web/src/components/atoms/StatusDot.module.css +0 -29
  90. package/web/src/components/atoms/TPSCell.jsx +0 -36
  91. package/web/src/components/atoms/TPSCell.module.css +0 -44
  92. package/web/src/components/atoms/TierBadge.jsx +0 -10
  93. package/web/src/components/atoms/TierBadge.module.css +0 -29
  94. package/web/src/components/atoms/Toast.jsx +0 -25
  95. package/web/src/components/atoms/Toast.module.css +0 -41
  96. package/web/src/components/atoms/ToastContainer.jsx +0 -16
  97. package/web/src/components/atoms/ToastContainer.module.css +0 -10
  98. package/web/src/components/atoms/VerdictBadge.jsx +0 -36
  99. package/web/src/components/atoms/VerdictBadge.module.css +0 -40
  100. package/web/src/components/changelog/ChangelogView.jsx +0 -135
  101. package/web/src/components/changelog/ChangelogView.module.css +0 -159
  102. package/web/src/components/dashboard/DetailPanel.jsx +0 -217
  103. package/web/src/components/dashboard/DetailPanel.module.css +0 -260
  104. package/web/src/components/dashboard/ExpandedDetailRow.jsx +0 -361
  105. package/web/src/components/dashboard/ExpandedDetailRow.module.css +0 -356
  106. package/web/src/components/dashboard/ExportModal.jsx +0 -84
  107. package/web/src/components/dashboard/ExportModal.module.css +0 -99
  108. package/web/src/components/dashboard/FilterBar.jsx +0 -338
  109. package/web/src/components/dashboard/FilterBar.module.css +0 -342
  110. package/web/src/components/dashboard/ModelTable.jsx +0 -672
  111. package/web/src/components/dashboard/ModelTable.module.css +0 -446
  112. package/web/src/components/dashboard/ProviderDropdown.jsx +0 -156
  113. package/web/src/components/dashboard/ProviderDropdown.module.css +0 -248
  114. package/web/src/components/help/HelpView.jsx +0 -201
  115. package/web/src/components/help/HelpView.module.css +0 -156
  116. package/web/src/components/install/InstallEndpointsView.jsx +0 -309
  117. package/web/src/components/install/InstallEndpointsView.module.css +0 -427
  118. package/web/src/components/installed/InstalledModelsView.jsx +0 -89
  119. package/web/src/components/installed/InstalledModelsView.module.css +0 -200
  120. package/web/src/components/launch/IncompatibleFallbackModal.jsx +0 -69
  121. package/web/src/components/launch/LaunchButton.jsx +0 -30
  122. package/web/src/components/launch/LaunchButton.module.css +0 -35
  123. package/web/src/components/launch/LaunchModal.module.css +0 -125
  124. package/web/src/components/layout/Footer.jsx +0 -19
  125. package/web/src/components/layout/Footer.module.css +0 -10
  126. package/web/src/components/layout/Header.jsx +0 -265
  127. package/web/src/components/layout/Header.module.css +0 -357
  128. package/web/src/components/palette/CommandPalette.jsx +0 -301
  129. package/web/src/components/palette/CommandPalette.module.css +0 -121
  130. package/web/src/components/playground/PlaygroundChat.jsx +0 -527
  131. package/web/src/components/playground/PlaygroundChat.module.css +0 -382
  132. package/web/src/components/playground/PlaygroundView.jsx +0 -371
  133. package/web/src/components/playground/PlaygroundView.module.css +0 -504
  134. package/web/src/components/recommend/RecommendView.jsx +0 -121
  135. package/web/src/components/recommend/RecommendView.module.css +0 -57
  136. package/web/src/components/router/RouterView.jsx +0 -1212
  137. package/web/src/components/router/RouterView.module.css +0 -1289
  138. package/web/src/components/settings/SettingsView.jsx +0 -555
  139. package/web/src/components/settings/SettingsView.module.css +0 -550
  140. package/web/src/components/tools/ToolPicker.jsx +0 -86
  141. package/web/src/components/tools/ToolPicker.module.css +0 -97
  142. package/web/src/components/update/UpdateChip.jsx +0 -104
  143. package/web/src/components/update/UpdateChip.module.css +0 -146
  144. package/web/src/global.css +0 -298
  145. package/web/src/hooks/urlState.constants.js +0 -28
  146. package/web/src/hooks/useChangelog.js +0 -51
  147. package/web/src/hooks/useColumnSizing.js +0 -211
  148. package/web/src/hooks/useFavorites.js +0 -150
  149. package/web/src/hooks/useFilter.js +0 -310
  150. package/web/src/hooks/useInstalledModels.js +0 -42
  151. package/web/src/hooks/useRecommend.js +0 -67
  152. package/web/src/hooks/useRouterDashboard.js +0 -133
  153. package/web/src/hooks/useSSE.js +0 -73
  154. package/web/src/hooks/useSocket.js +0 -203
  155. package/web/src/hooks/useTheme.js +0 -83
  156. package/web/src/hooks/useTokenUsage.js +0 -103
  157. package/web/src/hooks/useToolMode.js +0 -77
  158. package/web/src/hooks/useUpdateChecker.js +0 -91
  159. package/web/src/hooks/useUrlState.js +0 -170
  160. package/web/src/main.jsx +0 -15
  161. package/web/src/utils/download.js +0 -15
  162. package/web/src/utils/format.js +0 -42
  163. package/web/src/utils/m3.js +0 -57
  164. package/web/src/utils/ranks.js +0 -37
  165. package/web/styles.legacy.css +0 -963
@@ -1,211 +0,0 @@
1
- /**
2
- * @file web/src/hooks/useColumnSizing.js
3
- * @description React hook for resizable table columns with localStorage persistence.
4
- *
5
- * 📖 Wires TanStack Table's `columnSizing` state to a localStorage entry so the user's
6
- * 📖 custom column widths survive page reloads and Dashboard/Desktop restarts.
7
- *
8
- * 📖 Storage strategy:
9
- * 📖 - Key: `fcm.columnSizing.v1`
10
- * 📖 - Value: JSON object { [columnId]: number } with the user's chosen widths in px
11
- * 📖 - Read once on mount, written on every sizing change (debounced via React batching)
12
- *
13
- * 📖 Why a versioned key:
14
- * 📖 - Future column additions or remodels can invalidate by bumping the suffix.
15
- * 📖 - Old saved widths won't silently apply to a table whose columns have changed.
16
- *
17
- * 📖 Why not Zustand/Redux:
18
- * 📖 - localStorage is the simplest possible cross-tab/cross-session persistence layer.
19
- * 📖 - The data is 200 bytes max — no need for IndexedDB or a migration story.
20
- * 📖 - SSR safety: every localStorage access is guarded with `typeof window !== 'undefined'`.
21
- *
22
- * @functions
23
- * → useColumnSizing(defaultSizing) — returns { columnSizing, setColumnSizing, resetColumnSizing, hasCustomSizing }
24
- * → sanitizeSizing / clampSizing / mergeSizing / hasCustomSizing — pure helpers (unit-tested)
25
- * → readSizingFromStorage / writeSizingToStorage / removeSizingFromStorage — storage adapters
26
- * @exports useColumnSizing, sanitizeSizing, clampSizing, mergeSizing, hasCustomSizing,
27
- * readSizingFromStorage, writeSizingToStorage, removeSizingFromStorage,
28
- * COLUMN_SIZING_STORAGE_KEY, COLUMN_SIZING_MIN, COLUMN_SIZING_MAX
29
- */
30
-
31
- import { useState, useCallback, useEffect, useRef } from 'react'
32
-
33
- export const COLUMN_SIZING_STORAGE_KEY = 'fcm.columnSizing.v1'
34
-
35
- // 📖 Bounds (px) for every column width. Mirrored in the model's CSS so the
36
- // 📖 rendering layer never has to ask the hook about min/max.
37
- export const COLUMN_SIZING_MIN = 24
38
- export const COLUMN_SIZING_MAX = 1200
39
-
40
- /**
41
- * 📖 sanitizeSizing: Validate + clean an arbitrary object into a { id: number } map.
42
- * 📖 Strips non-numbers, NaN, Infinity, zero, and negative widths.
43
- * 📖 Exported for unit testing and for any future code path that ingests raw input.
44
- */
45
- export function sanitizeSizing(input) {
46
- if (!input || typeof input !== 'object' || Array.isArray(input)) return {}
47
- const out = {}
48
- for (const [k, v] of Object.entries(input)) {
49
- if (typeof v === 'number' && Number.isFinite(v) && v > 0) out[k] = v
50
- }
51
- return out
52
- }
53
-
54
- /**
55
- * 📖 clampSizing: Force every value into [min, max], rounded to an integer.
56
- * 📖 Drops entries whose value is not a finite number so a typo in storage
57
- * 📖 never crashes the dashboard.
58
- */
59
- export function clampSizing(sizing, min = COLUMN_SIZING_MIN, max = COLUMN_SIZING_MAX) {
60
- const out = {}
61
- for (const [k, v] of Object.entries(sizing || {})) {
62
- if (typeof v !== 'number' || !Number.isFinite(v)) continue
63
- out[k] = Math.max(min, Math.min(max, Math.round(v)))
64
- }
65
- return out
66
- }
67
-
68
- /**
69
- * 📖 mergeSizing: Layer the stored widths on top of the defaults, with the
70
- * 📖 runtime (in-memory) state winning on conflicts. This three-way merge is
71
- * 📖 what keeps new columns (added in newer releases) from disappearing when
72
- * 📖 a stale localStorage entry is loaded.
73
- */
74
- export function mergeSizing(defaults, stored, runtime) {
75
- return { ...(defaults || {}), ...(stored || {}), ...(runtime || {}) }
76
- }
77
-
78
- /**
79
- * 📖 hasCustomSizing: True when at least one column differs from its default.
80
- * 📖 Used by the UI to decide whether to show a "Reset" affordance.
81
- */
82
- export function hasCustomSizing(current, defaults) {
83
- if (!current || !defaults) return false
84
- for (const k of Object.keys(current)) {
85
- if (current[k] !== defaults[k]) return true
86
- }
87
- return false
88
- }
89
-
90
- /**
91
- * 📖 readSizingFromStorage: Read JSON from a localStorage-like object, returns
92
- * 📖 a sanitized empty object on any failure. Exported separately from the
93
- * 📖 React hook so unit tests can drive it without a DOM.
94
- *
95
- * 📖 @param {Storage|null|undefined} storage - the localStorage-like object
96
- * 📖 @param {string} key - the storage key
97
- * 📖 @param {Object} fallback - default if missing/empty
98
- */
99
- export function readSizingFromStorage(storage, key, fallback = {}) {
100
- if (!storage || typeof storage.getItem !== 'function') return fallback
101
- let raw
102
- try { raw = storage.getItem(key) } catch { return fallback }
103
- if (!raw) return fallback
104
- let parsed
105
- try { parsed = JSON.parse(raw) } catch { return fallback }
106
- return sanitizeSizing(parsed)
107
- }
108
-
109
- /**
110
- * 📖 writeSizingToStorage: Persist a sizing object to a localStorage-like store.
111
- * 📖 Returns true on success, false on any failure (quota, private mode, etc.).
112
- */
113
- export function writeSizingToStorage(storage, key, sizing) {
114
- if (!storage || typeof storage.setItem !== 'function') return false
115
- try {
116
- storage.setItem(key, JSON.stringify(sizing))
117
- return true
118
- } catch {
119
- return false
120
- }
121
- }
122
-
123
- /**
124
- * 📖 removeSizingFromStorage: Best-effort delete; used when the runtime sizing
125
- * 📖 returns to defaults so we don't keep stale entries around.
126
- */
127
- export function removeSizingFromStorage(storage, key) {
128
- if (!storage || typeof storage.removeItem !== 'function') return false
129
- try { storage.removeItem(key); return true } catch { return false }
130
- }
131
-
132
- /**
133
- * 📖 useColumnSizing: TanStack-compatible state for the table's columnSizing slot.
134
- *
135
- * 📖 Pass the default sizing object (e.g. `{ mood: 28, idx: 36, ... }`) used to seed
136
- * 📖 new sessions; it also acts as the "reset to defaults" target.
137
- *
138
- * @param {Object} defaultSizing - { [columnId]: number } — defaults for the current column set
139
- * @returns {{
140
- * columnSizing: Object,
141
- * setColumnSizing: (updater: Object | ((prev: Object) => Object)) => void,
142
- * resetColumnSizing: () => void,
143
- * hasCustomSizing: boolean,
144
- * }}
145
- */
146
- export function useColumnSizing(defaultSizing) {
147
- const defaultRef = useRef(defaultSizing)
148
- defaultRef.current = defaultSizing
149
-
150
- const [columnSizing, setColumnSizingState] = useState(() => {
151
- // 📖 SSR / unit-test safe: in Node, window is undefined and we fall back to defaults.
152
- if (typeof window === 'undefined') return { ...defaultSizing }
153
- const stored = readSizingFromStorage(window.localStorage, COLUMN_SIZING_STORAGE_KEY, {})
154
- // 📖 Merge: defaults first, then stored overrides. New columns added in a
155
- // 📖 future release still get their declared default width.
156
- return mergeSizing(defaultSizing, stored, {})
157
- })
158
-
159
- // 📖 Keep a ref to the latest sizing for the cross-tab sync listener.
160
- const sizingRef = useRef(columnSizing)
161
- sizingRef.current = columnSizing
162
-
163
- // 📖 Persist on every change. React batches updates, so this only fires once per real change.
164
- useEffect(() => {
165
- if (typeof window === 'undefined' || !window.localStorage) return
166
- // 📖 When the runtime matches the defaults, drop the stored entry so a "reset"
167
- // 📖 is also persisted (no orphan entry, no waste of quota).
168
- if (!hasCustomSizing(columnSizing, defaultRef.current)) {
169
- removeSizingFromStorage(window.localStorage, COLUMN_SIZING_STORAGE_KEY)
170
- return
171
- }
172
- writeSizingToStorage(window.localStorage, COLUMN_SIZING_STORAGE_KEY, columnSizing)
173
- }, [columnSizing])
174
-
175
- // 📖 Cross-tab sync: if the user resizes columns in another tab/window, apply it live here.
176
- useEffect(() => {
177
- if (typeof window === 'undefined' || !window.addEventListener) return
178
- const onStorage = (e) => {
179
- if (e.key !== COLUMN_SIZING_STORAGE_KEY) return
180
- const next = readSizingFromStorage(window.localStorage, COLUMN_SIZING_STORAGE_KEY, {})
181
- // 📖 Merge incoming changes into the current default-sourced state so removed
182
- // 📖 columns in the incoming payload don't drop widths for columns we still show.
183
- setColumnSizingState((prev) => mergeSizing(defaultRef.current, next, prev))
184
- }
185
- window.addEventListener('storage', onStorage)
186
- return () => window.removeEventListener('storage', onStorage)
187
- }, [])
188
-
189
- const setColumnSizing = useCallback((updater) => {
190
- setColumnSizingState((prev) => {
191
- const next = typeof updater === 'function' ? updater(prev) : updater
192
- // 📖 Clamp every value: TanStack can momentarily receive 0-width from a drag,
193
- // 📖 but rendering a 0-width column breaks layout.
194
- return { ...prev, ...clampSizing(next) }
195
- })
196
- }, [])
197
-
198
- const resetColumnSizing = useCallback(() => {
199
- setColumnSizingState({ ...defaultRef.current })
200
- }, [])
201
-
202
- // 📖 hasCustomSizing is memoized cheaply — one pass over the current keys is enough.
203
- const isCustom = hasCustomSizing(columnSizing, defaultRef.current)
204
-
205
- return {
206
- columnSizing,
207
- setColumnSizing,
208
- resetColumnSizing,
209
- hasCustomSizing: isCustom,
210
- }
211
- }
@@ -1,150 +0,0 @@
1
- /**
2
- * @file web/src/hooks/useFavorites.js
3
- * @description Favorites management hook for the Web Dashboard — M1 parity with TUI `F` / `Y` / `Shift+↑↓`.
4
- * 📖 Persists through the existing ~/.free-coding-models.json config file (same source as the TUI),
5
- * 📖 via the /api/favorites endpoint. Both surfaces share the same favorites list.
6
- *
7
- * 📖 Storage shape (mirrors src/core/favorites.js):
8
- * 📖 config.favorites — string[] of "providerKey/modelId", insertion-ordered
9
- * 📖 config.settings.favoritesPinnedAndSticky — boolean (TUI `Y` key behavior)
10
- *
11
- * 📖 Returns a stable shape the rest of the Web UI can consume:
12
- * 📖 {
13
- * 📖 favorites: string[], — current favorites in priority order
14
- * 📖 pinnedAndSticky: boolean, — true = favorites bypass filters
15
- * 📖 isFavorite(model): boolean, — predicate
16
- * 📖 favoriteRank(model): number, — 0-based index, Number.MAX_SAFE_INTEGER if not favorited
17
- * 📖 toggle(model): Promise<void>, — add/remove
18
- * 📖 reorder(model, dir): Promise<void>, — 'up' or 'down'
19
- * 📖 setPinnedAndSticky(bool): Promise<void>,
20
- * 📖 refresh(): Promise<void>, — manual reload from server
21
- * 📖 loading: boolean, — initial load
22
- * 📖 }
23
- *
24
- * @functions
25
- * → useFavorites({ models }) — hook
26
- *
27
- * @see src/core/favorites.js — TUI engine (same storage format)
28
- * @see web/server.js — /api/favorites endpoint
29
- */
30
- import { useCallback, useEffect, useState, useMemo } from 'react'
31
-
32
- const EMPTY_FAVORITES = Object.freeze([])
33
-
34
- export function useFavorites({ models } = {}) {
35
- const [favorites, setFavorites] = useState(EMPTY_FAVORITES)
36
- const [pinnedAndSticky, setPinnedAndStickyState] = useState(false)
37
- const [loading, setLoading] = useState(true)
38
- const [error, setError] = useState(null)
39
-
40
- // 📖 Initial load: fetch the current favorites + pinnedAndSticky from the server.
41
- const refresh = useCallback(async () => {
42
- try {
43
- const resp = await fetch('/api/favorites')
44
- if (!resp.ok) throw new Error(`HTTP ${resp.status}`)
45
- const data = await resp.json()
46
- setFavorites(Array.isArray(data.favorites) ? data.favorites : [])
47
- setPinnedAndStickyState(Boolean(data.pinnedAndSticky))
48
- setError(null)
49
- } catch (err) {
50
- setError(err.message || 'Failed to load favorites')
51
- // 📖 On error, fall back to empty state — the UI stays usable.
52
- setFavorites(EMPTY_FAVORITES)
53
- setPinnedAndStickyState(false)
54
- } finally {
55
- setLoading(false)
56
- }
57
- }, [])
58
-
59
- useEffect(() => { refresh() }, [refresh])
60
-
61
- // 📖 Build a map "providerKey/modelId" → index for O(1) lookup.
62
- const favoriteIndex = useMemo(() => {
63
- const map = new Map()
64
- favorites.forEach((key, idx) => map.set(key, idx))
65
- return map
66
- }, [favorites])
67
-
68
- const isFavorite = useCallback((model) => {
69
- if (!model) return false
70
- const key = `${model.providerKey}/${model.modelId}`
71
- return favoriteIndex.has(key)
72
- }, [favoriteIndex])
73
-
74
- const favoriteRank = useCallback((model) => {
75
- if (!model) return Number.MAX_SAFE_INTEGER
76
- const key = `${model.providerKey}/${model.modelId}`
77
- const idx = favoriteIndex.get(key)
78
- return idx === undefined ? Number.MAX_SAFE_INTEGER : idx
79
- }, [favoriteIndex])
80
-
81
- // 📖 Build a key for the API — same shape the TUI uses (providerKey/modelId).
82
- const keyOf = useCallback((model) => {
83
- if (!model) return null
84
- return `${model.providerKey}/${model.modelId}`
85
- }, [])
86
-
87
- // 📖 Send the updated favorites list to the server.
88
- const persistFavorites = useCallback(async (next) => {
89
- try {
90
- const resp = await fetch('/api/favorites', {
91
- method: 'POST',
92
- headers: { 'Content-Type': 'application/json' },
93
- body: JSON.stringify({ favorites: next }),
94
- })
95
- if (!resp.ok) throw new Error(`HTTP ${resp.status}`)
96
- setFavorites(next)
97
- } catch (err) {
98
- setError(err.message || 'Failed to save favorites')
99
- }
100
- }, [])
101
-
102
- const toggle = useCallback(async (model) => {
103
- const key = keyOf(model)
104
- if (!key) return
105
- const exists = favoriteIndex.has(key)
106
- const next = exists
107
- ? favorites.filter((k) => k !== key)
108
- : [...favorites, key]
109
- await persistFavorites(next)
110
- }, [favorites, favoriteIndex, keyOf, persistFavorites])
111
-
112
- const reorder = useCallback(async (model, direction) => {
113
- const key = keyOf(model)
114
- if (!key) return
115
- const idx = favorites.indexOf(key)
116
- if (idx < 0) return
117
- const swapIdx = direction === 'up' ? idx - 1 : idx + 1
118
- if (swapIdx < 0 || swapIdx >= favorites.length) return
119
- const next = [...favorites]
120
- ;[next[idx], next[swapIdx]] = [next[swapIdx], next[idx]]
121
- await persistFavorites(next)
122
- }, [favorites, keyOf, persistFavorites])
123
-
124
- const setPinnedAndSticky = useCallback(async (value) => {
125
- setPinnedAndStickyState(Boolean(value))
126
- try {
127
- await fetch('/api/favorites', {
128
- method: 'POST',
129
- headers: { 'Content-Type': 'application/json' },
130
- body: JSON.stringify({ pinnedAndSticky: Boolean(value) }),
131
- })
132
- } catch (err) {
133
- setError(err.message || 'Failed to save display mode')
134
- }
135
- }, [])
136
-
137
- // 📖 Return a stable object — callers can destructure safely across renders.
138
- return {
139
- favorites,
140
- pinnedAndSticky,
141
- isFavorite,
142
- favoriteRank,
143
- toggle,
144
- reorder,
145
- setPinnedAndSticky,
146
- refresh,
147
- loading,
148
- error,
149
- }
150
- }
@@ -1,310 +0,0 @@
1
- /**
2
- * @file web/src/hooks/useFilter.js
3
- * @description React hook for model filtering and tri-state sorting state.
4
- *
5
- * 📖 M1 parity additions (vs the previous version):
6
- * 📖 - verdict filter cycle (`V` in TUI)
7
- * 📖 - health filter cycle (`H` in TUI)
8
- * 📖 - visibility mode cycle (`E` in TUI: Normal / Configured-only / Usable-only)
9
- * 📖 - custom text filter (Ctrl+P → "Apply text filter" in TUI)
10
- * 📖 - resetView() (`N` in TUI) — clears every filter back to defaults
11
- *
12
- * 📖 All TUI filter values (tier / status / provider / verdict / health)
13
- * 📖 are read in a single sorted pass so the visible list always matches the
14
- * 📖 header chip bar one-to-one.
15
- *
16
- * @functions
17
- * → useFilter(models) — filter + sort state for the dashboard table
18
- * @exports useFilter
19
- */
20
- import { useState, useMemo, useCallback } from 'react'
21
- import { tierRank, verdictRank, parseSwe } from '../utils/ranks.js'
22
- import { formatCtx } from '../utils/format.js'
23
-
24
- // 📖 TUI constants mirrored in JS so the Web chips use the same labels/cycle as the TUI.
25
- export const TIER_CYCLE = ['all', 'S+', 'S', 'A+', 'A', 'A-', 'B+', 'B', 'C']
26
- export const STATUS_CYCLE = ['all', 'up', 'down', 'pending']
27
- // 📖 Verdict filter: full set of verdict states the TUI cycles through.
28
- // 📖 `all` = no verdict filter (the TUI's null position in VERDICT_CYCLE).
29
- export const VERDICT_CYCLE = ['all', 'Perfect', 'Normal', 'Spiky', 'Slow', 'Overloaded', 'Down', 'Unstable', 'Pending']
30
- // 📖 Health filter: matches the TUI's HEALTH_CYCLE exactly.
31
- export const HEALTH_CYCLE = ['all', 'up', 'timeout', 'down', 'pending', 'noauth', 'auth_error']
32
- // 📖 Visibility mode: TUI's E key cycle — Normal / Configured-only / Usable-only.
33
- export const VISIBILITY_CYCLE = ['normal', 'configured', 'usable']
34
-
35
- function rankOrder(model) {
36
- return model.idx ?? 9999
37
- }
38
-
39
- function pingHistory(model) {
40
- return model.pingHistory || model.pings || []
41
- }
42
-
43
- function latestPingMs(model) {
44
- const hist = pingHistory(model)
45
- const latest = hist.length > 0 ? hist[hist.length - 1] : null
46
- return latest?.ms ?? null
47
- }
48
-
49
- function trendDelta(model) {
50
- const points = pingHistory(model)
51
- .map((point) => point?.ms)
52
- .filter((ms) => typeof ms === 'number' && Number.isFinite(ms))
53
- if (points.length < 2) return null
54
- return points[points.length - 1] - points[0]
55
- }
56
-
57
- function numericOrNull(value) {
58
- return typeof value === 'number' && Number.isFinite(value) ? value : null
59
- }
60
-
61
- function compareNullableNumber(aValue, bValue, direction) {
62
- const aMissing = aValue == null
63
- const bMissing = bValue == null
64
- if (aMissing && bMissing) return 0
65
- if (aMissing) return 1
66
- if (bMissing) return -1
67
- return (aValue - bValue) * direction
68
- }
69
-
70
- function compareNullableString(aValue, bValue, direction) {
71
- const aText = typeof aValue === 'string' ? aValue : ''
72
- const bText = typeof bValue === 'string' ? bValue : ''
73
- const aMissing = aText.length === 0
74
- const bMissing = bText.length === 0
75
- if (aMissing && bMissing) return 0
76
- if (aMissing) return 1
77
- if (bMissing) return -1
78
- return aText.localeCompare(bText) * direction
79
- }
80
-
81
- // 📖 Health order: same precedence as the TUI's HEALTH_CYCLE.
82
- // 📖 "up" is best, noauth/auth_error are worst.
83
- const HEALTH_ORDER = {
84
- up: 0, pending: 1, timeout: 2, down: 3, noauth: 4, auth_error: 5,
85
- }
86
-
87
- export function useFilter(models) {
88
- // 📖 Initial values match the TUI defaults (everything off, "all" everywhere).
89
- const [filterTier, setFilterTier] = useState('all')
90
- const [filterStatus, setFilterStatus] = useState('all')
91
- const [filterProvider, setFilterProvider] = useState('all')
92
- const [filterVerdict, setFilterVerdict] = useState('all')
93
- const [filterHealth, setFilterHealth] = useState('all')
94
- const [visibilityMode, setVisibilityMode] = useState('normal')
95
- const [searchQuery, setSearchQuery] = useState('')
96
- const [customTextFilter, setCustomTextFilter] = useState(null)
97
- const [sortColumn, setSortColumn] = useState('avg')
98
- const [sortDirection, setSortDirection] = useState('asc')
99
-
100
- // 📖 Cycle helpers: clicking the same chip advances to the next value in its
101
- // 📖 cycle, matching the TUI's single-key cycling (T / D / V / H / E).
102
- const cycleFilter = useCallback((current, cycle, setter) => {
103
- const idx = cycle.indexOf(current)
104
- setter(cycle[(idx + 1) % cycle.length])
105
- }, [])
106
-
107
- const cycleTier = useCallback(() => cycleFilter(filterTier, TIER_CYCLE, setFilterTier), [filterTier, cycleFilter])
108
- const cycleStatus = useCallback(() => cycleFilter(filterStatus, STATUS_CYCLE, setFilterStatus), [filterStatus, cycleFilter])
109
- const cycleVerdict = useCallback(() => cycleFilter(filterVerdict, VERDICT_CYCLE, setFilterVerdict), [filterVerdict, cycleFilter])
110
- const cycleHealth = useCallback(() => cycleFilter(filterHealth, HEALTH_CYCLE, setFilterHealth), [filterHealth, cycleFilter])
111
- const cycleVisibility = useCallback(() => cycleFilter(visibilityMode, VISIBILITY_CYCLE, setVisibilityMode), [visibilityMode, cycleFilter])
112
-
113
- const toggleSort = useCallback((col) => {
114
- if (sortColumn !== col) {
115
- setSortColumn(col)
116
- setSortDirection('asc')
117
- return
118
- }
119
- if (sortDirection === 'asc') {
120
- setSortDirection('desc')
121
- return
122
- }
123
- // 📖 Third click resets the column: no active sort, catalog/rank order.
124
- setSortColumn(null)
125
- setSortDirection('asc')
126
- }, [sortColumn, sortDirection])
127
-
128
- // 📖 Reset view — TUI's `N` key. Wipes every filter + sort back to default
129
- // 📖 and clears the search box. The custom text filter is part of the view
130
- // 📖 state and gets cleared too.
131
- const resetView = useCallback(() => {
132
- setFilterTier('all')
133
- setFilterStatus('all')
134
- setFilterProvider('all')
135
- setFilterVerdict('all')
136
- setFilterHealth('all')
137
- setVisibilityMode('normal')
138
- setSearchQuery('')
139
- setCustomTextFilter(null)
140
- setSortColumn('avg')
141
- setSortDirection('asc')
142
- }, [])
143
-
144
- const filtered = useMemo(() => {
145
- let result = [...models]
146
-
147
- // ── Tier ──
148
- if (filterTier !== 'all') result = result.filter((m) => m.tier === filterTier)
149
- // ── Status (coarser cycle that maps onto the TUI's "health" semantics) ──
150
- if (filterStatus !== 'all') {
151
- result = result.filter((m) => {
152
- if (filterStatus === 'up') return m.status === 'up'
153
- if (filterStatus === 'down') return m.status === 'down' || m.status === 'timeout'
154
- if (filterStatus === 'pending') return m.status === 'pending'
155
- return true
156
- })
157
- }
158
- // ── Provider ──
159
- if (filterProvider !== 'all') result = result.filter((m) => m.providerKey === filterProvider)
160
- // ── Verdict (TUI's `V` cycle) ──
161
- if (filterVerdict !== 'all') result = result.filter((m) => m.verdict === filterVerdict)
162
- // ── Health (TUI's `H` cycle — full granularity) ──
163
- if (filterHealth !== 'all') result = result.filter((m) => m.status === filterHealth)
164
- // ── Visibility mode (TUI's `E` cycle) ──
165
- if (visibilityMode === 'configured') {
166
- // 📖 Hide models with no API key OR auth/noauth errors. Keep timeout/429.
167
- result = result.filter((m) => m.hasApiKey && m.status !== 'noauth' && m.status !== 'auth_error')
168
- } else if (visibilityMode === 'usable') {
169
- // 📖 Only Health UP with verdict in the good set (Perfect / Normal / Slow).
170
- result = result.filter((m) => {
171
- if (m.status !== 'up') return false
172
- return ['Perfect', 'Normal', 'Slow'].includes(m.verdict)
173
- })
174
- }
175
- // ── Search + custom text filter (TUI's Ctrl+P "Apply text filter") ──
176
- const searchLower = searchQuery.trim().toLowerCase()
177
- const customLower = (customTextFilter || '').trim().toLowerCase()
178
- if (searchLower) {
179
- result = result.filter((m) => (
180
- m.label.toLowerCase().includes(searchLower) ||
181
- m.modelId.toLowerCase().includes(searchLower) ||
182
- m.origin.toLowerCase().includes(searchLower) ||
183
- m.tier.toLowerCase().includes(searchLower) ||
184
- (m.verdict || '').toLowerCase().includes(searchLower)
185
- ))
186
- }
187
- if (customLower) {
188
- result = result.filter((m) => (
189
- m.label.toLowerCase().includes(customLower) ||
190
- m.modelId.toLowerCase().includes(customLower) ||
191
- m.origin.toLowerCase().includes(customLower) ||
192
- (m.ctx || '').toLowerCase().includes(customLower) ||
193
- (m.providerKey || '').toLowerCase().includes(customLower)
194
- ))
195
- }
196
-
197
- result.sort((a, b) => {
198
- if (!sortColumn) return rankOrder(a) - rankOrder(b)
199
-
200
- const direction = sortDirection === 'desc' ? -1 : 1
201
- let cmp = 0
202
-
203
- if (sortColumn === 'mood') {
204
- cmp = compareNullableNumber(verdictRank(a.verdict), verdictRank(b.verdict), direction)
205
- } else if (sortColumn === 'idx') {
206
- cmp = compareNullableNumber(rankOrder(a), rankOrder(b), direction)
207
- } else if (sortColumn === 'tier') {
208
- cmp = compareNullableNumber(tierRank(a.tier), tierRank(b.tier), direction)
209
- } else if (sortColumn === 'label') {
210
- cmp = compareNullableString(a.label, b.label, direction)
211
- } else if (sortColumn === 'origin') {
212
- cmp = compareNullableString(a.origin, b.origin, direction)
213
- } else if (sortColumn === 'sweScore') {
214
- cmp = compareNullableNumber(parseSwe(a.sweScore), parseSwe(b.sweScore), direction)
215
- } else if (sortColumn === 'ctx') {
216
- cmp = compareNullableNumber(formatCtx(a.ctx), formatCtx(b.ctx), direction)
217
- } else if (sortColumn === 'latestPing') {
218
- cmp = compareNullableNumber(latestPingMs(a), latestPingMs(b), direction)
219
- } else if (sortColumn === 'avg') {
220
- const aAvg = a.avg == null || a.avg === Infinity || a.avg > 99000 ? null : a.avg
221
- const bAvg = b.avg == null || b.avg === Infinity || b.avg > 99000 ? null : b.avg
222
- cmp = compareNullableNumber(aAvg, bAvg, direction)
223
- } else if (sortColumn === 'condition') {
224
- cmp = compareNullableNumber(HEALTH_ORDER[a.status] ?? 9, HEALTH_ORDER[b.status] ?? 9, direction)
225
- } else if (sortColumn === 'verdict') {
226
- cmp = compareNullableNumber(verdictRank(a.verdict), verdictRank(b.verdict), direction)
227
- } else if (sortColumn === 'stability') {
228
- cmp = compareNullableNumber(numericOrNull(a.stability), numericOrNull(b.stability), direction)
229
- } else if (sortColumn === 'uptime') {
230
- cmp = compareNullableNumber(numericOrNull(a.uptime), numericOrNull(b.uptime), direction)
231
- } else if (sortColumn === 'aiLatency') {
232
- cmp = compareNullableNumber(a.benchmark?.ok ? a.benchmark.totalMs : null, b.benchmark?.ok ? b.benchmark.totalMs : null, direction)
233
- } else if (sortColumn === 'tps') {
234
- cmp = compareNullableNumber(a.benchmark?.ok ? a.benchmark.tokensPerSecond ?? 0 : null, b.benchmark?.ok ? b.benchmark.tokensPerSecond ?? 0 : null, direction)
235
- } else if (sortColumn === 'quota') {
236
- cmp = compareNullableNumber(numericOrNull(a.usagePercent), numericOrNull(b.usagePercent), direction)
237
- } else if (sortColumn === 'trend') {
238
- cmp = compareNullableNumber(trendDelta(a), trendDelta(b), direction)
239
- }
240
-
241
- return cmp || (rankOrder(a) - rankOrder(b))
242
- })
243
-
244
- return result
245
- }, [models, filterTier, filterStatus, filterProvider, filterVerdict, filterHealth, visibilityMode, searchQuery, customTextFilter, sortColumn, sortDirection])
246
-
247
- return {
248
- filtered,
249
- filterTier, setFilterTier, cycleTier,
250
- filterStatus, setFilterStatus, cycleStatus,
251
- filterProvider, setFilterProvider,
252
- filterVerdict, setFilterVerdict, cycleVerdict,
253
- filterHealth, setFilterHealth, cycleHealth,
254
- visibilityMode, setVisibilityMode, cycleVisibility,
255
- searchQuery, setSearchQuery,
256
- customTextFilter, setCustomTextFilter,
257
- sortColumn, sortDirection, toggleSort,
258
- resetView,
259
- }
260
- }
261
-
262
- // ─── 3-bucket sort helpers (extracted for unit testing) ────────────────
263
- // 📖 The AI Latency + TPS columns sort rows by a "bucket" then by value:
264
- // 📖 bucket 0 = completed benchmark (has a real result)
265
- // 📖 bucket 1 = benchmark currently running (no result yet, but isBenchmarking)
266
- // 📖 bucket 2 = never tested (no benchmark object, not running)
267
- // 📖 Rows in buckets 1 and 2 are NOT sorted by their numeric value (they don't
268
- // 📖 have one) — the comparator returns 0 inside each bucket so the rank-based
269
- // 📖 tie-breaker takes over.
270
-
271
- /**
272
- * 📖 Classify a model into one of the 3 benchmark buckets.
273
- * @param {{benchmark?: {ok?: boolean}|null, isBenchmarking?: boolean}} model
274
- * @returns {0|1|2}
275
- */
276
- export function benchmarkBucket(model) {
277
- if (!model) return 2
278
- if (model.benchmark && model.benchmark.ok === true) return 0
279
- if (model.isBenchmarking === true) return 1
280
- return 2
281
- }
282
-
283
- /**
284
- * 📖 Comparator for the AI Latency / TPS sort columns.
285
- * 📖 Always agrees on bucket order (completed → running → never) regardless of
286
- * 📖 direction. Within each bucket, returns 0 so the rank tie-breaker applies.
287
- * 📖 `valueGetter` reads the numeric metric (totalMs for latency, tokensPerSecond for TPS).
288
- * @param {object} a
289
- * @param {object} b
290
- * @param {1|-1} direction — 1 = ascending, -1 = descending
291
- * @param {(bench: object) => number} valueGetter
292
- * @returns {number}
293
- */
294
- export function compareBenchmark(a, b, direction, valueGetter) {
295
- const ba = benchmarkBucket(a)
296
- const bb = benchmarkBucket(b)
297
- if (ba !== bb) {
298
- // 📖 Completed always first, never-tested always last, regardless of direction.
299
- if (ba < bb) return -1
300
- return 1
301
- }
302
- if (ba !== 0) return 0
303
- // 📖 Both completed: sort by the actual metric.
304
- const va = valueGetter ? valueGetter(a.benchmark) : null
305
- const vb = valueGetter ? valueGetter(b.benchmark) : null
306
- if (va == null && vb == null) return 0
307
- if (va == null) return 1
308
- if (vb == null) return -1
309
- return (va - vb) * direction
310
- }