@goodandready/dsh-clinebot 0.3.11 → 0.3.12

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/lib/client.js CHANGED
@@ -1,14 +1,19 @@
1
- // Plugin Settings Card (settings.plugin.item).
1
+ // @generated by scripts/build-client.js - DO NOT EDIT DIRECTLY.
2
+ // Edit modular source files in src/client/ instead.
3
+
2
4
  window.__ModuleLoader__.load({
3
5
  id: '@goodandready/dsh-clinebot',
4
6
  factory: (require) => {
5
7
  var module = { exports: {} }
6
- const React = require('react')
7
- const NS = 'dsh-clinebot'
8
- const TITLE = 'ClineBot'
9
- const SUBTITLE = 'ClinePass subscription ($9.99/mo) with curated & custom models.'
10
- const ROUTE_PREFIX = '/dsh-clinebot'
8
+ var exports = module.exports
9
+ let React = require('react')
10
+
11
+ // --- src/client/constants.js ---
12
+ const NS = 'dsh-clinebot'
13
+ const ROUTE_PREFIX = '/dsh-clinebot'
14
+ const SNAPSHOT_LOADING = { status: 'loading', view: null }
11
15
 
16
+ // --- src/client/locales.js ---
12
17
  const en = {
13
18
  title: 'ClineBot',
14
19
  subtitle: 'ClinePass subscription ($9.99/mo) with curated & custom models.',
@@ -199,29 +204,31 @@ const en = {
199
204
  }
200
205
  }
201
206
 
202
- function FallbackChevron() {
203
- return React.createElement(
204
- 'svg',
205
- { width: 14, height: 14, viewBox: '0 0 14 14', fill: 'none', 'aria-hidden': true },
206
- React.createElement('path', {
207
- d: 'M3.5 5.25L7 8.75L10.5 5.25',
208
- stroke: 'currentColor',
209
- strokeWidth: 1.5,
210
- strokeLinecap: 'round',
211
- strokeLinejoin: 'round',
212
- })
213
- )
214
- }
215
- let ChevronIcon = null
216
- try {
217
- const primitives = require('@deepseek-ai/dsh-client-ui-primitives')
218
- ChevronIcon = primitives && (primitives.IconChevronDownOutline14 || primitives.IconChevronDownOutline)
219
- } catch (_) {
220
- ChevronIcon = null
221
- }
222
- const Chevron = ChevronIcon || FallbackChevron
223
-
224
- function ensureCss() {
207
+ // --- src/client/chevron.js ---
208
+ function FallbackChevron() {
209
+ return React.createElement(
210
+ 'svg',
211
+ { width: 14, height: 14, viewBox: '0 0 14 14', fill: 'none', 'aria-hidden': true },
212
+ React.createElement('path', {
213
+ d: 'M3.5 5.25L7 8.75L10.5 5.25',
214
+ stroke: 'currentColor',
215
+ strokeWidth: 1.5,
216
+ strokeLinecap: 'round',
217
+ strokeLinejoin: 'round',
218
+ })
219
+ )
220
+ }
221
+ let ChevronIcon = null
222
+ try {
223
+ const primitives = require('@deepseek-ai/dsh-client-ui-primitives')
224
+ ChevronIcon = primitives && (primitives.IconChevronDownOutline14 || primitives.IconChevronDownOutline)
225
+ } catch (_) {
226
+ ChevronIcon = null
227
+ }
228
+ const Chevron = ChevronIcon || FallbackChevron
229
+
230
+ // --- src/client/styles.js ---
231
+ function ensureCss() {
225
232
  if (typeof document === 'undefined') return
226
233
  if (document.getElementById('dsh-clinebot-full-css')) return
227
234
  const style = document.createElement('style')
@@ -283,1197 +290,1162 @@ const en = {
283
290
  document.head.appendChild(style)
284
291
  }
285
292
 
286
- function createErrorBoundary() {
287
- if (!React || typeof React.Component !== 'function') {
288
- return function NoopBoundary(props) { return props?.children || null }
289
- }
290
- return class ErrorBoundary extends React.Component {
291
- constructor(props) {
292
- super(props)
293
- this.state = { hasError: false, error: null }
294
- }
295
- static getDerivedStateFromError(error) {
296
- return { hasError: true, error }
297
- }
298
- componentDidCatch(error, errorInfo) {
299
- console.error('[dsh-clinebot] React Error:', error, errorInfo)
300
- }
301
- render() {
302
- if (this.state.hasError) {
303
- return React.createElement(
304
- 'div',
305
- {
306
- className: 'cb-alert cb-alert-err',
307
- style: { margin: '12px 0', padding: '14px', borderRadius: '8px' },
308
- },
309
- React.createElement('div', { style: { fontWeight: 600, marginBottom: '6px' } }, '⚠️ ClineBot UI Error:'),
310
- React.createElement('div', { style: { fontSize: '12px', wordBreak: 'break-all' } }, String(this.state.error?.message || this.state.error)),
311
- React.createElement(
312
- 'button',
313
- {
314
- type: 'button',
315
- className: 'cb-btn',
316
- style: { marginTop: '10px', fontSize: '12px', padding: '4px 10px' },
317
- onClick: () => this.setState({ hasError: false, error: null }),
318
- },
319
- 'Retry'
320
- )
321
- )
322
- }
323
- return this.props?.children || null
324
- }
325
- }
326
- }
327
- const ErrorBoundary = createErrorBoundary()
328
-
329
- const SNAPSHOT_READY = Object.freeze({ status: 'ready', value: {} })
330
- const SNAPSHOT_LOADING = Object.freeze({ status: 'loading', value: {} })
331
-
332
- function formatResetTime(isoString) {
333
- if (!isoString) return '—'
334
- try {
335
- const d = new Date(isoString)
336
- if (isNaN(d.getTime())) return '—'
337
- return d.toLocaleTimeString()
338
- } catch {
339
- return '—'
340
- }
341
- }
342
-
343
- function ProgressBar({ label, percentUsed, remainingPercent, resetsAt, t }) {
344
- const used = Math.max(0, Math.min(100, percentUsed || 0))
345
- let fillColor = 'var(--dsw-alias-state-success-primary)'
346
- if (used > 75) fillColor = 'var(--dsw-alias-state-warning-primary)'
347
- if (used > 90) fillColor = 'var(--dsw-alias-state-error-primary)'
348
-
349
- const resetStr = formatResetTime(resetsAt)
350
-
351
- return React.createElement(
352
- 'div',
353
- { className: 'cb-bar-container' },
354
- React.createElement(
355
- 'div',
356
- { className: 'cb-bar-head' },
357
- React.createElement('span', null, label),
358
- React.createElement('span', { style: { fontWeight: 600 } }, t('quota.used', { pct: used }))
359
- ),
360
- React.createElement(
361
- 'div',
362
- { className: 'cb-bar-track' },
363
- React.createElement('div', {
364
- className: 'cb-bar-fill',
365
- style: { width: `${used}%`, background: fillColor },
366
- })
367
- ),
368
- React.createElement(
369
- 'div',
370
- { className: 'cb-bar-meta' },
371
- React.createElement('span', null, t('quota.remaining', { pct: remainingPercent ?? (100 - used) })),
372
- React.createElement('span', null, t('quota.reset_at', { time: resetStr }))
373
- )
374
- )
293
+ // --- src/client/error-boundary.js ---
294
+ function createErrorBoundary() {
295
+ try {
296
+ if (!React || typeof React.Component !== 'function') {
297
+ return function NoopBoundary(props) { return props?.children || null }
375
298
  }
376
-
377
- function SettingsPage(props) {
378
- const ctx = props?.ctx
379
- const t = props?.t || makeT(en, en)
380
-
381
- const scope = React.useMemo(() => {
382
- const s = (ctx?.get && ctx.get('lanSettings')) || ctx?.settingsScope
383
- if (!s?.bind) return undefined
384
- try {
385
- return s.bind({ namespace: NS })
386
- } catch (_) {
387
- return undefined
388
- }
389
- }, [ctx])
390
-
391
- const subscribe = React.useMemo(() => {
392
- return (cb) => {
393
- if (!scope?.subscribe) return () => {}
394
- try {
395
- return scope.subscribe(cb) || (() => {})
396
- } catch (_) {
397
- return () => {}
398
- }
399
- }
400
- }, [scope])
401
-
402
- const getSnapshot = React.useCallback(() => {
403
- if (!scope?.getSnapshot) return SNAPSHOT_LOADING
404
- try {
405
- return scope.getSnapshot() || SNAPSHOT_LOADING
406
- } catch (_) {
407
- return SNAPSHOT_LOADING
408
- }
409
- }, [scope])
410
-
411
- const snapshot = React.useSyncExternalStore(
412
- subscribe,
413
- getSnapshot,
414
- React.useCallback(() => SNAPSHOT_LOADING, [])
415
- )
416
- const snapshotStatus = snapshot?.status || 'loading'
417
-
418
- const [status, setStatus] = React.useState(null)
419
- const [draft, setDraft] = React.useState(null)
420
- const [busy, setBusy] = React.useState('')
421
- const [err, setErr] = React.useState('')
422
- const [msg, setMsg] = React.useState('')
423
- const [smokeResult, setSmokeResult] = React.useState(null)
424
-
425
- // Plugin in-app updater state
426
- const [updateState, setUpdateState] = React.useState({
427
- checking: false,
428
- updating: false,
429
- currentVersion: '0.3.10',
430
- latestVersion: '',
431
- updateAvailable: false,
432
- canAutoUpdate: true,
433
- error: '',
434
- notice: '',
435
- })
436
-
437
- // Key input state
438
- const [apiKeyInput, setApiKeyInput] = React.useState('')
439
- const [showKey, setShowKey] = React.useState(false)
440
-
441
- React.useEffect(() => {
442
- ensureCss()
443
- }, [])
444
-
445
- const load = React.useCallback(async () => {
446
- setErr('')
447
- const res = await fetch(`${ROUTE_PREFIX}/status`, { cache: 'no-store' })
448
- const data = await res.json().catch(() => ({}))
449
- if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`)
450
- setStatus(data)
451
- setDraft(data.config || {})
452
- }, [])
453
-
454
- React.useEffect(() => {
455
- load().catch((e) => setErr(String(e.message || e)))
456
- }, [load])
457
-
458
- const checkUpdate = React.useCallback(async () => {
459
- setUpdateState((s) => ({ ...s, checking: true, error: '' }))
460
- try {
461
- const res = await fetch(`${ROUTE_PREFIX}/update`)
462
- if (!res.ok) throw new Error(`HTTP ${res.status}`)
463
- const data = await res.json().catch(() => ({}))
464
- setUpdateState((s) => ({
465
- ...s,
466
- checking: false,
467
- currentVersion: data.currentVersion || s.currentVersion,
468
- latestVersion: data.latestVersion || '',
469
- updateAvailable: !!data.updateAvailable,
470
- canAutoUpdate: data.canAutoUpdate !== false,
471
- }))
472
- } catch (_) {
473
- setUpdateState((s) => ({ ...s, checking: false }))
474
- }
475
- }, [])
476
-
477
- React.useEffect(() => {
478
- checkUpdate()
479
- }, [checkUpdate])
480
-
481
- async function handleTriggerUpdate() {
482
- if (updateState.updating) return
483
- setUpdateState((s) => ({ ...s, updating: true, error: '', notice: '' }))
484
- try {
485
- const res = await fetch(`${ROUTE_PREFIX}/update`, {
486
- method: 'POST',
487
- headers: { 'x-dsh-plugin-update': '1' },
488
- })
489
- const data = await res.json().catch(() => ({}))
490
- if (!res.ok || data.ok === false || data.error) {
491
- throw new Error(data.error || `HTTP ${res.status}`)
492
- }
493
- const newVer = data.updatedVersion || updateState.latestVersion || updateState.currentVersion
494
- setUpdateState((s) => ({
495
- ...s,
496
- updating: false,
497
- updateAvailable: false,
498
- currentVersion: newVer,
499
- notice: t('update.done', { version: newVer }),
500
- }))
501
- } catch (e) {
502
- setUpdateState((s) => ({
503
- ...s,
504
- updating: false,
505
- error: t('update.failed', { error: e.message || String(e) }),
506
- }))
507
- }
508
- }
509
-
510
- React.useEffect(() => {
511
- if (snapshotStatus === 'ready' && snapshot?.value && typeof snapshot.value === 'object') {
512
- setDraft((prev) => (prev ? { ...prev, ...snapshot.value } : snapshot.value))
513
- }
514
- }, [snapshotStatus, snapshot?.value])
515
-
516
- // Save Key handler
517
- async function handleSaveKey() {
518
- if (!apiKeyInput.trim()) {
519
- setErr(t('key.empty_err'))
520
- return
521
- }
522
- setBusy('save-key')
523
- setErr('')
524
- setMsg('')
525
- try {
526
- const res = await fetch(`${ROUTE_PREFIX}/save-key`, {
527
- method: 'POST',
528
- headers: { 'Content-Type': 'application/json' },
529
- body: JSON.stringify({
530
- apiKey: apiKeyInput.trim(),
531
- apiKeyEnv: draft?.apiKeyEnv || 'CLINEBOT_API_KEY',
532
- }),
533
- })
534
- const data = await res.json().catch(() => ({}))
535
- if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`)
536
- setMsg(t('key.saved_msg', { status: data.validated ? 'OK' : 'Error' }))
537
- setApiKeyInput('')
538
- await load()
539
- } catch (e) {
540
- setErr(String(e.message || e))
541
- } finally {
542
- setBusy('')
543
- }
544
- }
545
-
546
- // Refresh Quota handler
547
- async function handleRefreshQuota() {
548
- setBusy('refresh-quota')
549
- setErr('')
550
- setMsg('')
551
- try {
552
- const res = await fetch(`${ROUTE_PREFIX}/usage`)
553
- const data = await res.json().catch(() => ({}))
554
- if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`)
555
- setStatus((prev) => ({ ...prev, usage: data }))
556
- setMsg(t('quota.refreshed_msg'))
557
- } catch (e) {
558
- setErr(String(e.message || e))
559
- } finally {
560
- setBusy('')
561
- }
562
- }
563
-
564
- // Sync official models from ClinePass plan
565
- async function handleSyncPlanModels() {
566
- setBusy('sync-models')
567
- setErr('')
568
- setMsg('')
569
- try {
570
- const res = await fetch(`${ROUTE_PREFIX}/models/sync`, { method: 'POST' })
571
- const data = await res.json().catch(() => ({}))
572
- if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`)
573
- setMsg(t('models.synced_msg', { total: data.totalModelsCount, discovered: data.discoveredCount }))
574
- await load()
575
- } catch (e) {
576
- setErr(String(e.message || e))
577
- } finally {
578
- setBusy('')
579
- }
580
- }
581
-
582
- // Register/Force Re-sync Provider in DSH
583
- async function handleRegister() {
584
- setBusy('register')
585
- setErr('')
586
- setMsg('')
587
- try {
588
- const res = await fetch(`${ROUTE_PREFIX}/register`, {
589
- method: 'POST',
590
- headers: { 'Content-Type': 'application/json' },
591
- body: JSON.stringify({ models: draft.enabledModels }),
592
- })
593
- const data = await res.json().catch(() => ({}))
594
- if (!data.ok) throw new Error(data.error || `HTTP ${res.status}`)
595
- setMsg(t('diag.resynced_msg', { count: draft.enabledModels?.length || 0 }))
596
- await load()
597
- } catch (e) {
598
- setErr(String(e.message || e))
599
- } finally {
600
- setBusy('')
601
- }
602
- }
603
-
604
- // Unregister
605
- async function handleUnregister() {
606
- setBusy('unregister')
607
- setErr('')
608
- setMsg('')
609
- try {
610
- const res = await fetch(`${ROUTE_PREFIX}/unregister`, { method: 'POST' })
611
- const data = await res.json().catch(() => ({}))
612
- if (!data.ok) throw new Error(data.error || `HTTP ${res.status}`)
613
- setMsg(t('diag.unregistered_msg'))
614
- await load()
615
- } catch (e) {
616
- setErr(String(e.message || e))
617
- } finally {
618
- setBusy('')
619
- }
620
- }
621
-
622
- // Smoke chat test
623
- async function handleSmoke() {
624
- setBusy('smoke')
625
- setErr('')
626
- setMsg('')
627
- setSmokeResult(null)
628
- try {
629
- const res = await fetch(`${ROUTE_PREFIX}/smoke`, {
630
- method: 'POST',
631
- headers: { 'Content-Type': 'application/json' },
632
- body: JSON.stringify({ model: draft?.defaultModel }),
633
- })
634
- const data = await res.json().catch(() => ({}))
635
- if (!data.ok) throw new Error(data.error || `HTTP ${res.status}`)
636
- setSmokeResult(data)
637
- setMsg(t('diag.smoke_ok', { latency: data.latencyMs }))
638
- } catch (e) {
639
- setErr(String(e.message || e))
640
- } finally {
641
- setBusy('')
642
- }
643
- }
644
-
645
- // Fast Browser Login
646
- async function handleFastLogin() {
647
- setBusy('fast-login')
648
- setErr('')
649
- setMsg(t('key.login_waiting'))
650
- try {
651
- const res = await fetch(`${ROUTE_PREFIX}/auth/begin`, { method: 'POST' })
652
- const data = await res.json().catch(() => ({}))
653
- if (!data.ok) throw new Error(data.error || 'Failed to initiate login')
654
- if (data.authUrl) {
655
- window.open(data.authUrl, '_blank')
656
- }
657
- } catch (e) {
658
- setErr(String(e.message || e))
659
- } finally {
660
- setBusy('')
661
- }
662
- }
663
-
664
- // Pin active account
665
- async function handlePinAccount(accountEnv) {
666
- if (scope && snapshotStatus === "ready") {
667
- try {
668
- await scope.set("activeAccount", accountEnv)
669
- } catch (e) {
670
- console.warn('[dsh-clinebot] Failed to set activeAccount on scope:', e)
671
- }
672
- }
673
- setBusy(`pin-${accountEnv}`)
674
- setErr('')
675
- try {
676
- const res = await fetch(`${ROUTE_PREFIX}/accounts/active`, {
677
- method: 'POST',
678
- headers: { 'Content-Type': 'application/json' },
679
- body: JSON.stringify({ account: accountEnv }),
680
- })
681
- const data = await res.json().catch(() => ({}))
682
- if (!data.ok) throw new Error(data.error || 'Failed to switch account')
683
- await load()
684
- } catch (e) {
685
- setErr(String(e.message || e))
686
- } finally {
687
- setBusy('')
688
- }
689
- }
690
-
691
- // Debounced persistence for model exclusion to avoid stuttering and request thrashing
692
- const debounceTimerRef = typeof React.useRef === 'function' ? React.useRef(null) : { current: null }
693
-
694
- React.useEffect(() => {
695
- return () => {
696
- if (debounceTimerRef.current) clearTimeout(debounceTimerRef.current)
697
- }
698
- }, [])
699
-
700
- function debounceSaveDisabledModels(nextDisabled) {
701
- if (debounceTimerRef.current) clearTimeout(debounceTimerRef.current)
702
- debounceTimerRef.current = setTimeout(async () => {
703
- if (scope && snapshotStatus === 'ready') {
704
- try {
705
- await scope.set('disabledModels', nextDisabled)
706
- } catch (e) {
707
- console.warn('[dsh-clinebot] Failed to set disabledModels on scope:', e)
708
- }
709
- }
710
- try {
711
- const res = await fetch(`${ROUTE_PREFIX}/models/toggle`, {
712
- method: 'POST',
713
- headers: { 'Content-Type': 'application/json' },
714
- body: JSON.stringify({ disabledModels: nextDisabled }),
715
- })
716
- const data = await res.json().catch(() => ({}))
717
- if (!data.ok) {
718
- setErr(data.error || 'Failed to persist disabled models')
719
- }
720
- } catch (e) {
721
- console.warn('[dsh-clinebot] Failed to persist disabled models:', e)
722
- setErr(String(e.message || e))
723
- }
724
- }, 280)
725
- }
726
-
727
- // Toggle model exclusion (disabledModels logic) with instant UI state update & debounced network persist
728
- function handleToggleModel(id) {
729
- if (!draft) return
730
- const currentDisabled = new Set(draft.disabledModels || [])
731
- if (currentDisabled.has(id)) {
732
- currentDisabled.delete(id) // Re-enable
733
- } else {
734
- currentDisabled.add(id) // Disable
735
- }
736
- const nextDisabled = Array.from(currentDisabled)
737
-
738
- // Compute next enabled models
739
- const allIds = (status?.availableModels || []).map((m) => m.id)
740
- const nextEnabled = allIds.filter((mId) => !currentDisabled.has(mId))
741
-
742
- setDraft({ ...draft, disabledModels: nextDisabled, enabledModels: nextEnabled })
743
- debounceSaveDisabledModels(nextDisabled)
299
+ return class ClientErrorBoundary extends React.Component {
300
+ constructor(props) {
301
+ super(props)
302
+ this.state = { hasError: false, error: null }
744
303
  }
745
-
746
- // Select all / filter models with instant UI state update & debounced network persist
747
- function handleSetModelsFilter(type) {
748
- if (!status?.availableModels) return
749
- const all = status.availableModels
750
- let allowed = new Set()
751
- if (type === 'all') {
752
- allowed = new Set(all.map((m) => m.id))
753
- } else if (type === 'vision') {
754
- allowed = new Set(all.filter((m) => m.input?.includes('image') || m.input?.includes('vision')).map((m) => m.id))
755
- } else if (type === 'coding') {
756
- allowed = new Set(all.filter((m) => m.category === 'coding').map((m) => m.id))
757
- } else if (type === 'recommended') {
758
- allowed = new Set(all.filter((m) => m.recommended).map((m) => m.id))
759
- }
760
-
761
- const nextDisabled = all.map((m) => m.id).filter((id) => !allowed.has(id))
762
- const nextEnabled = Array.from(allowed)
763
-
764
- setDraft({ ...draft, disabledModels: nextDisabled, enabledModels: nextEnabled })
765
- debounceSaveDisabledModels(nextDisabled)
304
+ static getDerivedStateFromError(error) {
305
+ return { hasError: true, error }
766
306
  }
767
-
768
- if (snapshotStatus === 'unavailable') {
769
- return React.createElement(
770
- 'div',
771
- { className: 'cb-page' },
772
- React.createElement('div', { className: 'cb-banner-warning' }, t('settings.unavailable'))
773
- )
307
+ componentDidCatch(error, info) {
308
+ console.warn('[dsh-clinebot] Client render error caught by boundary:', error, info)
774
309
  }
775
-
776
- if (!status || !draft || snapshotStatus === 'loading') {
777
- if (err) {
310
+ render() {
311
+ if (this.state.hasError) {
778
312
  return React.createElement(
779
313
  'div',
780
- { className: 'cb-page' },
781
- React.createElement('div', { className: 'cb-alert cb-alert-err' }, `${t('settings.loading')}: ${err}`),
314
+ { className: 'cb-alert-err' },
315
+ React.createElement('strong', null, '⚠️ ClineBot UI Error: '),
316
+ String(this.state.error?.message || this.state.error || 'Unknown rendering error'),
782
317
  React.createElement(
783
318
  'button',
784
319
  {
785
320
  type: 'button',
786
321
  className: 'cb-btn',
787
- style: { marginTop: '12px', alignSelf: 'flex-start' },
788
- onClick: () => {
789
- setErr('')
790
- load().catch((e) => setErr(String(e.message || e)))
791
- },
322
+ style: { marginLeft: '12px', padding: '2px 8px', fontSize: '11px' },
323
+ onClick: () => this.setState({ hasError: false, error: null }),
792
324
  },
793
- t('settings.retry')
325
+ 'Retry'
794
326
  )
795
327
  )
796
328
  }
797
- return React.createElement('div', { className: 'cb-page' }, t('settings.loading'))
329
+ return this.props.children
798
330
  }
799
-
800
- const healthOk = !!status.health?.ok
801
- const keyPresent = !!status.key?.present
802
- const isRegistered = !!status.isRegistered
803
- const modelsList = status.availableModels || []
804
- const disabledSet = new Set(draft.disabledModels || [])
805
- const enabledCount = modelsList.filter((m) => !disabledSet.has(m.id)).length
806
- const usage = status.usage
807
-
808
- return React.createElement(
331
+ }
332
+ } catch (e) {
333
+ return function FallbackBoundary(props) { return props?.children || null }
334
+ }
335
+ }
336
+ const ErrorBoundary = createErrorBoundary()
337
+
338
+ // --- src/client/progress-bar.js ---
339
+ function formatResetTime(isoString) {
340
+ if (!isoString) return ''
341
+ try {
342
+ const d = new Date(isoString)
343
+ if (Number.isNaN(d.getTime())) return ''
344
+ return d.toLocaleTimeString()
345
+ } catch {
346
+ return ''
347
+ }
348
+ }
349
+
350
+ function ProgressBar({ label, percentUsed, remainingPercent, resetsAt, t }) {
351
+ const pct = Math.max(0, Math.min(100, Math.round(percentUsed || 0)))
352
+ const isWarn = pct >= 80 && pct < 95
353
+ const isBad = pct >= 95
354
+ const barClass = isBad ? 'cb-progress-bar-bad' : isWarn ? 'cb-progress-bar-warn' : 'cb-progress-bar-fill'
355
+ const resetLabel = formatResetTime(resetsAt)
356
+
357
+ return React.createElement(
358
+ 'div',
359
+ { className: 'cb-progress-card' },
360
+ React.createElement(
361
+ 'div',
362
+ { className: 'cb-progress-head' },
363
+ React.createElement('span', { className: 'cb-progress-label' }, label),
364
+ React.createElement(
365
+ 'span',
366
+ { className: 'cb-progress-meta' },
367
+ t('quota.used', { pct }),
368
+ resetLabel ? ` · ${t('quota.reset_at', { time: resetLabel })}` : ''
369
+ )
370
+ ),
371
+ React.createElement(
372
+ 'div',
373
+ { className: 'cb-progress-track' },
374
+ React.createElement('div', {
375
+ className: barClass,
376
+ style: { width: `${pct}%` },
377
+ })
378
+ )
379
+ )
380
+ }
381
+
382
+ // --- src/client/components/update-banner.js ---
383
+ function UpdateBanner({ updateState, handleTriggerUpdate, t }) {
384
+ return React.createElement(
385
+ React.Fragment,
386
+ null,
387
+ React.createElement(
388
+ 'div',
389
+ {
390
+ className: 'cb-row',
391
+ style: {
392
+ padding: '10px 14px',
393
+ borderRadius: '8px',
394
+ border: '1px solid var(--dsw-alias-border-l2)',
395
+ background: 'var(--dsw-alias-bg-layer-2)',
396
+ justifyContent: 'space-between',
397
+ }
398
+ },
399
+ React.createElement(
809
400
  'div',
810
- { className: 'cb-page' },
811
-
812
- // Page Header
401
+ { style: { display: 'flex', alignItems: 'center', gap: '8px', fontSize: '13px' } },
402
+ React.createElement('span', { style: { color: 'var(--dsw-alias-label-secondary)', fontWeight: 500 } },
403
+ `v${updateState.currentVersion}`
404
+ ),
405
+ updateState.checking
406
+ ? React.createElement('span', { style: { color: 'var(--dsw-alias-label-tertiary)', fontSize: '12px' } },
407
+ t('update.checking')
408
+ )
409
+ : updateState.updateAvailable
410
+ ? React.createElement('span', { className: 'cb-badge cb-badge-warn' },
411
+ t('update.available', { latestVersion: updateState.latestVersion, currentVersion: updateState.currentVersion })
412
+ )
413
+ : React.createElement('span', { className: 'cb-badge cb-badge-ok' },
414
+ '✓ ' + t('update.up_to_date')
415
+ )
416
+ ),
417
+ updateState.updateAvailable
418
+ ? React.createElement(
419
+ 'button',
420
+ {
421
+ type: 'button',
422
+ className: 'cb-btn cb-btn-primary',
423
+ disabled: updateState.updating,
424
+ onClick: handleTriggerUpdate,
425
+ style: { padding: '5px 12px', fontSize: '12px' }
426
+ },
427
+ updateState.updating ? t('update.updating') : t('update.btn')
428
+ )
429
+ : null
430
+ ),
431
+ updateState.notice ? React.createElement('div', { className: 'cb-alert-ok' }, '✓ ' + updateState.notice) : null,
432
+ updateState.error ? React.createElement('div', { className: 'cb-alert-err' }, updateState.error) : null
433
+ )
434
+ }
435
+
436
+ // --- src/client/components/key-section.js ---
437
+ function KeySection({ keyPresent, apiKeyInput, setApiKeyInput, showKey, setShowKey, busy, draft, handleSaveKey, handleFastLogin, t }) {
438
+ return React.createElement(
439
+ 'div',
440
+ { className: 'cb-section-card' },
441
+ React.createElement('div', { className: 'cb-section-title' }, t('key.title')),
442
+ React.createElement('div', { className: 'cb-section-desc' }, t('key.desc')),
443
+ React.createElement(
444
+ 'div',
445
+ { className: 'cb-input-group' },
446
+ React.createElement('input', {
447
+ className: 'cb-input',
448
+ type: showKey ? 'text' : 'password',
449
+ placeholder: keyPresent ? t('key.placeholder_has') : t('key.placeholder_empty'),
450
+ value: apiKeyInput,
451
+ onChange: (e) => setApiKeyInput(e.target.value),
452
+ }),
453
+ React.createElement(
454
+ 'button',
455
+ {
456
+ type: 'button',
457
+ className: 'cb-btn',
458
+ onClick: () => setShowKey((v) => !v),
459
+ },
460
+ showKey ? t('key.hide') : t('key.show')
461
+ ),
462
+ React.createElement(
463
+ 'button',
464
+ {
465
+ type: 'button',
466
+ className: 'cb-btn cb-btn-primary',
467
+ disabled: !!busy || !String(apiKeyInput || '').trim(),
468
+ onClick: handleSaveKey,
469
+ },
470
+ busy === 'save-key' ? t('key.saving') : t('key.save')
471
+ ),
472
+ React.createElement(
473
+ 'button',
474
+ {
475
+ type: 'button',
476
+ className: 'cb-btn',
477
+ disabled: !!busy,
478
+ onClick: handleFastLogin,
479
+ },
480
+ busy === 'fast-login' ? '…' : t('key.login_fast')
481
+ )
482
+ ),
483
+ React.createElement(
484
+ 'div',
485
+ { className: 'cb-row', style: { fontSize: '13px', color: 'var(--dsw-alias-label-secondary)' } },
486
+ React.createElement('span', null, t('key.env_label')),
487
+ React.createElement('code', null, draft.apiKeyEnv || 'CLINEBOT_API_KEY'),
488
+ React.createElement('span', null, ' · '),
489
+ React.createElement(
490
+ 'a',
491
+ {
492
+ href: 'https://app.cline.bot',
493
+ target: '_blank',
494
+ rel: 'noreferrer',
495
+ style: { color: 'var(--dsw-alias-state-brand-primary)' },
496
+ },
497
+ t('key.get_key')
498
+ )
499
+ )
500
+ )
501
+ }
502
+
503
+ // --- src/client/components/accounts-section.js ---
504
+ function AccountsSection({ status, busy, handlePinAccount, t }) {
505
+ if (!status.accounts || !status.accounts.length) return null
506
+ return React.createElement(
507
+ 'div',
508
+ { className: 'cb-section-card' },
509
+ React.createElement('div', { className: 'cb-section-title' }, t('accounts.title')),
510
+ React.createElement('div', { className: 'cb-section-desc' }, t('accounts.desc')),
511
+ React.createElement(
512
+ 'table',
513
+ { className: 'cb-table' },
514
+ React.createElement(
515
+ 'thead',
516
+ null,
813
517
  React.createElement(
814
- 'div',
815
- { className: 'cb-header' },
816
- React.createElement(
817
- 'div',
818
- { className: 'cb-page-title' },
819
- `🤖 ${t('header.title')}`,
820
- React.createElement(
821
- 'span',
822
- { className: `cb-badge ${healthOk ? 'cb-badge-ok' : 'cb-badge-bad'}` },
823
- healthOk ? t('badge.online', { latency: status.health?.latencyMs }) : t('badge.offline')
824
- ),
518
+ 'tr',
519
+ null,
520
+ React.createElement('th', null, 'Account'),
521
+ React.createElement('th', null, 'Credential Ref'),
522
+ React.createElement('th', null, 'Status'),
523
+ React.createElement('th', { style: { textAlign: 'right' } }, 'Action')
524
+ )
525
+ ),
526
+ React.createElement(
527
+ 'tbody',
528
+ null,
529
+ status.accounts.map((acc) => {
530
+ const isActive = status.activeAccount === acc.apiKeyEnv || (!status.activeAccount && acc.id === 'default')
531
+ return React.createElement(
532
+ 'tr',
533
+ { key: acc.id },
534
+ React.createElement('td', null, React.createElement('strong', null, acc.label)),
535
+ React.createElement('td', null, React.createElement('code', null, acc.apiKeyEnv)),
825
536
  React.createElement(
826
- 'span',
827
- { className: `cb-badge ${keyPresent ? 'cb-badge-ok' : 'cb-badge-warn'}` },
828
- keyPresent ? t('badge.key_ok', { source: status.key?.source }) : t('badge.key_missing')
537
+ 'td',
538
+ null,
539
+ acc.present
540
+ ? React.createElement('span', { className: 'cb-badge cb-badge-ok' }, `Configured (${acc.source})`)
541
+ : React.createElement('span', { className: 'cb-badge cb-badge-warn' }, 'Missing Key'),
542
+ isActive
543
+ ? React.createElement('span', { className: 'cb-badge cb-badge-ok', style: { marginLeft: '6px' } }, t('accounts.active_badge'))
544
+ : null
829
545
  ),
830
546
  React.createElement(
831
- 'span',
832
- { className: `cb-badge ${isRegistered ? 'cb-badge-ok' : 'cb-badge-warn'}` },
833
- isRegistered ? t('badge.registered', { count: enabledCount }) : t('badge.not_registered')
547
+ 'td',
548
+ { style: { textAlign: 'right' } },
549
+ !isActive && acc.present
550
+ ? React.createElement(
551
+ 'button',
552
+ {
553
+ type: 'button',
554
+ className: 'cb-btn',
555
+ style: { padding: '4px 8px', fontSize: '11px' },
556
+ disabled: !!busy,
557
+ onClick: () => handlePinAccount(acc.apiKeyEnv),
558
+ },
559
+ t('accounts.pin_btn')
560
+ )
561
+ : null
834
562
  )
835
- ),
836
- React.createElement(
837
- 'div',
838
- { className: 'cb-page-sub' },
839
- t('header.sub')
840
563
  )
841
- ),
842
-
843
- // In-app Update Bar
844
- React.createElement(
564
+ })
565
+ )
566
+ )
567
+ )
568
+ }
569
+
570
+ // --- src/client/components/quota-section.js ---
571
+ function QuotaSection({ keyPresent, status, usage, busy, handleRefreshQuota, t }) {
572
+ return React.createElement(
573
+ React.Fragment,
574
+ null,
575
+ status.quotaWarning
576
+ ? React.createElement(
845
577
  'div',
846
- {
847
- className: 'cb-row',
848
- style: {
849
- padding: '10px 14px',
850
- borderRadius: '8px',
851
- border: '1px solid var(--dsw-alias-border-l2)',
852
- background: 'var(--dsw-alias-bg-layer-2)',
853
- justifyContent: 'space-between',
854
- }
855
- },
578
+ { className: status.quotaWarning.level === 'exhausted' ? 'cb-banner-exhausted' : 'cb-banner-warning' },
579
+ React.createElement('span', { style: { fontSize: '16px' } }, status.quotaWarning.level === 'exhausted' ? '🚨' : '⚠️'),
856
580
  React.createElement(
857
581
  'div',
858
- { style: { display: 'flex', alignItems: 'center', gap: '8px', fontSize: '13px' } },
859
- React.createElement('span', { style: { color: 'var(--dsw-alias-label-secondary)', fontWeight: 500 } },
860
- `v${updateState.currentVersion}`
861
- ),
862
- updateState.checking
863
- ? React.createElement('span', { style: { color: 'var(--dsw-alias-label-tertiary)', fontSize: '12px' } },
864
- t('update.checking')
582
+ { style: { flex: 1 } },
583
+ React.createElement('div', null, status.quotaWarning.message),
584
+ status.quotaWarning.resetsAt
585
+ ? React.createElement(
586
+ 'div',
587
+ { style: { fontSize: '11px', opacity: 0.9, marginTop: '2px' } },
588
+ t('quota.reset_at', { time: new Date(status.quotaWarning.resetsAt).toLocaleTimeString() })
865
589
  )
866
- : updateState.updateAvailable
867
- ? React.createElement('span', { className: 'cb-badge cb-badge-warn' },
868
- t('update.available', { latestVersion: updateState.latestVersion, currentVersion: updateState.currentVersion })
869
- )
870
- : React.createElement('span', { className: 'cb-badge cb-badge-ok' },
871
- '✓ ' + t('update.up_to_date')
872
- )
873
- ),
874
- updateState.updateAvailable
875
- ? React.createElement(
876
- 'button',
877
- {
878
- type: 'button',
879
- className: 'cb-btn cb-btn-primary',
880
- disabled: updateState.updating,
881
- onClick: handleTriggerUpdate,
882
- style: { padding: '5px 12px', fontSize: '12px' }
883
- },
884
- updateState.updating ? t('update.updating') : t('update.btn')
885
- )
886
- : null
887
- ),
888
-
889
- // Update notice & error banners
890
- updateState.notice
891
- ? React.createElement('div', { className: 'cb-alert-ok' }, '✓ ' + updateState.notice)
892
- : null,
893
- updateState.error
894
- ? React.createElement('div', { className: 'cb-alert-err' }, updateState.error)
895
- : null,
896
-
897
- // Notifications
898
- err ? React.createElement('div', { className: 'cb-alert-bad' }, err) : null,
899
- msg ? React.createElement('div', { className: 'cb-alert-ok' }, msg) : null,
900
-
901
- // Card 1: API Key and Credentials
902
- React.createElement(
590
+ : null
591
+ )
592
+ )
593
+ : null,
594
+ keyPresent
595
+ ? React.createElement(
903
596
  'div',
904
597
  { className: 'cb-section-card' },
905
598
  React.createElement(
906
599
  'div',
907
600
  { className: 'cb-section-title' },
908
- t('key.title')
909
- ),
910
- React.createElement(
911
- 'div',
912
- { className: 'cb-section-desc' },
913
- t('key.desc')
914
- ),
915
- React.createElement(
916
- 'div',
917
- { className: 'cb-input-group' },
918
- React.createElement('input', {
919
- className: 'cb-input',
920
- type: showKey ? 'text' : 'password',
921
- placeholder: keyPresent ? t('key.placeholder_has') : t('key.placeholder_empty'),
922
- value: apiKeyInput,
923
- onChange: (e) => setApiKeyInput(e.target.value),
924
- }),
925
- React.createElement(
926
- 'button',
927
- {
928
- type: 'button',
929
- className: 'cb-btn',
930
- onClick: () => setShowKey((v) => !v),
931
- },
932
- showKey ? t('key.hide') : t('key.show')
933
- ),
934
- React.createElement(
935
- 'button',
936
- {
937
- type: 'button',
938
- className: 'cb-btn cb-btn-primary',
939
- disabled: !!busy || !String(apiKeyInput || '').trim(),
940
- onClick: handleSaveKey,
941
- },
942
- busy === 'save-key' ? t('key.saving') : t('key.save')
943
- ),
601
+ t('quota.title'),
944
602
  React.createElement(
945
603
  'button',
946
604
  {
947
605
  type: 'button',
948
606
  className: 'cb-btn',
949
607
  disabled: !!busy,
950
- onClick: handleFastLogin,
608
+ onClick: handleRefreshQuota,
951
609
  },
952
- busy === 'fast-login' ? '…' : t('key.login_fast')
953
- )
954
- ),
955
- React.createElement(
956
- 'div',
957
- { className: 'cb-row', style: { fontSize: '13px', color: 'var(--dsw-alias-label-secondary)' } },
958
- React.createElement('span', null, t('key.env_label')),
959
- React.createElement('code', null, draft.apiKeyEnv || 'CLINEBOT_API_KEY'),
960
- React.createElement('span', null, ` · `),
961
- React.createElement(
962
- 'a',
963
- {
964
- href: 'https://app.cline.bot',
965
- target: '_blank',
966
- rel: 'noreferrer',
967
- style: { color: 'var(--dsw-alias-state-brand-primary)' },
968
- },
969
- t('key.get_key')
970
- )
971
- )
972
- ),
973
-
974
- // Accounts Pool Card
975
- status.accounts && status.accounts.length > 0
976
- ? React.createElement(
977
- 'div',
978
- { className: 'cb-section-card' },
979
- React.createElement(
980
- 'div',
981
- { className: 'cb-section-title' },
982
- t('accounts.title')
983
- ),
984
- React.createElement(
985
- 'div',
986
- { className: 'cb-section-desc' },
987
- t('accounts.desc')
988
- ),
989
- React.createElement(
990
- 'table',
991
- { className: 'cb-table' },
992
- React.createElement(
993
- 'thead',
994
- null,
995
- React.createElement(
996
- 'tr',
997
- null,
998
- React.createElement('th', null, 'Account'),
999
- React.createElement('th', null, 'Credential Ref'),
1000
- React.createElement('th', null, 'Status'),
1001
- React.createElement('th', { style: { textAlign: 'right' } }, 'Action')
1002
- )
1003
- ),
1004
- React.createElement(
1005
- 'tbody',
1006
- null,
1007
- status.accounts.map((acc) => {
1008
- const isActive = status.activeAccount === acc.apiKeyEnv || (!status.activeAccount && acc.id === 'default')
1009
- return React.createElement(
1010
- 'tr',
1011
- { key: acc.id },
1012
- React.createElement('td', null, React.createElement('strong', null, acc.label)),
1013
- React.createElement('td', null, React.createElement('code', null, acc.apiKeyEnv)),
1014
- React.createElement(
1015
- 'td',
1016
- null,
1017
- acc.present
1018
- ? React.createElement('span', { className: 'cb-badge cb-badge-ok' }, `Configured (${acc.source})`)
1019
- : React.createElement('span', { className: 'cb-badge cb-badge-warn' }, 'Missing Key'),
1020
- isActive
1021
- ? React.createElement('span', { className: 'cb-badge cb-badge-ok', style: { marginLeft: '6px' } }, t('accounts.active_badge'))
1022
- : null
1023
- ),
1024
- React.createElement(
1025
- 'td',
1026
- { style: { textAlign: 'right' } },
1027
- !isActive && acc.present
1028
- ? React.createElement(
1029
- 'button',
1030
- {
1031
- type: 'button',
1032
- className: 'cb-btn',
1033
- style: { padding: '4px 8px', fontSize: '11px' },
1034
- disabled: !!busy,
1035
- onClick: () => handlePinAccount(acc.apiKeyEnv),
1036
- },
1037
- t('accounts.pin_btn')
1038
- )
1039
- : null
1040
- )
1041
- )
1042
- })
1043
- )
1044
- )
1045
- )
1046
- : null,
1047
-
1048
- // Quota Warning Banner
1049
- status.quotaWarning
1050
- ? React.createElement(
1051
- 'div',
1052
- { className: status.quotaWarning.level === 'exhausted' ? 'cb-banner-exhausted' : 'cb-banner-warning' },
1053
- React.createElement('span', { style: { fontSize: '16px' } }, status.quotaWarning.level === 'exhausted' ? '🚨' : '⚠️'),
1054
- React.createElement(
1055
- 'div',
1056
- { style: { flex: 1 } },
1057
- React.createElement('div', null, status.quotaWarning.message),
1058
- status.quotaWarning.resetsAt
1059
- ? React.createElement(
1060
- 'div',
1061
- { style: { fontSize: '11px', opacity: 0.9, marginTop: '2px' } },
1062
- t('quota.reset_at', { time: new Date(status.quotaWarning.resetsAt).toLocaleTimeString() })
1063
- )
1064
- : null
1065
- )
1066
- )
1067
- : null,
1068
-
1069
- // Card 2: Subscription Limits & Usage Dashboard
1070
- keyPresent
1071
- ? React.createElement(
1072
- 'div',
1073
- { className: 'cb-section-card' },
1074
- React.createElement(
1075
- 'div',
1076
- { className: 'cb-section-title' },
1077
- t('quota.title'),
1078
- React.createElement(
1079
- 'button',
1080
- {
1081
- type: 'button',
1082
- className: 'cb-btn',
1083
- disabled: !!busy,
1084
- onClick: handleRefreshQuota,
1085
- },
1086
- busy === 'refresh-quota' ? t('quota.refreshing') : t('quota.refresh')
1087
- )
1088
- ),
1089
- React.createElement(
1090
- 'div',
1091
- { className: 'cb-section-desc' },
1092
- usage?.user?.email
1093
- ? t('quota.account', { email: usage.user.email, plan: usage.plan || 'ClinePass ($9.99/mo)' })
1094
- : t('quota.desc')
1095
- ),
1096
- React.createElement(
1097
- 'div',
1098
- { className: 'cb-grid-2' },
1099
- React.createElement(ProgressBar, {
1100
- label: t('quota.window_5h'),
1101
- percentUsed: usage?.windows?.fiveHour?.percentUsed || 0,
1102
- remainingPercent: usage?.windows?.fiveHour?.remainingPercent || 100,
1103
- resetsAt: usage?.windows?.fiveHour?.resetsAt,
1104
- t,
1105
- }),
1106
- React.createElement(ProgressBar, {
1107
- label: t('quota.window_weekly'),
1108
- percentUsed: usage?.windows?.weekly?.percentUsed || 0,
1109
- remainingPercent: usage?.windows?.weekly?.remainingPercent || 100,
1110
- resetsAt: usage?.windows?.weekly?.resetsAt,
1111
- t,
1112
- })
1113
- )
1114
- )
1115
- : null,
1116
-
1117
- // Card 3: Model Picker Management with Official Plan Sync & Auto-Enable
1118
- React.createElement(
1119
- 'div',
1120
- { className: 'cb-section-card' },
1121
- React.createElement(
1122
- 'div',
1123
- { className: 'cb-section-title' },
1124
- t('models.title', { enabled: enabledCount, total: modelsList.length }),
1125
- React.createElement(
1126
- 'div',
1127
- { className: 'cb-row' },
1128
- React.createElement(
1129
- 'button',
1130
- {
1131
- type: 'button',
1132
- className: 'cb-btn',
1133
- disabled: !!busy || !keyPresent,
1134
- onClick: handleSyncPlanModels,
1135
- },
1136
- busy === 'sync-models' ? t('models.syncing') : t('models.sync')
1137
- ),
1138
- React.createElement(
1139
- 'button',
1140
- { type: 'button', className: 'cb-btn', onClick: () => handleSetModelsFilter('all') },
1141
- t('models.all')
1142
- ),
1143
- React.createElement(
1144
- 'button',
1145
- { type: 'button', className: 'cb-btn', onClick: () => handleSetModelsFilter('vision') },
1146
- t('models.vision')
1147
- ),
1148
- React.createElement(
1149
- 'button',
1150
- { type: 'button', className: 'cb-btn', onClick: () => handleSetModelsFilter('coding') },
1151
- t('models.coding')
1152
- ),
1153
- React.createElement(
1154
- 'button',
1155
- { type: 'button', className: 'cb-btn', onClick: () => handleSetModelsFilter('recommended') },
1156
- t('models.recommended')
1157
- )
610
+ busy === 'refresh-quota' ? t('quota.refreshing') : t('quota.refresh')
1158
611
  )
1159
612
  ),
1160
613
  React.createElement(
1161
614
  'div',
1162
615
  { className: 'cb-section-desc' },
1163
- t('models.desc')
616
+ usage?.user?.email
617
+ ? t('quota.account', { email: usage.user.email, plan: usage.plan || 'ClinePass ($9.99/mo)' })
618
+ : t('quota.desc')
1164
619
  ),
1165
620
  React.createElement(
1166
- 'table',
1167
- { className: 'cb-table' },
1168
- React.createElement(
1169
- 'thead',
1170
- null,
1171
- React.createElement(
1172
- 'tr',
1173
- null,
1174
- React.createElement('th', { style: { width: '40px' } }, t('models.th_active')),
1175
- React.createElement('th', null, t('models.th_name')),
1176
- React.createElement('th', null, t('models.th_id')),
1177
- React.createElement('th', null, t('models.th_ctx')),
1178
- React.createElement('th', null, t('models.th_caps'))
1179
- )
1180
- ),
1181
- React.createElement(
1182
- 'tbody',
1183
- null,
1184
- modelsList.map((m) => {
1185
- const isEnabled = !disabledSet.has(m.id)
1186
- return React.createElement(
1187
- 'tr',
1188
- { key: m.id },
1189
- React.createElement(
1190
- 'td',
1191
- null,
1192
- React.createElement('input', {
1193
- type: 'checkbox',
1194
- checked: isEnabled,
1195
- onChange: () => handleToggleModel(m.id),
1196
- })
1197
- ),
1198
- React.createElement(
1199
- 'td',
1200
- null,
1201
- React.createElement('strong', null, m.name),
1202
- m.recommended
1203
- ? React.createElement('span', { className: 'cb-badge cb-badge-ok', style: { marginLeft: '6px' } }, t('models.star'))
1204
- : null,
1205
- m.isCustom
1206
- ? React.createElement('span', { className: 'cb-badge', style: { marginLeft: '6px', opacity: 0.8 } }, t('models.auto_new'))
1207
- : null
1208
- ),
1209
- React.createElement('td', null, React.createElement('code', null, m.id)),
1210
- React.createElement('td', null, `${Math.round((m.contextLength || 200000) / 1000)}k`),
1211
- React.createElement(
1212
- 'td',
1213
- null,
1214
- React.createElement('span', { className: 'cb-badge' }, m.category || 'general'),
1215
- m.input?.includes('image') || m.input?.includes('vision')
1216
- ? React.createElement('span', { className: 'cb-badge', style: { marginLeft: '4px' } }, 'Vision')
1217
- : null,
1218
- m.reasoningEfforts?.length || m.reasoning
1219
- ? React.createElement('span', { className: 'cb-badge', style: { marginLeft: '4px' } }, '🧠 Reasoning')
1220
- : null
1221
- )
1222
- )
1223
- })
1224
- )
621
+ 'div',
622
+ { className: 'cb-grid-2' },
623
+ React.createElement(ProgressBar, {
624
+ label: t('quota.window_5h'),
625
+ percentUsed: usage?.windows?.fiveHour?.percentUsed || 0,
626
+ remainingPercent: usage?.windows?.fiveHour?.remainingPercent || 100,
627
+ resetsAt: usage?.windows?.fiveHour?.resetsAt,
628
+ t,
629
+ }),
630
+ React.createElement(ProgressBar, {
631
+ label: t('quota.window_weekly'),
632
+ percentUsed: usage?.windows?.weekly?.percentUsed || 0,
633
+ remainingPercent: usage?.windows?.weekly?.remainingPercent || 100,
634
+ resetsAt: usage?.windows?.weekly?.resetsAt,
635
+ t,
636
+ })
1225
637
  )
638
+ )
639
+ : null
640
+ )
641
+ }
642
+
643
+ // --- src/client/components/models-section.js ---
644
+ function ModelsSection({ modelsList, disabledSet, enabledCount, keyPresent, busy, handleSyncPlanModels, handleSetModelsFilter, handleToggleModel, t }) {
645
+ return React.createElement(
646
+ 'div',
647
+ { className: 'cb-section-card' },
648
+ React.createElement(
649
+ 'div',
650
+ { className: 'cb-section-title' },
651
+ t('models.title', { enabled: enabledCount, total: modelsList.length }),
652
+ React.createElement(
653
+ 'div',
654
+ { className: 'cb-row' },
655
+ React.createElement(
656
+ 'button',
657
+ {
658
+ type: 'button',
659
+ className: 'cb-btn',
660
+ disabled: !!busy || !keyPresent,
661
+ onClick: handleSyncPlanModels,
662
+ },
663
+ busy === 'sync-models' ? t('models.syncing') : t('models.sync')
1226
664
  ),
1227
-
1228
- // Card 4: Session Metrics & Usage Tracking
665
+ React.createElement('button', { type: 'button', className: 'cb-btn', onClick: () => handleSetModelsFilter('all') }, t('models.all')),
666
+ React.createElement('button', { type: 'button', className: 'cb-btn', onClick: () => handleSetModelsFilter('vision') }, t('models.vision')),
667
+ React.createElement('button', { type: 'button', className: 'cb-btn', onClick: () => handleSetModelsFilter('coding') }, t('models.coding')),
668
+ React.createElement('button', { type: 'button', className: 'cb-btn', onClick: () => handleSetModelsFilter('recommended') }, t('models.recommended'))
669
+ )
670
+ ),
671
+ React.createElement('div', { className: 'cb-section-desc' }, t('models.desc')),
672
+ React.createElement(
673
+ 'table',
674
+ { className: 'cb-table' },
675
+ React.createElement(
676
+ 'thead',
677
+ null,
1229
678
  React.createElement(
1230
- 'div',
1231
- { className: 'cb-section-card' },
1232
- React.createElement(
1233
- 'div',
1234
- { className: 'cb-section-title' },
1235
- t('stats.title')
1236
- ),
1237
- React.createElement(
1238
- 'div',
1239
- { className: 'cb-section-desc' },
1240
- t('stats.desc')
1241
- ),
1242
- React.createElement(
1243
- 'div',
1244
- { className: 'cb-grid-2' },
1245
- React.createElement(
1246
- 'div',
1247
- { className: 'cb-stat-box' },
1248
- React.createElement('div', { className: 'cb-stat-val' }, `${status.sessionStats?.successfulRequests || 0} / ${status.sessionStats?.totalRequests || 0}`),
1249
- React.createElement('div', { className: 'cb-stat-lbl' }, t('stats.requests'))
1250
- ),
679
+ 'tr',
680
+ null,
681
+ React.createElement('th', { style: { width: '40px' } }, t('models.th_active')),
682
+ React.createElement('th', null, t('models.th_name')),
683
+ React.createElement('th', null, t('models.th_id')),
684
+ React.createElement('th', null, t('models.th_ctx')),
685
+ React.createElement('th', null, t('models.th_caps'))
686
+ )
687
+ ),
688
+ React.createElement(
689
+ 'tbody',
690
+ null,
691
+ modelsList.map((m) => {
692
+ const isEnabled = !disabledSet.has(m.id)
693
+ return React.createElement(
694
+ 'tr',
695
+ { key: m.id },
1251
696
  React.createElement(
1252
- 'div',
1253
- { className: 'cb-stat-box' },
1254
- React.createElement('div', { className: 'cb-stat-val' }, `~${status.sessionStats?.totalTokensEst || 0}`),
1255
- React.createElement('div', { className: 'cb-stat-lbl' }, t('stats.tokens'))
697
+ 'td',
698
+ null,
699
+ React.createElement('input', {
700
+ type: 'checkbox',
701
+ checked: isEnabled,
702
+ onChange: () => handleToggleModel(m.id),
703
+ })
1256
704
  ),
1257
705
  React.createElement(
1258
- 'div',
1259
- { className: 'cb-stat-box' },
1260
- React.createElement('div', { className: 'cb-stat-val' }, status.sessionStats?.lastLatencyMs ? `${status.sessionStats.lastLatencyMs} ms` : '—'),
1261
- React.createElement('div', { className: 'cb-stat-lbl' }, t('stats.latency'))
706
+ 'td',
707
+ null,
708
+ React.createElement('strong', null, m.name),
709
+ m.recommended
710
+ ? React.createElement('span', { className: 'cb-badge cb-badge-ok', style: { marginLeft: '6px' } }, t('models.star'))
711
+ : null,
712
+ m.isCustom
713
+ ? React.createElement('span', { className: 'cb-badge', style: { marginLeft: '6px', opacity: 0.8 } }, t('models.auto_new'))
714
+ : null
1262
715
  ),
716
+ React.createElement('td', null, React.createElement('code', null, m.id)),
717
+ React.createElement('td', null, `${Math.round((m.contextLength || 200000) / 1000)}k`),
1263
718
  React.createElement(
1264
- 'div',
1265
- { className: 'cb-stat-box' },
1266
- React.createElement('div', { className: 'cb-stat-val' }, status.sessionStats?.lastRequestAt ? new Date(status.sessionStats.lastRequestAt).toLocaleTimeString() : '—'),
1267
- React.createElement('div', { className: 'cb-stat-lbl' }, t('stats.last_req'))
719
+ 'td',
720
+ null,
721
+ React.createElement('span', { className: 'cb-badge' }, m.category || 'general'),
722
+ m.input?.includes('image') || m.input?.includes('vision')
723
+ ? React.createElement('span', { className: 'cb-badge', style: { marginLeft: '4px' } }, 'Vision')
724
+ : null,
725
+ m.reasoningEfforts?.length || m.reasoning
726
+ ? React.createElement('span', { className: 'cb-badge', style: { marginLeft: '4px' } }, '🧠 Reasoning')
727
+ : null
1268
728
  )
1269
729
  )
1270
- ),
1271
-
1272
- // Card 5: Diagnostics & Sync with DSH
1273
- React.createElement(
730
+ })
731
+ )
732
+ )
733
+ )
734
+ }
735
+
736
+ // --- src/client/components/stats-section.js ---
737
+ function StatsSection({ status, t }) {
738
+ return React.createElement(
739
+ 'div',
740
+ { className: 'cb-section-card' },
741
+ React.createElement('div', { className: 'cb-section-title' }, t('stats.title')),
742
+ React.createElement('div', { className: 'cb-section-desc' }, t('stats.desc')),
743
+ React.createElement(
744
+ 'div',
745
+ { className: 'cb-grid-2' },
746
+ React.createElement(
747
+ 'div',
748
+ { className: 'cb-stat-box' },
749
+ React.createElement('div', { className: 'cb-stat-val' }, `${status.sessionStats?.successfulRequests || 0} / ${status.sessionStats?.totalRequests || 0}`),
750
+ React.createElement('div', { className: 'cb-stat-lbl' }, t('stats.requests'))
751
+ ),
752
+ React.createElement(
753
+ 'div',
754
+ { className: 'cb-stat-box' },
755
+ React.createElement('div', { className: 'cb-stat-val' }, `~${status.sessionStats?.totalTokensEst || 0}`),
756
+ React.createElement('div', { className: 'cb-stat-lbl' }, t('stats.tokens'))
757
+ ),
758
+ React.createElement(
759
+ 'div',
760
+ { className: 'cb-stat-box' },
761
+ React.createElement('div', { className: 'cb-stat-val' }, status.sessionStats?.lastLatencyMs ? `${status.sessionStats.lastLatencyMs} ms` : '—'),
762
+ React.createElement('div', { className: 'cb-stat-lbl' }, t('stats.latency'))
763
+ ),
764
+ React.createElement(
765
+ 'div',
766
+ { className: 'cb-stat-box' },
767
+ React.createElement('div', { className: 'cb-stat-val' }, status.sessionStats?.lastRequestAt ? new Date(status.sessionStats.lastRequestAt).toLocaleTimeString() : '—'),
768
+ React.createElement('div', { className: 'cb-stat-lbl' }, t('stats.last_req'))
769
+ )
770
+ )
771
+ )
772
+ }
773
+
774
+ // --- src/client/components/diag-section.js ---
775
+ function DiagSection({ keyPresent, isRegistered, busy, smokeResult, handleSmoke, handleUnregister, handleRegister, t }) {
776
+ return React.createElement(
777
+ 'div',
778
+ { className: 'cb-section-card' },
779
+ React.createElement('div', { className: 'cb-section-title' }, t('diag.title')),
780
+ React.createElement('div', { className: 'cb-section-desc' }, t('diag.desc')),
781
+ React.createElement(
782
+ 'div',
783
+ { className: 'cb-row' },
784
+ React.createElement(
785
+ 'button',
786
+ {
787
+ type: 'button',
788
+ className: 'cb-btn',
789
+ disabled: !!busy || !keyPresent,
790
+ onClick: handleSmoke,
791
+ },
792
+ busy === 'smoke' ? t('diag.smoke_testing') : t('diag.smoke_btn')
793
+ ),
794
+ isRegistered
795
+ ? React.createElement(
796
+ 'button',
797
+ {
798
+ type: 'button',
799
+ className: 'cb-btn cb-btn-danger',
800
+ disabled: !!busy,
801
+ onClick: handleUnregister,
802
+ },
803
+ busy === 'unregister' ? t('diag.unregistering') : t('diag.unregister_btn')
804
+ )
805
+ : null,
806
+ React.createElement(
807
+ 'button',
808
+ {
809
+ type: 'button',
810
+ className: 'cb-btn cb-btn-primary',
811
+ style: { marginLeft: 'auto' },
812
+ disabled: !!busy,
813
+ onClick: handleRegister,
814
+ },
815
+ busy === 'register' ? t('diag.resyncing') : t('diag.resync_btn')
816
+ )
817
+ ),
818
+ smokeResult
819
+ ? React.createElement(
1274
820
  'div',
1275
- { className: 'cb-section-card' },
1276
- React.createElement(
1277
- 'div',
1278
- { className: 'cb-section-title' },
1279
- t('diag.title')
1280
- ),
1281
- React.createElement(
1282
- 'div',
1283
- { className: 'cb-section-desc' },
1284
- t('diag.desc')
1285
- ),
1286
- React.createElement(
1287
- 'div',
1288
- { className: 'cb-row' },
1289
- React.createElement(
1290
- 'button',
1291
- {
1292
- type: 'button',
1293
- className: 'cb-btn',
1294
- disabled: !!busy || !keyPresent,
1295
- onClick: handleSmoke,
1296
- },
1297
- busy === 'smoke' ? t('diag.smoke_testing') : t('diag.smoke_btn')
1298
- ),
1299
- isRegistered
1300
- ? React.createElement(
1301
- 'button',
1302
- {
1303
- type: 'button',
1304
- className: 'cb-btn cb-btn-danger',
1305
- disabled: !!busy,
1306
- onClick: handleUnregister,
1307
- },
1308
- busy === 'unregister' ? t('diag.unregistering') : t('diag.unregister_btn')
1309
- )
1310
- : null,
1311
- React.createElement(
1312
- 'button',
1313
- {
1314
- type: 'button',
1315
- className: 'cb-btn cb-btn-primary',
1316
- style: { marginLeft: 'auto' },
1317
- disabled: !!busy,
1318
- onClick: handleRegister,
1319
- },
1320
- busy === 'register' ? t('diag.resyncing') : t('diag.resync_btn')
1321
- )
1322
- ),
1323
- smokeResult
1324
- ? React.createElement(
1325
- 'div',
1326
- { className: 'cb-preview' },
1327
- `✅ Latency: ${smokeResult.latencyMs} ms | Model: ${smokeResult.model}\nResponse: ${smokeResult.preview || '(empty)'}`
1328
- )
1329
- : null
821
+ { className: 'cb-preview' },
822
+ `✅ Latency: ${smokeResult.latencyMs} ms | Model: ${smokeResult.model}
823
+ Response: ${smokeResult.preview || '(empty)'}`
1330
824
  )
1331
- )
825
+ : null
826
+ )
827
+ }
828
+
829
+ // --- src/client/settings-page.js ---
830
+ function SettingsPage(props) {
831
+ const ctx = props?.ctx
832
+ const t = props?.t || makeT(en, en)
833
+
834
+ const scope = React.useMemo(() => {
835
+ const s = (ctx?.get && ctx.get('lanSettings')) || ctx?.settingsScope
836
+ if (!s?.bind) return undefined
837
+ try {
838
+ return s.bind({ namespace: NS })
839
+ } catch (_) {
840
+ return undefined
841
+ }
842
+ }, [ctx])
843
+
844
+ const subscribe = React.useMemo(() => {
845
+ return (cb) => {
846
+ if (!scope?.subscribe) return () => {}
847
+ try {
848
+ return scope.subscribe(cb) || (() => {})
849
+ } catch (_) {
850
+ return () => {}
851
+ }
852
+ }
853
+ }, [scope])
854
+
855
+ const getSnapshot = React.useCallback(() => {
856
+ if (!scope?.getSnapshot) return SNAPSHOT_LOADING
857
+ try {
858
+ return scope.getSnapshot() || SNAPSHOT_LOADING
859
+ } catch (_) {
860
+ return SNAPSHOT_LOADING
861
+ }
862
+ }, [scope])
863
+
864
+ const snapshot = React.useSyncExternalStore(
865
+ subscribe,
866
+ getSnapshot,
867
+ React.useCallback(() => SNAPSHOT_LOADING, [])
868
+ )
869
+ const snapshotStatus = snapshot?.status || 'loading'
870
+
871
+ const [status, setStatus] = React.useState(null)
872
+ const [draft, setDraft] = React.useState(null)
873
+ const [busy, setBusy] = React.useState('')
874
+ const [err, setErr] = React.useState('')
875
+ const [msg, setMsg] = React.useState('')
876
+ const [smokeResult, setSmokeResult] = React.useState(null)
877
+
878
+ // Plugin in-app updater state
879
+ const [updateState, setUpdateState] = React.useState({
880
+ checking: false,
881
+ updating: false,
882
+ currentVersion: '0.3.12',
883
+ latestVersion: '',
884
+ updateAvailable: false,
885
+ canAutoUpdate: true,
886
+ error: '',
887
+ notice: '',
888
+ })
889
+
890
+ // Key input state
891
+ const [apiKeyInput, setApiKeyInput] = React.useState('')
892
+ const [showKey, setShowKey] = React.useState(false)
893
+
894
+ // Accounts state
895
+ const [newAccountLabel, setNewAccountLabel] = React.useState('')
896
+ const [newAccountEnv, setNewAccountEnv] = React.useState('')
897
+ const [showAddAccount, setShowAddAccount] = React.useState(false)
898
+
899
+ // Filter & Search for Models
900
+ const [modelsFilter, setModelsFilter] = React.useState('all')
901
+ const [modelsSearch, setModelsSearch] = React.useState('')
902
+ const [disabledModels, setDisabledModels] = React.useState([])
903
+ const [advancedOpen, setAdvancedOpen] = React.useState(false)
904
+
905
+ // Loopback fast login state
906
+ const [fastLoginActive, setFastLoginActive] = React.useState(false)
907
+ const [fastLoginNotice, setFastLoginNotice] = React.useState('')
908
+
909
+ React.useEffect(() => {
910
+ ensureCss()
911
+ }, [])
912
+
913
+ const load = React.useCallback(async () => {
914
+ setErr('')
915
+ const res = await fetch(`${ROUTE_PREFIX}/status`, { cache: 'no-store' })
916
+ const data = await res.json().catch(() => ({}))
917
+ if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`)
918
+ setStatus(data)
919
+ setDraft(data.config || {})
920
+ }, [])
921
+
922
+ React.useEffect(() => {
923
+ load().catch((e) => setErr(String(e.message || e)))
924
+ }, [load])
925
+
926
+ const checkUpdate = React.useCallback(async () => {
927
+ setUpdateState((s) => ({ ...s, checking: true, error: '' }))
928
+ try {
929
+ const res = await fetch(`${ROUTE_PREFIX}/update`)
930
+ if (!res.ok) throw new Error(`HTTP ${res.status}`)
931
+ const data = await res.json().catch(() => ({}))
932
+ setUpdateState((s) => ({
933
+ ...s,
934
+ checking: false,
935
+ currentVersion: data.currentVersion || s.currentVersion,
936
+ latestVersion: data.latestVersion || '',
937
+ updateAvailable: !!data.updateAvailable,
938
+ canAutoUpdate: data.canAutoUpdate !== false,
939
+ }))
940
+ } catch (_) {
941
+ setUpdateState((s) => ({ ...s, checking: false }))
942
+ }
943
+ }, [])
944
+
945
+ React.useEffect(() => {
946
+ checkUpdate()
947
+ }, [checkUpdate])
948
+
949
+ async function handleTriggerUpdate() {
950
+ if (updateState.updating) return
951
+ setUpdateState((s) => ({ ...s, updating: true, error: '', notice: '' }))
952
+ try {
953
+ const res = await fetch(`${ROUTE_PREFIX}/update`, {
954
+ method: 'POST',
955
+ headers: { 'x-dsh-plugin-update': '1' },
956
+ })
957
+ const data = await res.json().catch(() => ({}))
958
+ if (!res.ok || data.ok === false || data.error) {
959
+ throw new Error(data.error || `HTTP ${res.status}`)
960
+ }
961
+ const newVer = data.updatedVersion || updateState.latestVersion || updateState.currentVersion
962
+ setUpdateState((s) => ({
963
+ ...s,
964
+ updating: false,
965
+ updateAvailable: false,
966
+ currentVersion: newVer,
967
+ notice: t('update.done', { version: newVer }),
968
+ }))
969
+ setTimeout(() => checkUpdate(), 2000)
970
+ } catch (err) {
971
+ setUpdateState((s) => ({
972
+ ...s,
973
+ updating: false,
974
+ error: t('update.failed', { error: String(err.message || err) }),
975
+ }))
976
+ }
977
+ }
978
+
979
+ async function handleSaveKey() {
980
+ const keyVal = String(apiKeyInput || '').trim()
981
+ if (!keyVal) {
982
+ setErr(t('key.empty_err'))
983
+ return
984
+ }
985
+ setBusy('save-key')
986
+ setErr('')
987
+ setMsg('')
988
+ try {
989
+ const res = await fetch(`${ROUTE_PREFIX}/save-key`, {
990
+ method: 'POST',
991
+ headers: { 'Content-Type': 'application/json' },
992
+ body: JSON.stringify({ apiKey: keyVal, apiKeyEnv: draft?.apiKeyEnv }),
993
+ })
994
+ const data = await res.json().catch(() => ({}))
995
+ if (!res.ok || !data.ok) throw new Error(data.error || `HTTP ${res.status}`)
996
+ setApiKeyInput('')
997
+ setMsg(t('key.saved_msg', { status: data.validated ? 'OK' : 'Notice (check console)' }))
998
+ await load()
999
+ } catch (e) {
1000
+ setErr(String(e.message || e))
1001
+ } finally {
1002
+ setBusy('')
1003
+ }
1004
+ }
1005
+
1006
+ async function handleRefreshQuota() {
1007
+ setBusy('refresh-quota')
1008
+ setErr('')
1009
+ setMsg('')
1010
+ try {
1011
+ const res = await fetch(`${ROUTE_PREFIX}/usage`)
1012
+ const data = await res.json().catch(() => ({}))
1013
+ if (!res.ok || !data.ok) throw new Error(data.error || `HTTP ${res.status}`)
1014
+ setStatus((prev) => (prev ? { ...prev, usage: data } : prev))
1015
+ setMsg(t('quota.refreshed_msg'))
1016
+ } catch (e) {
1017
+ setErr(String(e.message || e))
1018
+ } finally {
1019
+ setBusy('')
1020
+ }
1021
+ }
1022
+
1023
+ async function handleSyncPlanModels() {
1024
+ setBusy('sync-models')
1025
+ setErr('')
1026
+ setMsg('')
1027
+ try {
1028
+ const res = await fetch(`${ROUTE_PREFIX}/models/sync`, { method: 'POST' })
1029
+ const data = await res.json().catch(() => ({}))
1030
+ if (!res.ok || !data.ok) throw new Error(data.error || `HTTP ${res.status}`)
1031
+ setMsg(t('models.synced_msg', { total: data.totalModelsCount, discovered: data.discoveredCount }))
1032
+ await load()
1033
+ } catch (e) {
1034
+ setErr(String(e.message || e))
1035
+ } finally {
1036
+ setBusy('')
1037
+ }
1038
+ }
1039
+
1040
+ async function handleRegister() {
1041
+ setBusy('register')
1042
+ setErr('')
1043
+ setMsg('')
1044
+ try {
1045
+ const res = await fetch(`${ROUTE_PREFIX}/register`, { method: 'POST' })
1046
+ const data = await res.json().catch(() => ({}))
1047
+ if (!res.ok || !data.ok) throw new Error(data.error || `HTTP ${res.status}`)
1048
+ const count = status?.availableModels?.filter((m) => !(draft?.disabledModels || []).includes(m.id)).length || 0
1049
+ setMsg(t('diag.resynced_msg', { count }))
1050
+ await load()
1051
+ } catch (e) {
1052
+ setErr(String(e.message || e))
1053
+ } finally {
1054
+ setBusy('')
1332
1055
  }
1056
+ }
1333
1057
 
1334
- // Accordion item for Plugins section
1335
- function PluginCard(props) {
1336
- const [open, setOpen] = React.useState(false)
1337
- const t = props?.t || makeT(en, en)
1338
- React.useEffect(() => {
1339
- ensureCss()
1340
- }, [])
1058
+ async function handleUnregister() {
1059
+ setBusy('unregister')
1060
+ setErr('')
1061
+ setMsg('')
1062
+ try {
1063
+ const res = await fetch(`${ROUTE_PREFIX}/unregister`, { method: 'POST' })
1064
+ const data = await res.json().catch(() => ({}))
1065
+ if (!res.ok || !data.ok) throw new Error(data.error || `HTTP ${res.status}`)
1066
+ setMsg(t('diag.unregistered_msg'))
1067
+ await load()
1068
+ } catch (e) {
1069
+ setErr(String(e.message || e))
1070
+ } finally {
1071
+ setBusy('')
1072
+ }
1073
+ }
1074
+
1075
+ async function handleSmoke() {
1076
+ setBusy('smoke')
1077
+ setErr('')
1078
+ setSmokeResult(null)
1079
+ try {
1080
+ const res = await fetch(`${ROUTE_PREFIX}/smoke`, {
1081
+ method: 'POST',
1082
+ headers: { 'Content-Type': 'application/json' },
1083
+ body: JSON.stringify({ model: draft?.defaultModel }),
1084
+ })
1085
+ const data = await res.json().catch(() => ({}))
1086
+ if (!res.ok || !data.ok) throw new Error(data.error || `HTTP ${res.status}`)
1087
+ setSmokeResult(data)
1088
+ } catch (e) {
1089
+ setErr(String(e.message || e))
1090
+ } finally {
1091
+ setBusy('')
1092
+ }
1093
+ }
1094
+
1095
+ async function handleFastLogin() {
1096
+ setBusy('fast-login')
1097
+ setErr('')
1098
+ try {
1099
+ const res = await fetch(`${ROUTE_PREFIX}/auth/begin`, { method: 'POST' })
1100
+ const data = await res.json().catch(() => ({}))
1101
+ if (!res.ok || !data.ok) throw new Error(data.error || `HTTP ${res.status}`)
1102
+ if (data.authUrl) {
1103
+ window.open(data.authUrl, '_blank')
1104
+ }
1105
+ } catch (e) {
1106
+ setErr(String(e.message || e))
1107
+ } finally {
1108
+ setBusy('')
1109
+ }
1110
+ }
1111
+
1112
+ async function handlePinAccount(accountEnv) {
1113
+ setBusy('pin-account')
1114
+ setErr('')
1115
+ try {
1116
+ const res = await fetch(`${ROUTE_PREFIX}/accounts/active`, {
1117
+ method: 'POST',
1118
+ headers: { 'Content-Type': 'application/json' },
1119
+ body: JSON.stringify({ account: accountEnv }),
1120
+ })
1121
+ const data = await res.json().catch(() => ({}))
1122
+ if (!res.ok || !data.ok) throw new Error(data.error || `HTTP ${res.status}`)
1123
+ await load()
1124
+ } catch (e) {
1125
+ setErr(String(e.message || e))
1126
+ } finally {
1127
+ setBusy('')
1128
+ }
1129
+ }
1341
1130
 
1131
+ const debounceTimerRef = typeof React.useRef === 'function' ? React.useRef(null) : { current: null }
1132
+ function debounceSaveDisabledModels(nextDisabled) {
1133
+ if (debounceTimerRef.current) clearTimeout(debounceTimerRef.current)
1134
+ debounceTimerRef.current = setTimeout(async () => {
1135
+ try {
1136
+ await fetch(`${ROUTE_PREFIX}/models/toggle`, {
1137
+ method: 'POST',
1138
+ headers: { 'Content-Type': 'application/json' },
1139
+ body: JSON.stringify({ disabledModels: nextDisabled }),
1140
+ })
1141
+ } catch (err) {
1142
+ console.warn('[dsh-clinebot] Failed saving disabled models:', err)
1143
+ }
1144
+ }, 400)
1145
+ }
1146
+
1147
+ function handleToggleModel(id) {
1148
+ const curr = draft?.disabledModels || []
1149
+ const isCurrentlyDisabled = curr.includes(id)
1150
+ const next = isCurrentlyDisabled ? curr.filter((x) => x !== id) : [...curr, id]
1151
+ const all = status?.availableModels || []
1152
+ const enabledIds = all.map((m) => m.id).filter((mId) => !next.includes(mId))
1153
+ setDraft({ ...draft, disabledModels: next, enabledModels: enabledIds })
1154
+ debounceSaveDisabledModels(next)
1155
+ }
1156
+
1157
+ function handleSetModelsFilter(type) {
1158
+ const all = status?.availableModels || []
1159
+ let allowed = new Set()
1160
+ if (type === 'all') {
1161
+ allowed = new Set(all.map((m) => m.id))
1162
+ } else if (type === 'vision') {
1163
+ allowed = new Set(all.filter((m) => m.input?.includes('image') || m.input?.includes('vision')).map((m) => m.id))
1164
+ } else if (type === 'coding') {
1165
+ allowed = new Set(all.filter((m) => m.category === 'coding').map((m) => m.id))
1166
+ } else if (type === 'recommended') {
1167
+ allowed = new Set(all.filter((m) => m.recommended).map((m) => m.id))
1168
+ }
1169
+
1170
+ const nextDisabled = all.map((m) => m.id).filter((id) => !allowed.has(id))
1171
+ const nextEnabled = Array.from(allowed)
1172
+ setDraft({ ...draft, disabledModels: nextDisabled, enabledModels: nextEnabled })
1173
+ debounceSaveDisabledModels(nextDisabled)
1174
+ }
1175
+
1176
+ if (snapshotStatus === 'unavailable') {
1177
+ return React.createElement(
1178
+ 'div',
1179
+ { className: 'cb-page' },
1180
+ React.createElement('div', { className: 'cb-banner-warning' }, t('settings.unavailable'))
1181
+ )
1182
+ }
1183
+
1184
+ if (!status || !draft || snapshotStatus === 'loading') {
1185
+ if (err) {
1342
1186
  return React.createElement(
1343
- 'li',
1344
- { className: 'cb-section-card', style: { listStyle: 'none', marginBottom: '12px' } },
1187
+ 'div',
1188
+ { className: 'cb-page' },
1189
+ React.createElement('div', { className: 'cb-alert cb-alert-err' }, `${t('settings.loading')}: ${err}`),
1345
1190
  React.createElement(
1346
1191
  'button',
1347
1192
  {
1348
1193
  type: 'button',
1349
- style: {
1350
- background: 'none',
1351
- border: 'none',
1352
- cursor: 'pointer',
1353
- display: 'flex',
1354
- alignItems: 'center',
1355
- width: '100%',
1356
- padding: 0,
1357
- textAlign: 'left',
1194
+ className: 'cb-btn',
1195
+ style: { marginTop: '12px', alignSelf: 'flex-start' },
1196
+ onClick: () => {
1197
+ setErr('')
1198
+ load().catch((e) => setErr(String(e.message || e)))
1358
1199
  },
1359
- 'aria-expanded': open,
1360
- onClick: () => setOpen((v) => !v),
1361
1200
  },
1362
- React.createElement(
1363
- 'div',
1364
- { style: { flex: 1 } },
1365
- React.createElement('div', { style: { fontWeight: 600, fontSize: '15px' } }, t('title')),
1366
- React.createElement('div', { style: { fontSize: '13px', color: 'var(--dsw-alias-label-secondary)' } }, t('subtitle'))
1367
- ),
1368
- React.createElement('span', { className: 'cb-chevron' + (open ? ' cb-chevron-open' : '') },
1369
- React.createElement(Chevron)
1370
- )
1371
- ),
1372
- open
1373
- ? React.createElement(
1374
- 'div',
1375
- { style: { marginTop: '16px' } },
1376
- React.createElement(
1377
- ErrorBoundary,
1378
- null,
1379
- React.createElement(SettingsPage, { ...props, ctx: (props && props.ctx) || ctx })
1380
- )
1381
- )
1382
- : null
1201
+ t('settings.retry')
1202
+ )
1383
1203
  )
1384
1204
  }
1205
+ return React.createElement('div', { className: 'cb-page' }, t('settings.loading'))
1206
+ }
1207
+
1208
+ const healthOk = !!status.health?.ok
1209
+ const keyPresent = !!status.key?.present
1210
+ const isRegistered = !!status.isRegistered
1211
+ const modelsList = status.availableModels || []
1212
+ const disabledSet = new Set(draft.disabledModels || [])
1213
+ const enabledCount = modelsList.filter((m) => !disabledSet.has(m.id)).length
1214
+ const usage = status.usage
1215
+
1216
+ return React.createElement(
1217
+ 'div',
1218
+ { className: 'cb-page' },
1219
+
1220
+ // Page Header
1221
+ React.createElement(
1222
+ 'div',
1223
+ { className: 'cb-header' },
1224
+ React.createElement(
1225
+ 'div',
1226
+ { className: 'cb-page-title' },
1227
+ `🤖 ${t('header.title')}`,
1228
+ React.createElement(
1229
+ 'span',
1230
+ { className: `cb-badge ${healthOk ? 'cb-badge-ok' : 'cb-badge-bad'}` },
1231
+ healthOk ? t('badge.online', { latency: status.health?.latencyMs }) : t('badge.offline')
1232
+ ),
1233
+ React.createElement(
1234
+ 'span',
1235
+ { className: `cb-badge ${keyPresent ? 'cb-badge-ok' : 'cb-badge-warn'}` },
1236
+ keyPresent ? t('badge.key_ok', { source: status.key?.source }) : t('badge.key_missing')
1237
+ ),
1238
+ React.createElement(
1239
+ 'span',
1240
+ { className: `cb-badge ${isRegistered ? 'cb-badge-ok' : 'cb-badge-warn'}` },
1241
+ isRegistered ? t('badge.registered', { count: enabledCount }) : t('badge.not_registered')
1242
+ )
1243
+ ),
1244
+ React.createElement('div', { className: 'cb-page-sub' }, t('header.sub'))
1245
+ ),
1246
+
1247
+ // In-app Update Bar
1248
+ React.createElement(UpdateBanner, { updateState, handleTriggerUpdate, t }),
1249
+
1250
+ // Notifications
1251
+ err ? React.createElement('div', { className: 'cb-alert-bad' }, err) : null,
1252
+ msg ? React.createElement('div', { className: 'cb-alert-ok' }, msg) : null,
1253
+
1254
+ // Card 1: API Key and Credentials
1255
+ React.createElement(KeySection, {
1256
+ keyPresent,
1257
+ apiKeyInput,
1258
+ setApiKeyInput,
1259
+ showKey,
1260
+ setShowKey,
1261
+ busy,
1262
+ draft,
1263
+ handleSaveKey,
1264
+ handleFastLogin,
1265
+ t,
1266
+ }),
1267
+
1268
+ // Accounts Pool Card
1269
+ React.createElement(AccountsSection, { status, busy, handlePinAccount, t }),
1270
+
1271
+ // Quota Warning & Dashboard Card
1272
+ React.createElement(QuotaSection, { keyPresent, status, usage, busy, handleRefreshQuota, t }),
1273
+
1274
+ // Card 3: Model Picker Management
1275
+ React.createElement(ModelsSection, {
1276
+ modelsList,
1277
+ disabledSet,
1278
+ enabledCount,
1279
+ keyPresent,
1280
+ busy,
1281
+ handleSyncPlanModels,
1282
+ handleSetModelsFilter,
1283
+ handleToggleModel,
1284
+ t,
1285
+ }),
1286
+
1287
+ // Card 4: Session Metrics & Usage Tracking
1288
+ React.createElement(StatsSection, { status, t }),
1289
+
1290
+ // Card 5: Diagnostics & Sync with DSH
1291
+ React.createElement(DiagSection, {
1292
+ keyPresent,
1293
+ isRegistered,
1294
+ busy,
1295
+ smokeResult,
1296
+ handleSmoke,
1297
+ handleUnregister,
1298
+ handleRegister,
1299
+ t,
1300
+ })
1301
+ )
1302
+ }
1303
+
1304
+ // --- src/client/plugin-card.js ---
1305
+ function PluginCard(props) {
1306
+ const [open, setOpen] = React.useState(false)
1307
+ const t = props?.t || makeT(en, en)
1308
+ React.useEffect(() => {
1309
+ ensureCss()
1310
+ }, [])
1311
+
1312
+ return React.createElement(
1313
+ 'li',
1314
+ { className: 'cb-section-card', style: { listStyle: 'none', marginBottom: '12px' } },
1315
+ React.createElement(
1316
+ 'button',
1317
+ {
1318
+ type: 'button',
1319
+ style: {
1320
+ background: 'none',
1321
+ border: 'none',
1322
+ cursor: 'pointer',
1323
+ display: 'flex',
1324
+ alignItems: 'center',
1325
+ width: '100%',
1326
+ padding: 0,
1327
+ textAlign: 'left',
1328
+ },
1329
+ 'aria-expanded': open,
1330
+ onClick: () => setOpen((v) => !v),
1331
+ },
1332
+ React.createElement(
1333
+ 'div',
1334
+ { style: { flex: 1 } },
1335
+ React.createElement('div', { style: { fontWeight: 600, fontSize: '15px' } }, t('title')),
1336
+ React.createElement('div', { style: { fontSize: '13px', color: 'var(--dsw-alias-label-secondary)' } }, t('subtitle'))
1337
+ ),
1338
+ React.createElement('span', { className: 'cb-chevron' + (open ? ' cb-chevron-open' : '') },
1339
+ React.createElement(Chevron)
1340
+ )
1341
+ ),
1342
+ open
1343
+ ? React.createElement(
1344
+ 'div',
1345
+ { style: { marginTop: '16px' } },
1346
+ React.createElement(
1347
+ ErrorBoundary,
1348
+ null,
1349
+ React.createElement(SettingsPage, { ...props, ctx: (props && props.ctx) || ctx })
1350
+ )
1351
+ )
1352
+ : null
1353
+ )
1354
+ }
1385
1355
 
1386
- function refreshMirrorUntilVisible(ctx) {
1387
- const visible = () => {
1388
- try {
1389
- const s = (ctx?.get && ctx.get('lanSettings')) || ctx?.settingsScope
1390
- const view = s?.describe?.()?.getSnapshot?.()?.view
1391
- return !!view && Array.isArray(view.namespaces) && view.namespaces.some((row) => row.ns === NS)
1392
- } catch (_) {
1393
- return false
1394
- }
1395
- }
1396
- if (visible()) return () => {}
1397
- let tries = 0
1398
- const timer = setInterval(() => {
1399
- if (visible() || tries >= 15) { clearInterval(timer); return }
1400
- tries += 1
1401
- try {
1402
- const s = (ctx?.get && ctx.get('lanSettings')) || ctx?.settingsScope
1403
- s?.describe?.()?.load?.()
1404
- } catch (e) {
1405
- console.debug?.('[dsh-clinebot] Polling settings mirror:', e)
1406
- }
1407
- }, 1000)
1408
- return () => clearInterval(timer)
1356
+ // --- src/client/entry.js ---
1357
+ function refreshMirrorUntilVisible(ctx) {
1358
+ const visible = () => {
1359
+ try {
1360
+ const s = (ctx?.get && ctx.get('lanSettings')) || ctx?.settingsScope
1361
+ const view = s?.describe?.()?.getSnapshot?.()?.view
1362
+ return !!view && Array.isArray(view.namespaces) && view.namespaces.some((row) => row.ns === NS)
1363
+ } catch (_) {
1364
+ return false
1409
1365
  }
1366
+ }
1367
+ if (visible()) return () => {}
1368
+ let tries = 0
1369
+ const timer = setInterval(() => {
1370
+ if (visible() || tries >= 15) { clearInterval(timer); return }
1371
+ tries += 1
1372
+ try {
1373
+ const s = (ctx?.get && ctx.get('lanSettings')) || ctx?.settingsScope
1374
+ s?.describe?.()?.load?.()
1375
+ } catch (e) {
1376
+ console.debug?.('[dsh-clinebot] Polling settings mirror:', e)
1377
+ }
1378
+ }, 1000)
1379
+ return () => clearInterval(timer)
1380
+ }
1410
1381
 
1411
- function apply(ctx) {
1412
- const addLocale = (locale, dictionary) => {
1413
- try {
1414
- return ctx.locale.register(NS, locale, dictionary)
1415
- } catch (_) {
1416
- return () => {}
1417
- }
1418
- }
1419
- if (ctx.locale && ctx.locale.register) {
1420
- if (typeof ctx.effect === 'function') {
1421
- ctx.effect(() => {
1422
- const undo = [addLocale('en', en), addLocale('zh', zh)]
1423
- return () => { for (const off of undo) off() }
1424
- }, 'dsh-clinebot: dictionaries')
1425
- } else {
1426
- addLocale('en', en)
1427
- addLocale('zh', zh)
1428
- }
1429
- }
1430
-
1431
- if (typeof ctx.effect === 'function') {
1432
- ctx.effect(
1433
- () => refreshMirrorUntilVisible(ctx),
1434
- 'dsh-clinebot: re-read the settings mirror until our namespace appears',
1435
- )
1436
- }
1437
-
1438
- function registerSlotWhenReady(slotName, registerFn) {
1439
- if (!ctx.slots) return
1440
- if (typeof ctx.slots.inject === 'function') {
1441
- try {
1442
- ctx.slots.inject(slotName, () => {
1443
- try {
1444
- return registerFn()
1445
- } catch (err) {
1446
- console.warn('[dsh-clinebot] Error registering slot ' + slotName + ':', err)
1447
- }
1448
- })
1449
- return
1450
- } catch (err) {
1451
- console.warn('[dsh-clinebot] Failed to inject slot ' + slotName + ':', err)
1452
- }
1453
- }
1454
- if (typeof ctx.slots.register === 'function') {
1382
+ function apply(ctx) {
1383
+ const addLocale = (locale, dictionary) => {
1384
+ try {
1385
+ return ctx.locale.register(NS, locale, dictionary)
1386
+ } catch (_) {
1387
+ return () => {}
1388
+ }
1389
+ }
1390
+ if (ctx.locale && ctx.locale.register) {
1391
+ if (typeof ctx.effect === 'function') {
1392
+ ctx.effect(() => {
1393
+ const undo = [addLocale('en', en), addLocale('zh', zh)]
1394
+ return () => { for (const off of undo) off() }
1395
+ }, 'dsh-clinebot: dictionaries')
1396
+ } else {
1397
+ addLocale('en', en)
1398
+ addLocale('zh', zh)
1399
+ }
1400
+ }
1401
+
1402
+ if (typeof ctx.effect === 'function') {
1403
+ ctx.effect(
1404
+ () => refreshMirrorUntilVisible(ctx),
1405
+ 'dsh-clinebot: re-read the settings mirror until our namespace appears',
1406
+ )
1407
+ }
1408
+
1409
+ function registerSlotWhenReady(slotName, registerFn) {
1410
+ if (!ctx.slots) return
1411
+ if (typeof ctx.slots.inject === 'function') {
1412
+ try {
1413
+ ctx.slots.inject(slotName, () => {
1455
1414
  try {
1456
- registerFn()
1415
+ return registerFn()
1457
1416
  } catch (err) {
1458
- console.warn('[dsh-clinebot] Failed direct registration for ' + slotName + ':', err)
1417
+ console.warn('[dsh-clinebot] Error registering slot ' + slotName + ':', err)
1459
1418
  }
1460
- }
1419
+ })
1420
+ return
1421
+ } catch (err) {
1422
+ console.warn('[dsh-clinebot] Failed to inject slot ' + slotName + ':', err)
1423
+ }
1424
+ }
1425
+ if (typeof ctx.slots.register === 'function') {
1426
+ try {
1427
+ registerFn()
1428
+ } catch (err) {
1429
+ console.warn('[dsh-clinebot] Failed direct registration for ' + slotName + ':', err)
1461
1430
  }
1462
-
1463
- registerSlotWhenReady('settings.plugin.item', () =>
1464
- ctx.slots.register(
1465
- {
1466
- name: 'settings.plugin.item',
1467
- key: NS,
1468
- locale: NS,
1469
- inject: () => ({ ctx }),
1470
- },
1471
- (props) => React.createElement(ErrorBoundary, null, React.createElement(PluginCard, { ...props, ctx: (props && props.ctx) || ctx }))
1472
- )
1473
- )
1474
1431
  }
1432
+ }
1433
+
1434
+ registerSlotWhenReady('settings.plugin.item', () =>
1435
+ ctx.slots.register(
1436
+ {
1437
+ name: 'settings.plugin.item',
1438
+ key: NS,
1439
+ locale: NS,
1440
+ inject: () => ({ ctx }),
1441
+ },
1442
+ (props) => React.createElement(ErrorBoundary, null, React.createElement(PluginCard, { ...props, ctx: (props && props.ctx) || ctx }))
1443
+ )
1444
+ )
1445
+ }
1446
+
1447
+ module.exports = { apply, inject: ['slots', 'locale', 'settingsScope'] }
1475
1448
 
1476
- module.exports = { apply, inject: ['slots', 'locale', 'settingsScope'] }
1477
1449
  return module.exports
1478
1450
  },
1479
1451
  })