free-coding-models 0.5.4 β 0.5.6
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 +8 -5
- package/bin/free-coding-models.js +29 -8
- package/changelog/v0.5.5.md +16 -0
- package/changelog/v0.5.6.md +24 -0
- package/package.json +4 -4
- package/src/core/changelog-loader.js +5 -1
- package/src/core/router-daemon.js +11 -0
- package/src/core/updater.js +174 -10
- package/src/tui/app.js +11 -31
- package/src/tui/render-table.js +9 -3
- package/src/tui/tui-state.js +6 -0
- package/web/dist/assets/index-Blp9QJev.js +39 -0
- package/web/dist/assets/{index-BrpHevg4.css β index-Cz_aCLTR.css} +1 -1
- package/web/dist/index.html +2 -2
- package/web/server.js +158 -2
- package/web/src/App.jsx +107 -58
- package/web/src/components/changelog/ChangelogView.jsx +135 -0
- package/web/src/components/changelog/ChangelogView.module.css +160 -0
- package/web/src/components/help/HelpView.jsx +188 -0
- package/web/src/components/help/HelpView.module.css +157 -0
- package/web/src/components/layout/Header.jsx +8 -3
- package/web/src/components/palette/CommandPalette.jsx +228 -74
- package/web/src/components/settings/SettingsView.jsx +281 -8
- package/web/src/components/settings/SettingsView.module.css +174 -0
- package/web/src/components/update/UpdateChip.jsx +104 -0
- package/web/src/components/update/UpdateChip.module.css +146 -0
- package/web/src/global.css +15 -0
- package/web/src/hooks/urlState.constants.js +25 -0
- package/web/src/hooks/useChangelog.js +51 -0
- package/web/src/hooks/useSocket.js +3 -0
- package/web/src/hooks/useUpdateChecker.js +91 -0
- package/web/src/hooks/useUrlState.js +122 -62
- package/web/dist/assets/index-BoWmUveV.js +0 -39
|
@@ -1,21 +1,40 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* @file web/src/components/settings/SettingsView.jsx
|
|
3
|
-
* @description Full settings page
|
|
4
|
-
* π
|
|
5
|
-
*
|
|
3
|
+
* @description Full settings page β M2 parity with the TUI Settings overlay.
|
|
4
|
+
* π M1: API key management (per-provider cards: enable/disable, masked key,
|
|
5
|
+
* π reveal, copy, save, delete, search filter).
|
|
6
|
+
* π M2: theme dropdown, favorites display mode toggle, startup AI speed scan
|
|
7
|
+
* π toggle, shell-env export toggle, legacy proxy cleanup button, per-provider
|
|
8
|
+
* π test key button (calls /api/key/:provider/test), open Changelog link,
|
|
9
|
+
* π update status row.
|
|
6
10
|
* @functions SettingsView β main settings page component
|
|
7
11
|
*/
|
|
8
12
|
import { useState, useEffect, useCallback } from 'react'
|
|
9
|
-
import {
|
|
13
|
+
import {
|
|
14
|
+
IconSettings, IconPlug, IconCircleCheck, IconKey, IconEye, IconEyeOff, IconCopy, IconTrash,
|
|
15
|
+
IconBolt, IconCircleCheckFilled, IconHistory, IconRefresh, IconDownload, IconSun, IconStar,
|
|
16
|
+
} from '@tabler/icons-react'
|
|
10
17
|
import styles from './SettingsView.module.css'
|
|
11
18
|
import { maskKey } from '../../utils/format.js'
|
|
12
19
|
|
|
13
|
-
|
|
20
|
+
const TEST_OUTCOME_META = {
|
|
21
|
+
ok: { label: 'OK', icon: IconCircleCheckFilled, className: 'testOk' },
|
|
22
|
+
auth_error: { label: 'Auth error', icon: IconKey, className: 'testErr' },
|
|
23
|
+
rate_limited: { label: 'Rate limited', icon: IconRefresh, className: 'testWarn' },
|
|
24
|
+
no_callable_model: { label: 'No callable model', icon: IconKey, className: 'testWarn' },
|
|
25
|
+
fail: { label: 'Failed', icon: IconKey, className: 'testErr' },
|
|
26
|
+
missing_key: { label: 'Missing key', icon: IconKey, className: 'testNeutral' },
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export default function SettingsView({ onToast, onOpenChangelog, onCheckForUpdate }) {
|
|
14
30
|
const [config, setConfig] = useState(null)
|
|
15
31
|
const [searchQuery, setSearchQuery] = useState('')
|
|
16
32
|
const [expandedCards, setExpandedCards] = useState(new Set())
|
|
17
33
|
const [revealedKeys, setRevealedKeys] = useState(new Set())
|
|
18
34
|
const [keyInputs, setKeyInputs] = useState({})
|
|
35
|
+
const [testResults, setTestResults] = useState({}) // { providerKey: { outcome, code?, detail? } }
|
|
36
|
+
const [testingKeys, setTestingKeys] = useState(new Set())
|
|
37
|
+
const [legacyCleanupMsg, setLegacyCleanupMsg] = useState(null)
|
|
19
38
|
|
|
20
39
|
const loadConfig = useCallback(async () => {
|
|
21
40
|
try {
|
|
@@ -142,6 +161,100 @@ export default function SettingsView({ onToast }) {
|
|
|
142
161
|
}
|
|
143
162
|
}
|
|
144
163
|
|
|
164
|
+
// π M2: per-provider key test. Fires a parallel auth probe + chat ping
|
|
165
|
+
// π through /api/key/:provider/test and stores the outcome for badge display.
|
|
166
|
+
const testKey = useCallback(async (key) => {
|
|
167
|
+
if (testingKeys.has(key)) return
|
|
168
|
+
setTestingKeys((prev) => new Set(prev).add(key))
|
|
169
|
+
setTestResults((prev) => ({ ...prev, [key]: { outcome: 'pending' } }))
|
|
170
|
+
try {
|
|
171
|
+
const resp = await fetch(`/api/key/${encodeURIComponent(key)}/test`, { method: 'POST' })
|
|
172
|
+
const data = await resp.json().catch(() => ({}))
|
|
173
|
+
if (resp.ok) {
|
|
174
|
+
setTestResults((prev) => ({ ...prev, [key]: data }))
|
|
175
|
+
const meta = TEST_OUTCOME_META[data.outcome] || TEST_OUTCOME_META.fail
|
|
176
|
+
onToast?.(`${key} test: ${meta.label}${data.code ? ` (HTTP ${data.code})` : ''}`, data.outcome === 'ok' ? 'success' : 'info')
|
|
177
|
+
} else {
|
|
178
|
+
setTestResults((prev) => ({ ...prev, [key]: { outcome: 'fail', detail: data.error || 'HTTP ' + resp.status } }))
|
|
179
|
+
onToast?.(`${key} test failed: ${data.error || resp.statusText}`, 'error')
|
|
180
|
+
}
|
|
181
|
+
} catch (err) {
|
|
182
|
+
setTestResults((prev) => ({ ...prev, [key]: { outcome: 'fail', detail: err.message } }))
|
|
183
|
+
onToast?.(`${key} test failed: ${err.message}`, 'error')
|
|
184
|
+
} finally {
|
|
185
|
+
setTestingKeys((prev) => {
|
|
186
|
+
const next = new Set(prev)
|
|
187
|
+
next.delete(key)
|
|
188
|
+
return next
|
|
189
|
+
})
|
|
190
|
+
}
|
|
191
|
+
}, [testingKeys, onToast])
|
|
192
|
+
|
|
193
|
+
// π M2: feature toggles (theme / favorites mode / startup AI scan / shell env)
|
|
194
|
+
// π go through /api/settings/feature which persists to the same config file
|
|
195
|
+
// π the TUI uses. Theme is a tri-state string, not a boolean.
|
|
196
|
+
const toggleFeature = useCallback(async (feature, value) => {
|
|
197
|
+
try {
|
|
198
|
+
const body = value === undefined ? { feature } : { feature, value }
|
|
199
|
+
const resp = await fetch('/api/settings/feature', {
|
|
200
|
+
method: 'POST',
|
|
201
|
+
headers: { 'Content-Type': 'application/json' },
|
|
202
|
+
body: JSON.stringify(body),
|
|
203
|
+
})
|
|
204
|
+
const data = await resp.json()
|
|
205
|
+
if (data.success) {
|
|
206
|
+
await loadConfig()
|
|
207
|
+
onToast?.(`${feature} updated`, 'success')
|
|
208
|
+
} else {
|
|
209
|
+
onToast?.(data.error || 'Failed to update feature', 'error')
|
|
210
|
+
}
|
|
211
|
+
} catch {
|
|
212
|
+
onToast?.('Network error', 'error')
|
|
213
|
+
}
|
|
214
|
+
}, [onToast])
|
|
215
|
+
|
|
216
|
+
const setShellEnv = useCallback(async (enabled) => {
|
|
217
|
+
try {
|
|
218
|
+
const resp = await fetch('/api/shell-env/toggle', {
|
|
219
|
+
method: 'POST',
|
|
220
|
+
headers: { 'Content-Type': 'application/json' },
|
|
221
|
+
body: JSON.stringify({ enabled }),
|
|
222
|
+
})
|
|
223
|
+
const data = await resp.json()
|
|
224
|
+
if (data.success) {
|
|
225
|
+
await loadConfig()
|
|
226
|
+
onToast?.(`Shell env export ${data.enabled ? 'enabled' : 'disabled'} β restart your shell to apply.`, 'success')
|
|
227
|
+
} else {
|
|
228
|
+
onToast?.(data.error || 'Failed to toggle shell env', 'error')
|
|
229
|
+
}
|
|
230
|
+
} catch {
|
|
231
|
+
onToast?.('Network error', 'error')
|
|
232
|
+
}
|
|
233
|
+
}, [onToast])
|
|
234
|
+
|
|
235
|
+
const runLegacyCleanup = useCallback(async () => {
|
|
236
|
+
if (!confirm('Remove discontinued proxy config leftovers? This is safe to run.')) return
|
|
237
|
+
try {
|
|
238
|
+
const resp = await fetch('/api/legacy-cleanup', { method: 'POST' })
|
|
239
|
+
const data = await resp.json()
|
|
240
|
+
const cleaned = (data.removedFiles?.length || 0) + (data.updatedFiles?.length || 0)
|
|
241
|
+
if (data.changed) {
|
|
242
|
+
setLegacyCleanupMsg(`Cleaned ${cleaned} legacy file(s). ${data.errors.length} error(s).`)
|
|
243
|
+
onToast?.(`Legacy proxy cleanup: ${cleaned} file(s) cleaned.`, 'success')
|
|
244
|
+
} else {
|
|
245
|
+
setLegacyCleanupMsg('No discontinued proxy config was found. You are on the stable direct-provider setup.')
|
|
246
|
+
onToast?.('No legacy proxy config found β already on stable setup.', 'info')
|
|
247
|
+
}
|
|
248
|
+
await loadConfig()
|
|
249
|
+
} catch (err) {
|
|
250
|
+
onToast?.(`Legacy cleanup failed: ${err.message}`, 'error')
|
|
251
|
+
}
|
|
252
|
+
}, [onToast])
|
|
253
|
+
|
|
254
|
+
const onCheckUpdatesClick = useCallback(() => {
|
|
255
|
+
onCheckForUpdate?.()
|
|
256
|
+
}, [onCheckForUpdate])
|
|
257
|
+
|
|
145
258
|
if (!config) {
|
|
146
259
|
return (
|
|
147
260
|
<div className={styles.page}>
|
|
@@ -163,14 +276,150 @@ export default function SettingsView({ onToast }) {
|
|
|
163
276
|
<div className={styles.pageHeader}>
|
|
164
277
|
<h1 className={styles.pageTitle}>
|
|
165
278
|
<IconSettings size={24} stroke={1.5} style={{ marginRight: 8, verticalAlign: 'middle' }} />
|
|
166
|
-
|
|
279
|
+
Settings
|
|
167
280
|
</h1>
|
|
168
281
|
<p className={styles.pageSubtitle}>
|
|
169
|
-
|
|
170
|
-
<code>~/.free-coding-models.json</code>
|
|
282
|
+
API keys, theme, favorites mode, shell env, and update controls.
|
|
283
|
+
All settings are stored locally in <code>~/.free-coding-models.json</code>
|
|
284
|
+
and shared with the TUI.
|
|
171
285
|
</p>
|
|
172
286
|
</div>
|
|
173
287
|
|
|
288
|
+
{/* ββ M2: global feature toggles ββββββββββββββββββββββββββββββββββββββ */}
|
|
289
|
+
{config && (
|
|
290
|
+
<section className={styles.featureSection}>
|
|
291
|
+
<h2 className={styles.sectionHeading}>βοΈ Global settings</h2>
|
|
292
|
+
<div className={styles.featureGrid}>
|
|
293
|
+
{/* Theme */}
|
|
294
|
+
<div className={styles.featureRow}>
|
|
295
|
+
<div className={styles.featureLabel}>
|
|
296
|
+
<IconSun size={16} stroke={1.5} />
|
|
297
|
+
<div>
|
|
298
|
+
<div className={styles.featureTitle}>Theme</div>
|
|
299
|
+
<div className={styles.featureDesc}>Tri-state cycle. Auto follows your OS.</div>
|
|
300
|
+
</div>
|
|
301
|
+
</div>
|
|
302
|
+
<select
|
|
303
|
+
className={styles.select}
|
|
304
|
+
value={config.settings?.theme || 'auto'}
|
|
305
|
+
onChange={(e) => toggleFeature('theme', e.target.value)}
|
|
306
|
+
>
|
|
307
|
+
<option value="auto">Auto (OS)</option>
|
|
308
|
+
<option value="dark">Dark</option>
|
|
309
|
+
<option value="light">Light</option>
|
|
310
|
+
</select>
|
|
311
|
+
</div>
|
|
312
|
+
|
|
313
|
+
{/* Favorites display mode */}
|
|
314
|
+
<div className={styles.featureRow}>
|
|
315
|
+
<div className={styles.featureLabel}>
|
|
316
|
+
<IconStar size={16} stroke={1.5} />
|
|
317
|
+
<div>
|
|
318
|
+
<div className={styles.featureTitle}>Favorites pinned + always visible</div>
|
|
319
|
+
<div className={styles.featureDesc}>Favorites bypass filters and stay on top (TUI: Y key).</div>
|
|
320
|
+
</div>
|
|
321
|
+
</div>
|
|
322
|
+
<label className={styles.toggleSwitch}>
|
|
323
|
+
<input
|
|
324
|
+
type="checkbox"
|
|
325
|
+
checked={Boolean(config.settings?.favoritesPinnedAndSticky)}
|
|
326
|
+
onChange={(e) => toggleFeature('favoritesPinnedAndSticky', e.target.checked)}
|
|
327
|
+
/>
|
|
328
|
+
<span className={styles.toggleSlider} />
|
|
329
|
+
</label>
|
|
330
|
+
</div>
|
|
331
|
+
|
|
332
|
+
{/* Startup AI Speed Scan */}
|
|
333
|
+
<div className={styles.featureRow}>
|
|
334
|
+
<div className={styles.featureLabel}>
|
|
335
|
+
<IconBolt size={16} stroke={1.5} />
|
|
336
|
+
<div>
|
|
337
|
+
<div className={styles.featureTitle}>Run AI Speed Test on startup</div>
|
|
338
|
+
<div className={styles.featureDesc}>Auto-fire the global benchmark right after launch (TUI: U β 'Enable').</div>
|
|
339
|
+
</div>
|
|
340
|
+
</div>
|
|
341
|
+
<label className={styles.toggleSwitch}>
|
|
342
|
+
<input
|
|
343
|
+
type="checkbox"
|
|
344
|
+
checked={Boolean(config.settings?.runAiSpeedTestOnStartup)}
|
|
345
|
+
onChange={(e) => toggleFeature('runAiSpeedTestOnStartup', e.target.checked)}
|
|
346
|
+
/>
|
|
347
|
+
<span className={styles.toggleSlider} />
|
|
348
|
+
</label>
|
|
349
|
+
</div>
|
|
350
|
+
|
|
351
|
+
{/* Shell env export */}
|
|
352
|
+
<div className={styles.featureRow}>
|
|
353
|
+
<div className={styles.featureLabel}>
|
|
354
|
+
<IconCircleCheck size={16} stroke={1.5} />
|
|
355
|
+
<div>
|
|
356
|
+
<div className={styles.featureTitle}>Export API keys to shell rc</div>
|
|
357
|
+
<div className={styles.featureDesc}>Write NVIDIA_API_KEY / GROQ_API_KEY / β¦ to your shell rc file.</div>
|
|
358
|
+
</div>
|
|
359
|
+
</div>
|
|
360
|
+
<label className={styles.toggleSwitch}>
|
|
361
|
+
<input
|
|
362
|
+
type="checkbox"
|
|
363
|
+
checked={Boolean(config.settings?.shellEnvEnabled)}
|
|
364
|
+
onChange={(e) => setShellEnv(e.target.checked)}
|
|
365
|
+
/>
|
|
366
|
+
<span className={styles.toggleSlider} />
|
|
367
|
+
</label>
|
|
368
|
+
</div>
|
|
369
|
+
|
|
370
|
+
{/* Update row */}
|
|
371
|
+
<div className={styles.featureRow}>
|
|
372
|
+
<div className={styles.featureLabel}>
|
|
373
|
+
<IconDownload size={16} stroke={1.5} />
|
|
374
|
+
<div>
|
|
375
|
+
<div className={styles.featureTitle}>Check for updates</div>
|
|
376
|
+
<div className={styles.featureDesc}>
|
|
377
|
+
The header chip turns green when a newer npm version is available.
|
|
378
|
+
Use the 'Update now' button to install.
|
|
379
|
+
</div>
|
|
380
|
+
</div>
|
|
381
|
+
</div>
|
|
382
|
+
<div className={styles.featureActions}>
|
|
383
|
+
<button
|
|
384
|
+
className={styles.smallBtn}
|
|
385
|
+
onClick={onCheckUpdatesClick}
|
|
386
|
+
>
|
|
387
|
+
Check now
|
|
388
|
+
</button>
|
|
389
|
+
<button
|
|
390
|
+
className={styles.smallBtn}
|
|
391
|
+
onClick={() => onOpenChangelog?.(null)}
|
|
392
|
+
>
|
|
393
|
+
<IconHistory size={13} stroke={1.5} /> Changelog
|
|
394
|
+
</button>
|
|
395
|
+
</div>
|
|
396
|
+
</div>
|
|
397
|
+
|
|
398
|
+
{/* Legacy proxy cleanup */}
|
|
399
|
+
<div className={styles.featureRow}>
|
|
400
|
+
<div className={styles.featureLabel}>
|
|
401
|
+
<IconRefresh size={16} stroke={1.5} />
|
|
402
|
+
<div>
|
|
403
|
+
<div className={styles.featureTitle}>Cleanup discontinued proxy artifacts</div>
|
|
404
|
+
<div className={styles.featureDesc}>
|
|
405
|
+
Remove old config / env / service leftovers from the old multi-tool proxy.
|
|
406
|
+
</div>
|
|
407
|
+
</div>
|
|
408
|
+
</div>
|
|
409
|
+
<button
|
|
410
|
+
className={styles.smallBtn}
|
|
411
|
+
onClick={runLegacyCleanup}
|
|
412
|
+
>
|
|
413
|
+
Run cleanup
|
|
414
|
+
</button>
|
|
415
|
+
</div>
|
|
416
|
+
</div>
|
|
417
|
+
{legacyCleanupMsg && (
|
|
418
|
+
<div className={styles.notice}>{legacyCleanupMsg}</div>
|
|
419
|
+
)}
|
|
420
|
+
</section>
|
|
421
|
+
)}
|
|
422
|
+
|
|
174
423
|
<div className={styles.toolbar}>
|
|
175
424
|
<div className={styles.toolbarSearch}>
|
|
176
425
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
|
@@ -221,6 +470,20 @@ export default function SettingsView({ onToast }) {
|
|
|
221
470
|
|
|
222
471
|
<div className={styles.cardBody}>
|
|
223
472
|
<div className={styles.cardContent}>
|
|
473
|
+
{p.hasKey && testResults[key] && (
|
|
474
|
+
<div className={`${styles.testBadge} ${styles[`test_${testResults[key].outcome}`] || ''}`}>
|
|
475
|
+
{(() => {
|
|
476
|
+
const meta = TEST_OUTCOME_META[testResults[key].outcome] || TEST_OUTCOME_META.fail
|
|
477
|
+
const Icon = meta.icon
|
|
478
|
+
return (
|
|
479
|
+
<>
|
|
480
|
+
<Icon size={12} stroke={1.5} />
|
|
481
|
+
<span>Last test: {meta.label}{testResults[key].code ? ` (HTTP ${testResults[key].code})` : ''}</span>
|
|
482
|
+
</>
|
|
483
|
+
)
|
|
484
|
+
})()}
|
|
485
|
+
</div>
|
|
486
|
+
)}
|
|
224
487
|
{p.hasKey && (
|
|
225
488
|
<div className={styles.keyGroup}>
|
|
226
489
|
<label className={styles.keyLabel}>Current API Key</label>
|
|
@@ -235,6 +498,16 @@ export default function SettingsView({ onToast }) {
|
|
|
235
498
|
<button className={styles.actionBtn} onClick={() => copyKey(key)} title="Copy">
|
|
236
499
|
<IconCopy size={14} stroke={1.5} />
|
|
237
500
|
</button>
|
|
501
|
+
<button
|
|
502
|
+
className={styles.actionBtn}
|
|
503
|
+
onClick={() => testKey(key)}
|
|
504
|
+
disabled={testingKeys.has(key)}
|
|
505
|
+
title="Test this key against the provider (TUI: T key in Settings)"
|
|
506
|
+
aria-label={`Test key for ${key}`}
|
|
507
|
+
>
|
|
508
|
+
{testingKeys.has(key) ? <span className={styles.testSpinner} /> : <IconBolt size={14} stroke={1.5} />}
|
|
509
|
+
{testingKeys.has(key) ? 'Testingβ¦' : 'Test'}
|
|
510
|
+
</button>
|
|
238
511
|
<button className={`${styles.actionBtn} ${styles.actionBtnDanger}`} onClick={() => deleteKey(key)} title="Delete Key">
|
|
239
512
|
<IconTrash size={14} stroke={1.5} />
|
|
240
513
|
</button>
|
|
@@ -33,6 +33,180 @@
|
|
|
33
33
|
color: var(--color-text-muted);
|
|
34
34
|
}
|
|
35
35
|
|
|
36
|
+
/* βββ M2: feature toggles section βββ */
|
|
37
|
+
.featureSection {
|
|
38
|
+
margin-bottom: 28px;
|
|
39
|
+
padding: 18px 20px;
|
|
40
|
+
background: var(--color-bg-card);
|
|
41
|
+
border: 1px solid var(--color-border);
|
|
42
|
+
border-radius: 12px;
|
|
43
|
+
}
|
|
44
|
+
.sectionHeading {
|
|
45
|
+
font-size: 11px;
|
|
46
|
+
font-weight: 700;
|
|
47
|
+
text-transform: uppercase;
|
|
48
|
+
letter-spacing: 0.6px;
|
|
49
|
+
color: var(--color-text-muted);
|
|
50
|
+
margin: 0 0 14px;
|
|
51
|
+
}
|
|
52
|
+
.featureGrid {
|
|
53
|
+
display: flex;
|
|
54
|
+
flex-direction: column;
|
|
55
|
+
gap: 4px;
|
|
56
|
+
}
|
|
57
|
+
.featureRow {
|
|
58
|
+
display: flex;
|
|
59
|
+
align-items: center;
|
|
60
|
+
gap: 14px;
|
|
61
|
+
padding: 10px 12px;
|
|
62
|
+
border: 1px solid transparent;
|
|
63
|
+
border-radius: 8px;
|
|
64
|
+
transition: background 120ms;
|
|
65
|
+
}
|
|
66
|
+
.featureRow:hover { background: var(--color-surface); }
|
|
67
|
+
.featureLabel {
|
|
68
|
+
display: flex;
|
|
69
|
+
align-items: flex-start;
|
|
70
|
+
gap: 10px;
|
|
71
|
+
flex: 1;
|
|
72
|
+
min-width: 0;
|
|
73
|
+
}
|
|
74
|
+
.featureLabel > svg {
|
|
75
|
+
flex-shrink: 0;
|
|
76
|
+
color: var(--color-text-muted);
|
|
77
|
+
margin-top: 2px;
|
|
78
|
+
}
|
|
79
|
+
.featureTitle {
|
|
80
|
+
font-size: 13px;
|
|
81
|
+
font-weight: 600;
|
|
82
|
+
color: var(--color-text);
|
|
83
|
+
line-height: 1.4;
|
|
84
|
+
}
|
|
85
|
+
.featureDesc {
|
|
86
|
+
font-size: 11px;
|
|
87
|
+
color: var(--color-text-muted);
|
|
88
|
+
line-height: 1.5;
|
|
89
|
+
margin-top: 2px;
|
|
90
|
+
}
|
|
91
|
+
.featureActions {
|
|
92
|
+
display: flex;
|
|
93
|
+
gap: 6px;
|
|
94
|
+
flex-shrink: 0;
|
|
95
|
+
}
|
|
96
|
+
.select {
|
|
97
|
+
background: var(--color-surface);
|
|
98
|
+
color: var(--color-text);
|
|
99
|
+
border: 1px solid var(--color-border);
|
|
100
|
+
border-radius: 5px;
|
|
101
|
+
padding: 5px 8px;
|
|
102
|
+
font-size: 12px;
|
|
103
|
+
font-family: var(--font-sans);
|
|
104
|
+
cursor: pointer;
|
|
105
|
+
outline: none;
|
|
106
|
+
min-width: 130px;
|
|
107
|
+
}
|
|
108
|
+
.select:focus { border-color: var(--color-accent); }
|
|
109
|
+
|
|
110
|
+
.toggleSwitch {
|
|
111
|
+
position: relative;
|
|
112
|
+
display: inline-block;
|
|
113
|
+
width: 36px;
|
|
114
|
+
height: 20px;
|
|
115
|
+
flex-shrink: 0;
|
|
116
|
+
}
|
|
117
|
+
.toggleSwitch input {
|
|
118
|
+
opacity: 0;
|
|
119
|
+
width: 0;
|
|
120
|
+
height: 0;
|
|
121
|
+
}
|
|
122
|
+
.toggleSlider {
|
|
123
|
+
position: absolute;
|
|
124
|
+
inset: 0;
|
|
125
|
+
background: var(--color-border);
|
|
126
|
+
border-radius: 20px;
|
|
127
|
+
cursor: pointer;
|
|
128
|
+
transition: background 150ms;
|
|
129
|
+
}
|
|
130
|
+
.toggleSlider::before {
|
|
131
|
+
content: '';
|
|
132
|
+
position: absolute;
|
|
133
|
+
left: 2px;
|
|
134
|
+
top: 2px;
|
|
135
|
+
width: 16px;
|
|
136
|
+
height: 16px;
|
|
137
|
+
background: var(--color-text);
|
|
138
|
+
border-radius: 50%;
|
|
139
|
+
transition: transform 150ms;
|
|
140
|
+
}
|
|
141
|
+
.toggleSwitch input:checked + .toggleSlider {
|
|
142
|
+
background: var(--color-accent);
|
|
143
|
+
}
|
|
144
|
+
.toggleSwitch input:checked + .toggleSlider::before {
|
|
145
|
+
transform: translateX(16px);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
.smallBtn {
|
|
149
|
+
display: inline-flex;
|
|
150
|
+
align-items: center;
|
|
151
|
+
gap: 4px;
|
|
152
|
+
padding: 5px 10px;
|
|
153
|
+
font-size: 11px;
|
|
154
|
+
font-weight: 600;
|
|
155
|
+
font-family: var(--font-sans);
|
|
156
|
+
background: var(--color-surface);
|
|
157
|
+
color: var(--color-text);
|
|
158
|
+
border: 1px solid var(--color-border);
|
|
159
|
+
border-radius: 5px;
|
|
160
|
+
cursor: pointer;
|
|
161
|
+
white-space: nowrap;
|
|
162
|
+
}
|
|
163
|
+
.smallBtn:hover { background: var(--color-bg-hover); border-color: var(--color-text-muted); }
|
|
164
|
+
.smallBtn:disabled { opacity: 0.5; cursor: not-allowed; }
|
|
165
|
+
|
|
166
|
+
.notice {
|
|
167
|
+
margin-top: 12px;
|
|
168
|
+
padding: 8px 12px;
|
|
169
|
+
background: var(--color-info-dim);
|
|
170
|
+
color: var(--color-info);
|
|
171
|
+
border: 1px solid var(--color-info);
|
|
172
|
+
border-radius: 6px;
|
|
173
|
+
font-size: 12px;
|
|
174
|
+
line-height: 1.5;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/* βββ M2: per-provider test badge βββ */
|
|
178
|
+
.testBadge {
|
|
179
|
+
display: inline-flex;
|
|
180
|
+
align-items: center;
|
|
181
|
+
gap: 5px;
|
|
182
|
+
padding: 4px 8px;
|
|
183
|
+
border-radius: 5px;
|
|
184
|
+
font-size: 10px;
|
|
185
|
+
font-weight: 700;
|
|
186
|
+
font-family: var(--font-mono);
|
|
187
|
+
margin-bottom: 8px;
|
|
188
|
+
align-self: flex-start;
|
|
189
|
+
width: fit-content;
|
|
190
|
+
}
|
|
191
|
+
.testBadge.test_ok { background: var(--color-success-dim); color: var(--color-success); border: 1px solid var(--color-success); }
|
|
192
|
+
.testBadge.test_auth_error { background: var(--color-danger-dim); color: var(--color-danger); border: 1px solid var(--color-danger); }
|
|
193
|
+
.testBadge.test_rate_limited { background: var(--color-warning-dim); color: var(--color-warning); border: 1px solid var(--color-warning); }
|
|
194
|
+
.testBadge.test_no_callable_model { background: var(--color-warning-dim); color: var(--color-warning); border: 1px solid var(--color-warning); }
|
|
195
|
+
.testBadge.test_fail { background: var(--color-danger-dim); color: var(--color-danger); border: 1px solid var(--color-danger); }
|
|
196
|
+
.testBadge.test_missing_key { background: var(--color-surface); color: var(--color-text-muted); border: 1px solid var(--color-border); }
|
|
197
|
+
.testBadge.test_pending { background: var(--color-surface); color: var(--color-text-muted); border: 1px solid var(--color-border); }
|
|
198
|
+
|
|
199
|
+
.testSpinner {
|
|
200
|
+
display: inline-block;
|
|
201
|
+
width: 10px;
|
|
202
|
+
height: 10px;
|
|
203
|
+
border: 2px solid rgba(180, 0, 255, 0.3);
|
|
204
|
+
border-top-color: #b400ff;
|
|
205
|
+
border-radius: 50%;
|
|
206
|
+
animation: spin 0.8s linear infinite;
|
|
207
|
+
}
|
|
208
|
+
@keyframes spin { to { transform: rotate(360deg); } }
|
|
209
|
+
|
|
36
210
|
.toolbar {
|
|
37
211
|
display: flex;
|
|
38
212
|
align-items: center;
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file web/src/components/update/UpdateChip.jsx
|
|
3
|
+
* @description Header update chip + popover β M2 parity with TUI's auto-update banner.
|
|
4
|
+
* π Sits in the right side of the header (next to AI Latency, theme, export).
|
|
5
|
+
* π Hidden when no update is available; shows "β¬ vX.Y.Z" when one is.
|
|
6
|
+
* π Click β popover with "Update now" + "What's new" (which opens the
|
|
7
|
+
* π Changelog modal pre-focused on the new version).
|
|
8
|
+
*
|
|
9
|
+
* @functions
|
|
10
|
+
* β UpdateChip β small badge with popover
|
|
11
|
+
*/
|
|
12
|
+
import { useState, useRef, useEffect } from 'react'
|
|
13
|
+
import { IconDownload, IconHistory, IconX, IconExternalLink } from '@tabler/icons-react'
|
|
14
|
+
import styles from './UpdateChip.module.css'
|
|
15
|
+
|
|
16
|
+
export default function UpdateChip({ updateAvailable, latestVersion, onRunUpdate, onOpenChangelog, checking }) {
|
|
17
|
+
const [open, setOpen] = useState(false)
|
|
18
|
+
const ref = useRef(null)
|
|
19
|
+
|
|
20
|
+
// π Close on outside click / Esc β same pattern as the header kebab menu.
|
|
21
|
+
useEffect(() => {
|
|
22
|
+
if (!open) return
|
|
23
|
+
const onClick = (e) => { if (ref.current && !ref.current.contains(e.target)) setOpen(false) }
|
|
24
|
+
const onKey = (e) => { if (e.key === 'Escape') setOpen(false) }
|
|
25
|
+
document.addEventListener('mousedown', onClick)
|
|
26
|
+
document.addEventListener('keydown', onKey)
|
|
27
|
+
return () => {
|
|
28
|
+
document.removeEventListener('mousedown', onClick)
|
|
29
|
+
document.removeEventListener('keydown', onKey)
|
|
30
|
+
}
|
|
31
|
+
}, [open])
|
|
32
|
+
|
|
33
|
+
// π No chip when no update. The M1 plan keeps the chip honest β a fresh
|
|
34
|
+
// π install should look clean, not noisy.
|
|
35
|
+
if (!updateAvailable) {
|
|
36
|
+
if (checking) {
|
|
37
|
+
return (
|
|
38
|
+
<span className={styles.checking} title="Checking for updatesβ¦">
|
|
39
|
+
<span className={styles.dot} />
|
|
40
|
+
</span>
|
|
41
|
+
)
|
|
42
|
+
}
|
|
43
|
+
return null
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
return (
|
|
47
|
+
<div className={styles.wrap} ref={ref}>
|
|
48
|
+
<button
|
|
49
|
+
className={styles.chip}
|
|
50
|
+
onClick={() => setOpen((o) => !o)}
|
|
51
|
+
title={`Update available: v${latestVersion}. Click to install.`}
|
|
52
|
+
aria-expanded={open}
|
|
53
|
+
aria-haspopup="dialog"
|
|
54
|
+
>
|
|
55
|
+
<IconDownload size={13} stroke={1.5} />
|
|
56
|
+
<span>v{latestVersion}</span>
|
|
57
|
+
</button>
|
|
58
|
+
|
|
59
|
+
{open && (
|
|
60
|
+
<div className={styles.popover} role="dialog" aria-label="Update available">
|
|
61
|
+
<div className={styles.popoverHeader}>
|
|
62
|
+
<div className={styles.popoverTitle}>
|
|
63
|
+
<IconDownload size={14} stroke={1.5} />
|
|
64
|
+
<span>Update available</span>
|
|
65
|
+
</div>
|
|
66
|
+
<button
|
|
67
|
+
className={styles.popoverClose}
|
|
68
|
+
onClick={() => setOpen(false)}
|
|
69
|
+
aria-label="Close"
|
|
70
|
+
>
|
|
71
|
+
<IconX size={14} stroke={1.5} />
|
|
72
|
+
</button>
|
|
73
|
+
</div>
|
|
74
|
+
<p className={styles.popoverBody}>
|
|
75
|
+
A newer version (<strong>v{latestVersion}</strong>) is available on npm.
|
|
76
|
+
After updating, restart the dashboard to pick up the changes.
|
|
77
|
+
</p>
|
|
78
|
+
<div className={styles.popoverActions}>
|
|
79
|
+
<button
|
|
80
|
+
className={styles.primaryAction}
|
|
81
|
+
onClick={() => {
|
|
82
|
+
setOpen(false)
|
|
83
|
+
onRunUpdate?.()
|
|
84
|
+
}}
|
|
85
|
+
>
|
|
86
|
+
<IconDownload size={13} stroke={1.5} />
|
|
87
|
+
<span>Update now</span>
|
|
88
|
+
</button>
|
|
89
|
+
<button
|
|
90
|
+
className={styles.secondaryAction}
|
|
91
|
+
onClick={() => {
|
|
92
|
+
setOpen(false)
|
|
93
|
+
onOpenChangelog?.(latestVersion)
|
|
94
|
+
}}
|
|
95
|
+
>
|
|
96
|
+
<IconHistory size={13} stroke={1.5} />
|
|
97
|
+
<span>What's new</span>
|
|
98
|
+
</button>
|
|
99
|
+
</div>
|
|
100
|
+
</div>
|
|
101
|
+
)}
|
|
102
|
+
</div>
|
|
103
|
+
)
|
|
104
|
+
}
|