@goodandready/dsh-clinebot 0.3.25 → 0.4.1

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
@@ -14,21 +14,25 @@ const NS = 'dsh-clinebot'
14
14
  const ROW_ID = 'dsh-clinebot'
15
15
  const ROW_CONFIG_KEY = '@goodandready/dsh-clinebot#' + ROW_ID
16
16
  const ROUTE_PREFIX = '/dsh-clinebot'
17
- const SNAPSHOT_LOADING = Object.freeze({ status: 'loading', view: null })
18
17
  // Stable (frozen) fallback reference: useSyncExternalStore compares snapshots by
19
18
  // identity (Object.is), so returning a fresh object literal causes an infinite render loop
20
19
  // (React error #185 "Maximum update depth exceeded"). Keep this module-level.
21
20
  const SNAPSHOT_READY = Object.freeze({ status: 'ready', view: null })
22
21
 
22
+ function readConfigForms(ctx) {
23
+ if (!ctx || typeof ctx.get !== 'function') return undefined
24
+ try { return ctx.get('configForms') || undefined } catch { return undefined }
25
+ }
26
+
23
27
  // --- src/client/locales.js ---
24
28
  const en = {
25
29
  title: 'ClineBot',
26
- subtitle: 'ClinePass subscription ($9.99/mo) with curated & custom models.',
30
+ subtitle: 'ClinePass subscription with dynamic plan models.',
27
31
  'settings.loading': 'Loading ClineBot settings…',
28
32
  'settings.retry': 'Retry',
29
33
  'settings.unavailable': 'ClineBot settings unavailable (host namespace is not ready).',
30
34
  'header.title': 'ClineBot (ClinePass) Provider',
31
- 'header.sub': 'Connect models under ClinePass subscription ($9.99/mo). OpenAI API compatible, automated rolling limit tracking, and instant auto-registration in DSH.',
35
+ 'header.sub': 'Connect models under your active ClinePass subscription. OpenAI API compatible, automated rolling limit tracking, and instant auto-registration in DSH.',
32
36
  'badge.online': 'Host online ({latency} ms)',
33
37
  'badge.offline': 'Host unreachable',
34
38
  'badge.key_ok': 'Key ✓ ({source})',
@@ -43,8 +47,11 @@ const en = {
43
47
  'key.hide': 'Hide',
44
48
  'key.save': 'Save Key',
45
49
  'key.saving': 'Saving…',
46
- 'key.login_fast': '🚀 Quick Web Login',
47
- 'key.login_waiting': 'Waiting for authorization in browser…',
50
+ 'key.login_fast': '🔑 Open API Keys ↗',
51
+ 'key.login_waiting': 'Paste key below for instant verification.',
52
+ 'key.verifying': 'Validating key with Cline…',
53
+ 'key.verified': '✓ Valid key for {email} ({plan})',
54
+ 'key.invalid': '✗ Key validation failed: {error}',
48
55
  'key.env_label': 'Credential environment name: ',
49
56
  'key.get_key': 'Get key in app.cline.bot console ↗',
50
57
  'key.saved_msg': 'Key saved to DSH Credentials. Validation: {status}',
@@ -57,6 +64,14 @@ const en = {
57
64
  'accounts.add_btn': '+ Add Account',
58
65
  'accounts.label_placeholder': 'Account Label (e.g. Work)',
59
66
  'accounts.env_placeholder': 'CLINEBOT_API_KEY_2',
67
+ 'accounts.delete_btn': 'Delete',
68
+ 'accounts.delete_confirm': 'Are you sure you want to delete account "{label}"?',
69
+ 'accounts.delete_secret': 'Delete secret from credentials',
70
+ 'accounts.save_btn': 'Save Account',
71
+ 'accounts.cancel_btn': 'Cancel',
72
+ 'accounts.key_label': 'API Key',
73
+ 'accounts.quota_used': '{pct}% used',
74
+ 'accounts.last_failover': 'Last failover: {from} → {to} ({reason})',
60
75
  'quota.title': '📊 ClinePass Quota & Rate Limits',
61
76
  'quota.desc': 'Official rolling window request limits from ClinePass',
62
77
  'quota.account': 'Account: {email} · Plan: {plan}',
@@ -74,6 +89,15 @@ const en = {
74
89
  'models.sync': '🔄 Check Plan Models',
75
90
  'models.syncing': 'Checking…',
76
91
  'models.synced_msg': 'Synchronized subscription models: {total} ({discovered} discovered from ClinePass)',
92
+ 'models.verified': 'Verified with plan',
93
+ 'models.unverified': 'Not synced (fallback catalog)',
94
+ 'models.synced_at': 'Synced: {date}',
95
+ 'models.search_placeholder': 'Search models by name, ID or description…',
96
+ 'models.filter_all': 'All',
97
+ 'models.filter_vision': 'Vision',
98
+ 'models.filter_coding': 'Coding',
99
+ 'models.filter_recommended': 'Recommended',
100
+ 'models.filter_disabled': 'Disabled',
77
101
  'models.all': 'All',
78
102
  'models.vision': 'Vision Only',
79
103
  'models.coding': 'Coding Only',
@@ -113,12 +137,12 @@ const en = {
113
137
 
114
138
  const zh = {
115
139
  title: 'ClineBot',
116
- subtitle: 'ClinePass 订阅服务 ($9.99/月),包含精选与自定义模型。',
140
+ subtitle: 'ClinePass 订阅服务,包含动态套餐模型。',
117
141
  'settings.loading': '正在加载 ClineBot 设置…',
118
142
  'settings.retry': '重试',
119
143
  'settings.unavailable': 'ClineBot 设置不可用(宿主命名空间尚未就绪)。',
120
144
  'header.title': 'ClineBot (ClinePass) 模型服务商',
121
- 'header.sub': '通过 ClinePass 订阅 ($9.99/月) 连接主流推理与编程模型。完全兼容 OpenAI 接口,自动追踪滚动限额并在 DSH 中即时生效。',
145
+ 'header.sub': '通过 ClinePass 订阅连接主流推理与编程模型。完全兼容 OpenAI 接口,自动追踪滚动限额并在 DSH 中即时生效。',
122
146
  'badge.online': '服务在线 ({latency} ms)',
123
147
  'badge.offline': '主机无法连接',
124
148
  'badge.key_ok': '密钥正常 ({source})',
@@ -133,8 +157,11 @@ const en = {
133
157
  'key.hide': '隐藏',
134
158
  'key.save': '保存密钥',
135
159
  'key.saving': '正在保存…',
136
- 'key.login_fast': '🚀 浏览器快速登录',
137
- 'key.login_waiting': '等待在浏览器中完成授权…',
160
+ 'key.login_fast': '🔑 打开 API 密钥页 ↗',
161
+ 'key.login_waiting': '请在下方粘贴密钥,系统将自动进行有效性校验。',
162
+ 'key.verifying': '正在连接 Cline 验证密钥…',
163
+ 'key.verified': '✓ 密钥有效:{email} ({plan})',
164
+ 'key.invalid': '✗ 密钥验证未通过:{error}',
138
165
  'key.env_label': '凭据环境变量名称:',
139
166
  'key.get_key': '前往 app.cline.bot 控制台获取密钥 ↗',
140
167
  'key.saved_msg': '密钥已成功保存至 DSH 凭据服务。验证状态:{status}',
@@ -147,6 +174,14 @@ const en = {
147
174
  'accounts.add_btn': '+ 添加账号',
148
175
  'accounts.label_placeholder': '账号标签(例如:工作账号)',
149
176
  'accounts.env_placeholder': 'CLINEBOT_API_KEY_2',
177
+ 'accounts.delete_btn': '删除',
178
+ 'accounts.delete_confirm': '确定要删除账号“{label}”吗?',
179
+ 'accounts.delete_secret': '同时删除凭据密钥',
180
+ 'accounts.save_btn': '保存账号',
181
+ 'accounts.cancel_btn': '取消',
182
+ 'accounts.key_label': 'API 密钥',
183
+ 'accounts.quota_used': '已用 {pct}%',
184
+ 'accounts.last_failover': '最近故障转移:{from} → {to} ({reason})',
150
185
  'quota.title': '📊 ClinePass 用量配额与限额监控',
151
186
  'quota.desc': '来自 ClinePass 官方的实时滚动窗口用量限额',
152
187
  'quota.account': '账号:{email} · 套餐:{plan}',
@@ -164,6 +199,15 @@ const en = {
164
199
  'models.sync': '🔄 同步订阅模型',
165
200
  'models.syncing': '正在同步…',
166
201
  'models.synced_msg': '已成功同步订阅模型:共 {total} 个(发现 {discovered} 个新模型)',
202
+ 'models.verified': '已与套餐同步',
203
+ 'models.unverified': '未同步(备用目录)',
204
+ 'models.synced_at': '同步时间:{date}',
205
+ 'models.search_placeholder': '按名称、ID 或描述搜索模型…',
206
+ 'models.filter_all': '全部',
207
+ 'models.filter_vision': '仅视觉',
208
+ 'models.filter_coding': '仅编程',
209
+ 'models.filter_recommended': '推荐',
210
+ 'models.filter_disabled': '已禁用',
167
211
  'models.all': '全部模型',
168
212
  'models.vision': '仅视觉 (Vision)',
169
213
  'models.coding': '仅编程 (Coding)',
@@ -275,11 +319,14 @@ function ensureCss() {
275
319
  .cb-btn-danger:hover:not(:disabled){background:color-mix(in srgb, var(--dsw-alias-state-error-primary) 12%, transparent) !important;border-color:color-mix(in srgb, var(--dsw-alias-state-error-primary) 50%, transparent)}
276
320
  .cb-btn-disabled{opacity:0.5;cursor:not-allowed}
277
321
 
278
- .cb-bar-container{display:flex;flex-direction:column;gap:6px;padding:12px 14px;border:1px solid var(--dsw-alias-border-l2);border-radius:8px;background:var(--dsw-alias-bg-layer-2)}
279
- .cb-bar-head{display:flex;justify-content:space-between;font-size:13px;font-weight:500;color:var(--dsw-alias-label-primary)}
280
- .cb-bar-track{width:100%;height:10px;border-radius:999px;background:var(--dsw-alias-bg-layer-1);overflow:hidden;border:1px solid var(--dsw-alias-border-l2)}
281
- .cb-bar-fill{height:100%;border-radius:999px;transition:width .3s}
282
- .cb-bar-meta{display:flex;justify-content:space-between;font-size:12px;color:var(--dsw-alias-label-secondary)}
322
+ .cb-progress-card, .cb-bar-container{display:flex;flex-direction:column;gap:8px;padding:12px 14px;border:1px solid var(--dsw-alias-border-l2);border-radius:8px;background:var(--dsw-alias-bg-layer-2)}
323
+ .cb-progress-head, .cb-bar-head{display:flex;justify-content:space-between;align-items:baseline;gap:16px;font-size:13px;font-weight:500;color:var(--dsw-alias-label-primary);width:100%}
324
+ .cb-progress-label{font-weight:500;white-space:nowrap}
325
+ .cb-progress-meta, .cb-bar-meta{font-size:12px;color:var(--dsw-alias-label-secondary);white-space:nowrap;margin-left:auto}
326
+ .cb-progress-track, .cb-bar-track{width:100%;height:8px;border-radius:999px;background:var(--dsw-alias-bg-layer-1);overflow:hidden;border:1px solid var(--dsw-alias-border-l2)}
327
+ .cb-progress-bar-fill, .cb-bar-fill{height:100%;border-radius:999px;transition:width .3s;background:var(--dsw-alias-state-brand-primary, var(--dsw-alias-state-success-primary))}
328
+ .cb-progress-bar-warn{height:100%;border-radius:999px;transition:width .3s;background:var(--dsw-alias-state-warning-primary)}
329
+ .cb-progress-bar-bad{height:100%;border-radius:999px;transition:width .3s;background:var(--dsw-alias-state-error-primary)}
283
330
 
284
331
  .cb-table{width:100%;border-collapse:collapse;margin-top:8px}
285
332
  .cb-table th{text-align:left;font-size:12px;color:var(--dsw-alias-label-secondary);padding:8px 10px;border-bottom:1px solid var(--dsw-alias-border-l2);font-weight:600}
@@ -365,24 +412,62 @@ function ProgressBar({ label, percentUsed, remainingPercent, resetsAt, t }) {
365
412
 
366
413
  return React.createElement(
367
414
  'div',
368
- { className: 'cb-progress-card' },
415
+ {
416
+ className: 'cb-progress-card cb-bar-container',
417
+ style: {
418
+ display: 'flex',
419
+ flexDirection: 'column',
420
+ gap: '8px',
421
+ padding: '12px 14px',
422
+ borderRadius: '8px',
423
+ border: '1px solid var(--dsw-alias-border-l2)',
424
+ background: 'var(--dsw-alias-bg-layer-2)',
425
+ },
426
+ },
369
427
  React.createElement(
370
428
  'div',
371
- { className: 'cb-progress-head' },
372
- React.createElement('span', { className: 'cb-progress-label' }, label),
429
+ {
430
+ className: 'cb-progress-head cb-bar-head',
431
+ style: {
432
+ display: 'flex',
433
+ justifyContent: 'space-between',
434
+ alignItems: 'baseline',
435
+ gap: '16px',
436
+ width: '100%',
437
+ },
438
+ },
439
+ React.createElement('span', { className: 'cb-progress-label', style: { fontWeight: 500, whiteSpace: 'nowrap' } }, label),
373
440
  React.createElement(
374
441
  'span',
375
- { className: 'cb-progress-meta' },
442
+ {
443
+ className: 'cb-progress-meta cb-bar-meta',
444
+ style: {
445
+ fontSize: '12px',
446
+ color: 'var(--dsw-alias-label-secondary)',
447
+ whiteSpace: 'nowrap',
448
+ marginLeft: 'auto',
449
+ },
450
+ },
376
451
  t('quota.used', { pct }),
377
452
  resetLabel ? ` · ${t('quota.reset_at', { time: resetLabel })}` : ''
378
453
  )
379
454
  ),
380
455
  React.createElement(
381
456
  'div',
382
- { className: 'cb-progress-track' },
457
+ {
458
+ className: 'cb-progress-track cb-bar-track',
459
+ style: {
460
+ width: '100%',
461
+ height: '8px',
462
+ borderRadius: '999px',
463
+ background: 'var(--dsw-alias-bg-layer-1)',
464
+ overflow: 'hidden',
465
+ border: '1px solid var(--dsw-alias-border-l2)',
466
+ },
467
+ },
383
468
  React.createElement('div', {
384
469
  className: barClass,
385
- style: { width: `${pct}%` },
470
+ style: { width: `${pct}%`, height: '100%', borderRadius: '999px' },
386
471
  })
387
472
  )
388
473
  )
@@ -445,7 +530,7 @@ function UpdateBanner({ updateState, handleTriggerUpdate, t }) {
445
530
  }
446
531
 
447
532
  // --- src/client/components/key-section.js ---
448
- function KeySection({ keyPresent, apiKeyInput, setApiKeyInput, showKey, setShowKey, busy, draft, handleSaveKey, handleFastLogin, t }) {
533
+ function KeySection({ keyPresent, apiKeyInput, setApiKeyInput, showKey, setShowKey, busy, draft, handleSaveKey, handleFastLogin, verifyStatus, t }) {
449
534
  return React.createElement(
450
535
  'div',
451
536
  { className: 'cb-section-card' },
@@ -488,19 +573,28 @@ function KeySection({ keyPresent, apiKeyInput, setApiKeyInput, showKey, setShowK
488
573
  disabled: !!busy,
489
574
  onClick: handleFastLogin,
490
575
  },
491
- busy === 'fast-login' ? '…' : t('key.login_fast')
576
+ t('key.login_fast')
492
577
  )
493
578
  ),
579
+ verifyStatus && verifyStatus.state === 'verifying'
580
+ ? React.createElement('div', { style: { marginTop: '8px', fontSize: '13px', color: 'var(--dsw-alias-label-secondary)' } }, t('key.verifying'))
581
+ : null,
582
+ verifyStatus && verifyStatus.state === 'valid'
583
+ ? React.createElement('div', { className: 'cb-alert-ok', style: { marginTop: '8px', fontSize: '13px' } }, t('key.verified', { email: verifyStatus.email, plan: verifyStatus.plan }))
584
+ : null,
585
+ verifyStatus && verifyStatus.state === 'invalid'
586
+ ? React.createElement('div', { className: 'cb-alert-bad', style: { marginTop: '8px', fontSize: '13px' } }, t('key.invalid', { error: verifyStatus.error }))
587
+ : null,
494
588
  React.createElement(
495
589
  'div',
496
- { className: 'cb-row', style: { fontSize: '13px', color: 'var(--dsw-alias-label-secondary)' } },
590
+ { className: 'cb-row', style: { fontSize: '13px', marginTop: '12px', color: 'var(--dsw-alias-label-secondary)' } },
497
591
  React.createElement('span', null, t('key.env_label')),
498
592
  React.createElement('code', null, draft.apiKeyEnv || 'CLINEBOT_API_KEY'),
499
593
  React.createElement('span', null, ' · '),
500
594
  React.createElement(
501
595
  'a',
502
596
  {
503
- href: 'https://app.cline.bot',
597
+ href: 'https://app.cline.bot/settings/api-keys',
504
598
  target: '_blank',
505
599
  rel: 'noreferrer',
506
600
  style: { color: 'var(--dsw-alias-state-brand-primary)' },
@@ -512,13 +606,162 @@ function KeySection({ keyPresent, apiKeyInput, setApiKeyInput, showKey, setShowK
512
606
  }
513
607
 
514
608
  // --- src/client/components/accounts-section.js ---
515
- function AccountsSection({ status, busy, handlePinAccount, t }) {
609
+ function AccountsSection({ status, busy, handlePinAccount, handleAddAccount, handleDeleteAccount, t }) {
516
610
  if (!status.accounts || !status.accounts.length) return null
611
+
612
+ const [showAdd, setShowAdd] = React.useState(false)
613
+ const [newLabel, setNewLabel] = React.useState('')
614
+ const [newEnv, setNewEnv] = React.useState('')
615
+ const [newKey, setNewKey] = React.useState('')
616
+ const [showKey, setShowKey] = React.useState(false)
617
+ const [deleteSecret, setDeleteSecret] = React.useState(true)
618
+
619
+ function handleOpenAdd() {
620
+ let max = 1
621
+ for (const a of (status.accounts || [])) {
622
+ const m = String(a.apiKeyEnv || '').match(/^CLINEBOT_API_KEY_(\d+)$/)
623
+ if (m) {
624
+ const n = parseInt(m[1], 10)
625
+ if (n >= max) max = n + 1
626
+ }
627
+ }
628
+ setNewEnv(`CLINEBOT_API_KEY_${max}`)
629
+ setShowAdd(true)
630
+ }
631
+
632
+ async function onSubmitAdd(e) {
633
+ if (e && e.preventDefault) e.preventDefault()
634
+ if (!newKey.trim()) return
635
+ if (typeof handleAddAccount === 'function') {
636
+ await handleAddAccount({
637
+ label: newLabel.trim() || newEnv.trim(),
638
+ apiKeyEnv: newEnv.trim(),
639
+ apiKey: newKey.trim(),
640
+ })
641
+ }
642
+ setNewLabel('')
643
+ setNewEnv('')
644
+ setNewKey('')
645
+ setShowAdd(false)
646
+ }
647
+
648
+ async function onDeleteAccount(envName) {
649
+ if (typeof window !== 'undefined' && typeof window.confirm === 'function') {
650
+ if (!window.confirm(t('accounts.delete_confirm', { label: envName }))) return
651
+ }
652
+ if (typeof handleDeleteAccount === 'function') {
653
+ await handleDeleteAccount(envName, deleteSecret)
654
+ }
655
+ }
656
+
517
657
  return React.createElement(
518
658
  'div',
519
659
  { className: 'cb-section-card' },
520
- React.createElement('div', { className: 'cb-section-title' }, t('accounts.title')),
660
+ React.createElement(
661
+ 'div',
662
+ { className: 'cb-section-title', style: { display: 'flex', justifyContent: 'space-between', alignItems: 'center' } },
663
+ React.createElement('span', null, t('accounts.title')),
664
+ !showAdd
665
+ ? React.createElement(
666
+ 'button',
667
+ {
668
+ type: 'button',
669
+ className: 'cb-btn',
670
+ style: { fontSize: '11px', padding: '4px 10px' },
671
+ onClick: handleOpenAdd,
672
+ disabled: !!busy,
673
+ },
674
+ t('accounts.add_btn')
675
+ )
676
+ : null
677
+ ),
521
678
  React.createElement('div', { className: 'cb-section-desc' }, t('accounts.desc')),
679
+
680
+ showAdd
681
+ ? React.createElement(
682
+ 'form',
683
+ {
684
+ onSubmit: onSubmitAdd,
685
+ style: {
686
+ background: 'var(--dsw-alias-bg-hover, rgba(0,0,0,0.04))',
687
+ border: '1px solid var(--dsw-alias-border, rgba(0,0,0,0.12))',
688
+ borderRadius: '8px',
689
+ padding: '12px',
690
+ marginBottom: '14px',
691
+ display: 'flex',
692
+ flexDirection: 'column',
693
+ gap: '8px',
694
+ },
695
+ },
696
+ React.createElement(
697
+ 'div',
698
+ { style: { display: 'flex', gap: '8px', flexWrap: 'wrap' } },
699
+ React.createElement('input', {
700
+ type: 'text',
701
+ className: 'cb-input',
702
+ style: { flex: '1 1 180px' },
703
+ placeholder: t('accounts.label_placeholder'),
704
+ value: newLabel,
705
+ onChange: (e) => setNewLabel(e.target.value),
706
+ }),
707
+ React.createElement('input', {
708
+ type: 'text',
709
+ className: 'cb-input',
710
+ style: { flex: '1 1 180px' },
711
+ placeholder: t('accounts.env_placeholder'),
712
+ value: newEnv,
713
+ onChange: (e) => setNewEnv(e.target.value),
714
+ })
715
+ ),
716
+ React.createElement(
717
+ 'div',
718
+ { style: { display: 'flex', gap: '8px', alignItems: 'center' } },
719
+ React.createElement('input', {
720
+ type: showKey ? 'text' : 'password',
721
+ className: 'cb-input',
722
+ style: { flex: '1' },
723
+ placeholder: t('key.placeholder_empty'),
724
+ value: newKey,
725
+ onChange: (e) => setNewKey(e.target.value),
726
+ }),
727
+ React.createElement(
728
+ 'button',
729
+ {
730
+ type: 'button',
731
+ className: 'cb-btn',
732
+ style: { padding: '4px 8px', fontSize: '11px' },
733
+ onClick: () => setShowKey(!showKey),
734
+ },
735
+ showKey ? t('key.hide') : t('key.show')
736
+ )
737
+ ),
738
+ React.createElement(
739
+ 'div',
740
+ { style: { display: 'flex', gap: '8px', justifyContent: 'flex-end', marginTop: '4px' } },
741
+ React.createElement(
742
+ 'button',
743
+ {
744
+ type: 'button',
745
+ className: 'cb-btn',
746
+ style: { padding: '4px 10px', fontSize: '12px' },
747
+ onClick: () => setShowAdd(false),
748
+ },
749
+ t('accounts.cancel_btn')
750
+ ),
751
+ React.createElement(
752
+ 'button',
753
+ {
754
+ type: 'submit',
755
+ className: 'cb-btn cb-btn-primary',
756
+ style: { padding: '4px 12px', fontSize: '12px' },
757
+ disabled: !newKey.trim() || !!busy,
758
+ },
759
+ busy === 'add-account' ? t('key.saving') : t('accounts.save_btn')
760
+ )
761
+ )
762
+ )
763
+ : null,
764
+
522
765
  React.createElement(
523
766
  'table',
524
767
  { className: 'cb-table' },
@@ -541,9 +784,10 @@ function AccountsSection({ status, busy, handlePinAccount, t }) {
541
784
  const envName = typeof acc.apiKeyEnv === 'string' ? acc.apiKeyEnv : ''
542
785
  const label = typeof acc.label === 'string' ? acc.label : ''
543
786
  const isActive = status.activeAccount === envName || (!status.activeAccount && acc.id === 'default')
787
+ const isPrimary = acc.id === 'default' || envName === status.config?.apiKeyEnv
544
788
  return React.createElement(
545
789
  'tr',
546
- { key: acc.id },
790
+ { key: acc.id || envName },
547
791
  React.createElement('td', null, React.createElement('strong', null, label)),
548
792
  React.createElement('td', null, React.createElement('code', null, envName)),
549
793
  React.createElement(
@@ -554,6 +798,12 @@ function AccountsSection({ status, busy, handlePinAccount, t }) {
554
798
  : React.createElement('span', { className: 'cb-badge cb-badge-warn' }, 'Missing Key'),
555
799
  isActive
556
800
  ? React.createElement('span', { className: 'cb-badge cb-badge-ok', style: { marginLeft: '6px' } }, t('accounts.active_badge'))
801
+ : null,
802
+ acc.isPinned && !isActive
803
+ ? React.createElement('span', { className: 'cb-badge', style: { marginLeft: '6px', opacity: 0.85 } }, t('accounts.pinned_badge'))
804
+ : null,
805
+ typeof acc.percentUsed === 'number'
806
+ ? React.createElement('span', { className: 'cb-badge', style: { marginLeft: '6px' } }, t('accounts.quota_used', { pct: acc.percentUsed }))
557
807
  : null
558
808
  ),
559
809
  React.createElement(
@@ -571,12 +821,36 @@ function AccountsSection({ status, busy, handlePinAccount, t }) {
571
821
  },
572
822
  t('accounts.pin_btn')
573
823
  )
824
+ : null,
825
+ !isPrimary
826
+ ? React.createElement(
827
+ 'button',
828
+ {
829
+ type: 'button',
830
+ className: 'cb-btn cb-btn-danger',
831
+ style: { padding: '4px 8px', fontSize: '11px', marginLeft: '6px' },
832
+ disabled: !!busy,
833
+ onClick: () => onDeleteAccount(envName),
834
+ },
835
+ t('accounts.delete_btn')
836
+ )
574
837
  : null
575
838
  )
576
839
  )
577
840
  })
578
841
  )
579
- )
842
+ ),
843
+ status.lastRotation
844
+ ? React.createElement(
845
+ 'div',
846
+ { className: 'cb-rotation-info', style: { marginTop: '10px', fontSize: '12px', opacity: 0.85 } },
847
+ t('accounts.last_failover', {
848
+ from: status.lastRotation.from,
849
+ to: status.lastRotation.to,
850
+ reason: status.lastRotation.reason,
851
+ })
852
+ )
853
+ : null
580
854
  )
581
855
  }
582
856
 
@@ -627,7 +901,7 @@ function QuotaSection({ keyPresent, status, usage, busy, handleRefreshQuota, t }
627
901
  'div',
628
902
  { className: 'cb-section-desc' },
629
903
  usage?.user?.email
630
- ? t('quota.account', { email: usage.user.email, plan: usage.plan || 'ClinePass ($9.99/mo)' })
904
+ ? t('quota.account', { email: usage.user.email, plan: usage.plan || 'ClinePass' })
631
905
  : t('quota.desc')
632
906
  ),
633
907
  React.createElement(
@@ -654,14 +928,63 @@ function QuotaSection({ keyPresent, status, usage, busy, handleRefreshQuota, t }
654
928
  }
655
929
 
656
930
  // --- src/client/components/models-section.js ---
657
- function ModelsSection({ modelsList, disabledSet, enabledCount, keyPresent, busy, handleSyncPlanModels, handleSetModelsFilter, handleToggleModel, t }) {
931
+ function ModelsSection({
932
+ modelsList,
933
+ disabledSet,
934
+ enabledCount,
935
+ keyPresent,
936
+ busy,
937
+ handleSyncPlanModels,
938
+ handleSetModelsFilter,
939
+ handleToggleModel,
940
+ planSynced,
941
+ planSyncedAt,
942
+ defaultModelWarning,
943
+ t,
944
+ }) {
945
+ const syncDateStr = planSyncedAt ? new Date(planSyncedAt).toLocaleDateString() : ''
946
+ const [search, setSearch] = React.useState('')
947
+ const [viewFilter, setViewFilter] = React.useState('all')
948
+
949
+ const filteredModels = modelsList.filter((m) => {
950
+ if (viewFilter === 'vision') {
951
+ const hasVision = Boolean(m.input?.includes('image') || m.input?.includes('vision'))
952
+ if (!hasVision) return false
953
+ } else if (viewFilter === 'coding') {
954
+ if (m.category !== 'coding') return false
955
+ } else if (viewFilter === 'recommended') {
956
+ if (!m.recommended) return false
957
+ } else if (viewFilter === 'disabled') {
958
+ if (!disabledSet.has(m.id)) return false
959
+ }
960
+ if (search.trim()) {
961
+ const q = search.trim().toLowerCase()
962
+ const name = String(m.name || '').toLowerCase()
963
+ const id = String(m.id || '').toLowerCase()
964
+ const desc = String(m.description || '').toLowerCase()
965
+ if (!name.includes(q) && !id.includes(q) && !desc.includes(q)) return false
966
+ }
967
+ return true
968
+ })
969
+
658
970
  return React.createElement(
659
971
  'div',
660
972
  { className: 'cb-section-card' },
973
+ defaultModelWarning
974
+ ? React.createElement('div', { className: 'cb-alert-bad', style: { marginBottom: '12px' } }, `⚠️ ${defaultModelWarning}`)
975
+ : null,
661
976
  React.createElement(
662
977
  'div',
663
978
  { className: 'cb-section-title' },
664
- t('models.title', { enabled: enabledCount, total: modelsList.length }),
979
+ React.createElement(
980
+ 'span',
981
+ null,
982
+ t('models.title', { enabled: enabledCount, total: modelsList.length }),
983
+ planSynced
984
+ ? React.createElement('span', { className: 'cb-badge cb-badge-ok', style: { marginLeft: '8px', fontSize: '11px' } }, t('models.verified'))
985
+ : React.createElement('span', { className: 'cb-badge cb-badge-warn', style: { marginLeft: '8px', fontSize: '11px' } }, t('models.unverified')),
986
+ syncDateStr ? React.createElement('span', { style: { marginLeft: '8px', fontSize: '11px', color: 'var(--dsw-alias-label-secondary)' } }, t('models.synced_at', { date: syncDateStr })) : null
987
+ ),
665
988
  React.createElement(
666
989
  'div',
667
990
  { className: 'cb-row' },
@@ -682,6 +1005,83 @@ function ModelsSection({ modelsList, disabledSet, enabledCount, keyPresent, busy
682
1005
  )
683
1006
  ),
684
1007
  React.createElement('div', { className: 'cb-section-desc' }, t('models.desc')),
1008
+
1009
+ // Search and display filter bar
1010
+ React.createElement(
1011
+ 'div',
1012
+ {
1013
+ style: {
1014
+ display: 'flex',
1015
+ gap: '8px',
1016
+ alignItems: 'center',
1017
+ flexWrap: 'wrap',
1018
+ marginBottom: '12px',
1019
+ },
1020
+ },
1021
+ React.createElement('input', {
1022
+ type: 'text',
1023
+ className: 'cb-input',
1024
+ style: { flex: '1 1 200px' },
1025
+ placeholder: t('models.search_placeholder'),
1026
+ value: search,
1027
+ onChange: (e) => setSearch(e.target.value),
1028
+ }),
1029
+ React.createElement(
1030
+ 'div',
1031
+ { className: 'cb-row', style: { gap: '4px' } },
1032
+ React.createElement(
1033
+ 'button',
1034
+ {
1035
+ type: 'button',
1036
+ className: `cb-btn ${viewFilter === 'all' ? 'cb-btn-active' : ''}`,
1037
+ style: { padding: '4px 8px', fontSize: '11px' },
1038
+ onClick: () => setViewFilter('all'),
1039
+ },
1040
+ t('models.filter_all')
1041
+ ),
1042
+ React.createElement(
1043
+ 'button',
1044
+ {
1045
+ type: 'button',
1046
+ className: `cb-btn ${viewFilter === 'vision' ? 'cb-btn-active' : ''}`,
1047
+ style: { padding: '4px 8px', fontSize: '11px' },
1048
+ onClick: () => setViewFilter('vision'),
1049
+ },
1050
+ t('models.filter_vision')
1051
+ ),
1052
+ React.createElement(
1053
+ 'button',
1054
+ {
1055
+ type: 'button',
1056
+ className: `cb-btn ${viewFilter === 'coding' ? 'cb-btn-active' : ''}`,
1057
+ style: { padding: '4px 8px', fontSize: '11px' },
1058
+ onClick: () => setViewFilter('coding'),
1059
+ },
1060
+ t('models.filter_coding')
1061
+ ),
1062
+ React.createElement(
1063
+ 'button',
1064
+ {
1065
+ type: 'button',
1066
+ className: `cb-btn ${viewFilter === 'recommended' ? 'cb-btn-active' : ''}`,
1067
+ style: { padding: '4px 8px', fontSize: '11px' },
1068
+ onClick: () => setViewFilter('recommended'),
1069
+ },
1070
+ t('models.filter_recommended')
1071
+ ),
1072
+ React.createElement(
1073
+ 'button',
1074
+ {
1075
+ type: 'button',
1076
+ className: `cb-btn ${viewFilter === 'disabled' ? 'cb-btn-active' : ''}`,
1077
+ style: { padding: '4px 8px', fontSize: '11px' },
1078
+ onClick: () => setViewFilter('disabled'),
1079
+ },
1080
+ t('models.filter_disabled')
1081
+ )
1082
+ )
1083
+ ),
1084
+
685
1085
  React.createElement(
686
1086
  'table',
687
1087
  { className: 'cb-table' },
@@ -701,7 +1101,7 @@ function ModelsSection({ modelsList, disabledSet, enabledCount, keyPresent, busy
701
1101
  React.createElement(
702
1102
  'tbody',
703
1103
  null,
704
- modelsList.map((m) => {
1104
+ filteredModels.map((m) => {
705
1105
  const isEnabled = !disabledSet.has(m.id)
706
1106
  return React.createElement(
707
1107
  'tr',
@@ -727,17 +1127,31 @@ function ModelsSection({ modelsList, disabledSet, enabledCount, keyPresent, busy
727
1127
  : null
728
1128
  ),
729
1129
  React.createElement('td', null, React.createElement('code', null, m.id)),
730
- React.createElement('td', null, `${Math.round((m.contextLength || 200000) / 1000)}k`),
1130
+ React.createElement('td', null, `${Math.round((m.contextLength || 128000) / 1000)}k`),
731
1131
  React.createElement(
732
1132
  'td',
733
1133
  null,
734
- React.createElement('span', { className: 'cb-badge' }, m.category || 'general'),
735
- m.input?.includes('image') || m.input?.includes('vision')
736
- ? React.createElement('span', { className: 'cb-badge', style: { marginLeft: '4px' } }, 'Vision')
737
- : null,
738
- (Array.isArray(m.reasoningEfforts) ? m.reasoningEfforts.length > 0 : (m.reasoningEfforts && typeof m.reasoningEfforts === 'object' ? Object.keys(m.reasoningEfforts).length > 0 : Boolean(m.reasoning)))
739
- ? React.createElement('span', { className: 'cb-badge cb-badge-ok', style: { marginLeft: '4px' }, title: t('models.reasoning_tooltip') }, '🧠 Reasoning')
740
- : null
1134
+ (() => {
1135
+ const hasReasoning = Boolean(
1136
+ (Array.isArray(m.reasoningEfforts) && m.reasoningEfforts.length > 0) ||
1137
+ (m.reasoningEfforts && typeof m.reasoningEfforts === 'object' && Object.keys(m.reasoningEfforts).length > 0) ||
1138
+ m.reasoning ||
1139
+ m.category === 'reasoning'
1140
+ )
1141
+ const category = m.category && m.category !== 'reasoning' ? m.category : (hasReasoning ? null : 'general')
1142
+ const hasVision = Boolean(m.input?.includes('image') || m.input?.includes('vision'))
1143
+ return React.createElement(
1144
+ React.Fragment,
1145
+ null,
1146
+ category ? React.createElement('span', { className: 'cb-badge' }, category) : null,
1147
+ hasVision
1148
+ ? React.createElement('span', { className: 'cb-badge', style: { marginLeft: category ? '4px' : '0' } }, 'Vision')
1149
+ : null,
1150
+ hasReasoning
1151
+ ? React.createElement('span', { className: 'cb-badge cb-badge-ok', style: { marginLeft: (category || hasVision) ? '4px' : '0' }, title: t('models.reasoning_tooltip') }, '🧠 Reasoning')
1152
+ : null
1153
+ )
1154
+ })()
741
1155
  )
742
1156
  )
743
1157
  })
@@ -840,58 +1254,23 @@ Response: ${smokeResult.preview || '(empty)'}`
840
1254
  }
841
1255
 
842
1256
  // --- src/client/settings-page.js ---
843
- function readConfigForms(ctx) {
844
- if (!ctx || typeof ctx.get !== 'function') return undefined
845
- try {
846
- return ctx.get('configForms') || undefined
847
- } catch (err) {
848
- console.warn('[dsh-clinebot] configForms is not available:', err)
849
- return undefined
850
- }
851
- }
852
-
853
- function SettingsPage(props) {
854
- const ctx = props?.ctx
855
- const t = props?.t || makeT(en, en)
856
-
1257
+ function useConfigFormsSnapshot(ctx) {
857
1258
  const scope = React.useMemo(() => {
858
- const svc = readConfigForms(ctx)
859
- if (!svc || typeof svc.get !== 'function') return undefined
860
- try {
861
- return svc.get(NS)
862
- } catch (err) {
863
- console.warn('[dsh-clinebot] configForms.get failed:', err)
864
- return undefined
865
- }
1259
+ try { return readConfigForms(ctx)?.get?.(NS) || undefined } catch { return undefined }
866
1260
  }, [ctx])
867
-
868
- const subscribe = React.useMemo(() => {
869
- return (cb) => {
870
- if (!scope?.subscribe) return () => {}
871
- try {
872
- return scope.subscribe(cb) || (() => {})
873
- } catch (_) {
874
- return () => {}
875
- }
876
- }
1261
+ const subscribe = React.useMemo(() => (cb) => {
1262
+ try { return scope?.subscribe ? (scope.subscribe(cb) || (() => {})) : () => {} } catch { return () => {} }
877
1263
  }, [scope])
878
-
879
1264
  const getSnapshot = React.useCallback(() => {
880
- if (!scope?.getSnapshot) return SNAPSHOT_READY
881
- try {
882
- return scope.getSnapshot() || SNAPSHOT_READY
883
- } catch (err) {
884
- console.warn('[dsh-clinebot] settings snapshot failed:', err)
885
- return SNAPSHOT_READY
886
- }
1265
+ try { return scope?.getSnapshot?.() || SNAPSHOT_READY } catch { return SNAPSHOT_READY }
887
1266
  }, [scope])
1267
+ return React.useSyncExternalStore(subscribe, getSnapshot, () => SNAPSHOT_READY)?.status || 'loading'
1268
+ }
888
1269
 
889
- const snapshot = React.useSyncExternalStore(
890
- subscribe,
891
- getSnapshot,
892
- React.useCallback(() => SNAPSHOT_READY, [])
893
- )
894
- const snapshotStatus = snapshot?.status || 'loading'
1270
+ function SettingsPage(props) {
1271
+ const ctx = props?.ctx
1272
+ const t = props?.t || makeT(en, en)
1273
+ const snapshotStatus = useConfigFormsSnapshot(ctx)
895
1274
 
896
1275
  const [status, setStatus] = React.useState(null)
897
1276
  const [draft, setDraft] = React.useState(null)
@@ -900,7 +1279,6 @@ function SettingsPage(props) {
900
1279
  const [msg, setMsg] = React.useState('')
901
1280
  const [smokeResult, setSmokeResult] = React.useState(null)
902
1281
 
903
- // Plugin in-app updater state
904
1282
  const [updateState, setUpdateState] = React.useState({
905
1283
  checking: false,
906
1284
  updating: false,
@@ -912,28 +1290,10 @@ function SettingsPage(props) {
912
1290
  notice: '',
913
1291
  })
914
1292
 
915
- // Key input state
916
1293
  const [apiKeyInput, setApiKeyInput] = React.useState('')
917
1294
  const [showKey, setShowKey] = React.useState(false)
918
1295
 
919
- // Accounts state
920
- const [newAccountLabel, setNewAccountLabel] = React.useState('')
921
- const [newAccountEnv, setNewAccountEnv] = React.useState('')
922
- const [showAddAccount, setShowAddAccount] = React.useState(false)
923
-
924
- // Filter & Search for Models
925
- const [modelsFilter, setModelsFilter] = React.useState('all')
926
- const [modelsSearch, setModelsSearch] = React.useState('')
927
- const [disabledModels, setDisabledModels] = React.useState([])
928
- const [advancedOpen, setAdvancedOpen] = React.useState(false)
929
-
930
- // Loopback fast login state
931
- const [fastLoginActive, setFastLoginActive] = React.useState(false)
932
- const [fastLoginNotice, setFastLoginNotice] = React.useState('')
933
-
934
- React.useEffect(() => {
935
- ensureCss()
936
- }, [])
1296
+ React.useEffect(() => { ensureCss() }, [])
937
1297
 
938
1298
  const load = React.useCallback(async () => {
939
1299
  setErr('')
@@ -967,9 +1327,7 @@ function SettingsPage(props) {
967
1327
  }
968
1328
  }, [])
969
1329
 
970
- React.useEffect(() => {
971
- checkUpdate()
972
- }, [checkUpdate])
1330
+ React.useEffect(() => { checkUpdate() }, [checkUpdate])
973
1331
 
974
1332
  async function handleTriggerUpdate() {
975
1333
  if (updateState.updating) return
@@ -980,9 +1338,7 @@ function SettingsPage(props) {
980
1338
  headers: { 'x-dsh-plugin-update': '1' },
981
1339
  })
982
1340
  const data = await res.json().catch(() => ({}))
983
- if (!res.ok || data.ok === false || data.error) {
984
- throw new Error(data.error || `HTTP ${res.status}`)
985
- }
1341
+ if (!res.ok || data.ok === false || data.error) throw new Error(data.error || `HTTP ${res.status}`)
986
1342
  const newVer = data.updatedVersion || updateState.latestVersion || updateState.currentVersion
987
1343
  setUpdateState((s) => ({
988
1344
  ...s,
@@ -993,24 +1349,27 @@ function SettingsPage(props) {
993
1349
  }))
994
1350
  setTimeout(() => checkUpdate(), 2000)
995
1351
  } catch (err) {
996
- setUpdateState((s) => ({
997
- ...s,
998
- updating: false,
999
- error: t('update.failed', { error: String(err.message || err) }),
1000
- }))
1352
+ setUpdateState((s) => ({ ...s, updating: false, error: t('update.failed', { error: String(err.message || err) }) }))
1001
1353
  }
1002
1354
  }
1003
1355
 
1004
- async function handleSaveKey() {
1005
- const keyVal = String(apiKeyInput || '').trim()
1006
- if (!keyVal) {
1007
- setErr(t('key.empty_err'))
1008
- return
1009
- }
1010
- setBusy('save-key')
1356
+ async function performAction(busyKey, fn) {
1357
+ setBusy(busyKey)
1011
1358
  setErr('')
1012
1359
  setMsg('')
1013
1360
  try {
1361
+ await fn()
1362
+ } catch (e) {
1363
+ setErr(String(e.message || e))
1364
+ } finally {
1365
+ setBusy('')
1366
+ }
1367
+ }
1368
+
1369
+ async function handleSaveKey() {
1370
+ const keyVal = String(apiKeyInput || '').trim()
1371
+ if (!keyVal) { setErr(t('key.empty_err')); return }
1372
+ await performAction('save-key', async () => {
1014
1373
  const res = await fetch(`${ROUTE_PREFIX}/save-key`, {
1015
1374
  method: 'POST',
1016
1375
  headers: { 'Content-Type': 'application/json' },
@@ -1021,87 +1380,53 @@ function SettingsPage(props) {
1021
1380
  setApiKeyInput('')
1022
1381
  setMsg(t('key.saved_msg', { status: data.validated ? 'OK' : 'Notice (check console)' }))
1023
1382
  await load()
1024
- } catch (e) {
1025
- setErr(String(e.message || e))
1026
- } finally {
1027
- setBusy('')
1028
- }
1383
+ })
1029
1384
  }
1030
1385
 
1031
1386
  async function handleRefreshQuota() {
1032
- setBusy('refresh-quota')
1033
- setErr('')
1034
- setMsg('')
1035
- try {
1387
+ await performAction('refresh-quota', async () => {
1036
1388
  const res = await fetch(`${ROUTE_PREFIX}/usage`)
1037
1389
  const data = await res.json().catch(() => ({}))
1038
1390
  if (!res.ok || !data.ok) throw new Error(data.error || `HTTP ${res.status}`)
1039
1391
  setStatus((prev) => (prev ? { ...prev, usage: data } : prev))
1040
1392
  setMsg(t('quota.refreshed_msg'))
1041
- } catch (e) {
1042
- setErr(String(e.message || e))
1043
- } finally {
1044
- setBusy('')
1045
- }
1393
+ })
1046
1394
  }
1047
1395
 
1048
1396
  async function handleSyncPlanModels() {
1049
- setBusy('sync-models')
1050
- setErr('')
1051
- setMsg('')
1052
- try {
1397
+ await performAction('sync-models', async () => {
1053
1398
  const res = await fetch(`${ROUTE_PREFIX}/models/sync`, { method: 'POST' })
1054
1399
  const data = await res.json().catch(() => ({}))
1055
1400
  if (!res.ok || !data.ok) throw new Error(data.error || `HTTP ${res.status}`)
1056
1401
  setMsg(t('models.synced_msg', { total: data.totalModelsCount, discovered: data.discoveredCount }))
1057
1402
  await load()
1058
- } catch (e) {
1059
- setErr(String(e.message || e))
1060
- } finally {
1061
- setBusy('')
1062
- }
1403
+ })
1063
1404
  }
1064
1405
 
1065
1406
  async function handleRegister() {
1066
- setBusy('register')
1067
- setErr('')
1068
- setMsg('')
1069
- try {
1407
+ await performAction('register', async () => {
1070
1408
  const res = await fetch(`${ROUTE_PREFIX}/register`, { method: 'POST' })
1071
1409
  const data = await res.json().catch(() => ({}))
1072
1410
  if (!res.ok || !data.ok) throw new Error(data.error || `HTTP ${res.status}`)
1073
1411
  const count = status?.availableModels?.filter((m) => !(draft?.disabledModels || []).includes(m.id)).length || 0
1074
1412
  setMsg(t('diag.resynced_msg', { count }))
1075
1413
  await load()
1076
- } catch (e) {
1077
- setErr(String(e.message || e))
1078
- } finally {
1079
- setBusy('')
1080
- }
1414
+ })
1081
1415
  }
1082
1416
 
1083
1417
  async function handleUnregister() {
1084
- setBusy('unregister')
1085
- setErr('')
1086
- setMsg('')
1087
- try {
1418
+ await performAction('unregister', async () => {
1088
1419
  const res = await fetch(`${ROUTE_PREFIX}/unregister`, { method: 'POST' })
1089
1420
  const data = await res.json().catch(() => ({}))
1090
1421
  if (!res.ok || !data.ok) throw new Error(data.error || `HTTP ${res.status}`)
1091
1422
  setMsg(t('diag.unregistered_msg'))
1092
1423
  await load()
1093
- } catch (e) {
1094
- setErr(String(e.message || e))
1095
- } finally {
1096
- setBusy('')
1097
- }
1424
+ })
1098
1425
  }
1099
1426
 
1100
1427
  async function handleSmoke() {
1101
- setBusy('smoke')
1102
- setErr('')
1103
1428
  setSmokeResult(null)
1104
- try {
1429
+ await performAction('smoke', async () => {
1105
1430
  const res = await fetch(`${ROUTE_PREFIX}/smoke`, {
1106
1431
  method: 'POST',
1107
1432
  headers: { 'Content-Type': 'application/json' },
@@ -1110,34 +1435,48 @@ function SettingsPage(props) {
1110
1435
  const data = await res.json().catch(() => ({}))
1111
1436
  if (!res.ok || !data.ok) throw new Error(data.error || `HTTP ${res.status}`)
1112
1437
  setSmokeResult(data)
1113
- } catch (e) {
1114
- setErr(String(e.message || e))
1115
- } finally {
1116
- setBusy('')
1117
- }
1438
+ })
1118
1439
  }
1119
1440
 
1120
- async function handleFastLogin() {
1121
- setBusy('fast-login')
1122
- setErr('')
1123
- try {
1124
- const res = await fetch(`${ROUTE_PREFIX}/auth/begin`, { method: 'POST' })
1125
- const data = await res.json().catch(() => ({}))
1126
- if (!res.ok || !data.ok) throw new Error(data.error || `HTTP ${res.status}`)
1127
- if (data.authUrl) {
1128
- window.open(data.authUrl, '_blank')
1441
+ const [verifyStatus, setVerifyStatus] = React.useState({ state: 'idle', email: '', plan: '', error: '' })
1442
+ const verifyTimerRef = typeof React.useRef === 'function' ? React.useRef(null) : { current: null }
1443
+
1444
+ React.useEffect(() => {
1445
+ const raw = String(apiKeyInput || '').trim()
1446
+ if (!raw || raw.length < 10) {
1447
+ setVerifyStatus({ state: 'idle', email: '', plan: '', error: '' })
1448
+ return
1449
+ }
1450
+ if (verifyTimerRef.current) clearTimeout(verifyTimerRef.current)
1451
+ verifyTimerRef.current = setTimeout(async () => {
1452
+ setVerifyStatus({ state: 'verifying', email: '', plan: '', error: '' })
1453
+ try {
1454
+ const res = await fetch(`${ROUTE_PREFIX}/key/verify`, {
1455
+ method: 'POST',
1456
+ headers: { 'Content-Type': 'application/json' },
1457
+ body: JSON.stringify({ key: raw }),
1458
+ })
1459
+ const data = await res.json().catch(() => ({}))
1460
+ if (data.ok && data.valid) {
1461
+ setVerifyStatus({ state: 'valid', email: data.email, plan: data.plan, error: '' })
1462
+ } else {
1463
+ setVerifyStatus({ state: 'invalid', email: '', plan: '', error: data.error || 'Verification failed' })
1464
+ }
1465
+ } catch (err) {
1466
+ setVerifyStatus({ state: 'invalid', email: '', plan: '', error: String(err?.message || err) })
1129
1467
  }
1130
- } catch (e) {
1131
- setErr(String(e.message || e))
1132
- } finally {
1133
- setBusy('')
1468
+ }, 500)
1469
+ return () => {
1470
+ if (verifyTimerRef.current) clearTimeout(verifyTimerRef.current)
1134
1471
  }
1472
+ }, [apiKeyInput])
1473
+
1474
+ function handleFastLogin() {
1475
+ window.open('https://app.cline.bot/settings/api-keys', '_blank')
1135
1476
  }
1136
1477
 
1137
1478
  async function handlePinAccount(accountEnv) {
1138
- setBusy('pin-account')
1139
- setErr('')
1140
- try {
1479
+ await performAction('pin-account', async () => {
1141
1480
  const res = await fetch(`${ROUTE_PREFIX}/accounts/active`, {
1142
1481
  method: 'POST',
1143
1482
  headers: { 'Content-Type': 'application/json' },
@@ -1146,25 +1485,65 @@ function SettingsPage(props) {
1146
1485
  const data = await res.json().catch(() => ({}))
1147
1486
  if (!res.ok || !data.ok) throw new Error(data.error || `HTTP ${res.status}`)
1148
1487
  await load()
1149
- } catch (e) {
1150
- setErr(String(e.message || e))
1151
- } finally {
1152
- setBusy('')
1153
- }
1488
+ })
1489
+ }
1490
+
1491
+ async function handleAddAccount({ label, apiKeyEnv, apiKey }) {
1492
+ await performAction('add-account', async () => {
1493
+ const res = await fetch(`${ROUTE_PREFIX}/accounts`, {
1494
+ method: 'POST',
1495
+ headers: { 'Content-Type': 'application/json' },
1496
+ body: JSON.stringify({ label, apiKeyEnv, apiKey }),
1497
+ })
1498
+ const data = await res.json().catch(() => ({}))
1499
+ if (!res.ok || !data.ok) throw new Error(data.error || `HTTP ${res.status}`)
1500
+ setMsg(t('key.saved_msg', { status: data.validated ? 'OK' : (data.validationError || 'Notice') }))
1501
+ await load()
1502
+ })
1503
+ }
1504
+
1505
+ async function handleDeleteAccount(accountEnv, deleteSecret = true) {
1506
+ await performAction('delete-account', async () => {
1507
+ const res = await fetch(`${ROUTE_PREFIX}/accounts/delete`, {
1508
+ method: 'POST',
1509
+ headers: { 'Content-Type': 'application/json' },
1510
+ body: JSON.stringify({ apiKeyEnv: accountEnv, deleteSecret }),
1511
+ })
1512
+ const data = await res.json().catch(() => ({}))
1513
+ if (!res.ok || !data.ok) throw new Error(data.error || `HTTP ${res.status}`)
1514
+ await load()
1515
+ })
1154
1516
  }
1155
1517
 
1156
1518
  const debounceTimerRef = typeof React.useRef === 'function' ? React.useRef(null) : { current: null }
1157
- function debounceSaveDisabledModels(nextDisabled) {
1519
+
1520
+ React.useEffect(() => {
1521
+ return () => {
1522
+ if (debounceTimerRef.current) clearTimeout(debounceTimerRef.current)
1523
+ }
1524
+ }, [])
1525
+
1526
+ function debounceSaveDisabledModels(nextDisabled, previousDisabled) {
1158
1527
  if (debounceTimerRef.current) clearTimeout(debounceTimerRef.current)
1159
1528
  debounceTimerRef.current = setTimeout(async () => {
1160
1529
  try {
1161
- await fetch(`${ROUTE_PREFIX}/models/toggle`, {
1530
+ const res = await fetch(`${ROUTE_PREFIX}/models/toggle`, {
1162
1531
  method: 'POST',
1163
1532
  headers: { 'Content-Type': 'application/json' },
1164
1533
  body: JSON.stringify({ disabledModels: nextDisabled }),
1165
1534
  })
1535
+ const data = await res.json().catch(() => ({}))
1536
+ if (!res.ok || !data.ok) throw new Error(data.error || `HTTP ${res.status}`)
1537
+ await load()
1166
1538
  } catch (err) {
1167
1539
  console.warn('[dsh-clinebot] Failed saving disabled models:', err)
1540
+ setErr(String(err?.message || err))
1541
+ setDraft((prev) => {
1542
+ if (!prev) return prev
1543
+ const all = status?.availableModels || []
1544
+ const prevEnabled = all.map((m) => m.id).filter((mId) => !previousDisabled.includes(mId))
1545
+ return { ...prev, disabledModels: previousDisabled, enabledModels: prevEnabled }
1546
+ })
1168
1547
  }
1169
1548
  }, 400)
1170
1549
  }
@@ -1176,7 +1555,7 @@ function SettingsPage(props) {
1176
1555
  const all = status?.availableModels || []
1177
1556
  const enabledIds = all.map((m) => m.id).filter((mId) => !next.includes(mId))
1178
1557
  setDraft({ ...draft, disabledModels: next, enabledModels: enabledIds })
1179
- debounceSaveDisabledModels(next)
1558
+ debounceSaveDisabledModels(next, curr)
1180
1559
  }
1181
1560
 
1182
1561
  function handleSetModelsFilter(type) {
@@ -1192,10 +1571,11 @@ function SettingsPage(props) {
1192
1571
  allowed = new Set(all.filter((m) => m.recommended).map((m) => m.id))
1193
1572
  }
1194
1573
 
1574
+ const curr = draft?.disabledModels || []
1195
1575
  const nextDisabled = all.map((m) => m.id).filter((id) => !allowed.has(id))
1196
1576
  const nextEnabled = Array.from(allowed)
1197
1577
  setDraft({ ...draft, disabledModels: nextDisabled, enabledModels: nextEnabled })
1198
- debounceSaveDisabledModels(nextDisabled)
1578
+ debounceSaveDisabledModels(nextDisabled, curr)
1199
1579
  }
1200
1580
 
1201
1581
  if (!status || !draft) {
@@ -1264,6 +1644,11 @@ function SettingsPage(props) {
1264
1644
  // In-app Update Bar
1265
1645
  React.createElement(UpdateBanner, { updateState, handleTriggerUpdate, t }),
1266
1646
 
1647
+ // Settings host status warning
1648
+ snapshotStatus === 'unavailable'
1649
+ ? React.createElement('div', { className: 'cb-alert-bad', style: { marginBottom: '12px' } }, t('settings.unavailable'))
1650
+ : null,
1651
+
1267
1652
  // Notifications
1268
1653
  err ? React.createElement('div', { className: 'cb-alert-bad' }, err) : null,
1269
1654
  msg ? React.createElement('div', { className: 'cb-alert-ok' }, msg) : null,
@@ -1279,11 +1664,19 @@ function SettingsPage(props) {
1279
1664
  draft,
1280
1665
  handleSaveKey,
1281
1666
  handleFastLogin,
1667
+ verifyStatus,
1282
1668
  t,
1283
1669
  }),
1284
1670
 
1285
1671
  // Accounts Pool Card
1286
- React.createElement(AccountsSection, { status, busy, handlePinAccount, t }),
1672
+ React.createElement(AccountsSection, {
1673
+ status,
1674
+ busy,
1675
+ handlePinAccount,
1676
+ handleAddAccount,
1677
+ handleDeleteAccount,
1678
+ t,
1679
+ }),
1287
1680
 
1288
1681
  // Quota Warning & Dashboard Card
1289
1682
  React.createElement(QuotaSection, { keyPresent, status, usage, busy, handleRefreshQuota, t }),
@@ -1298,6 +1691,9 @@ function SettingsPage(props) {
1298
1691
  handleSyncPlanModels,
1299
1692
  handleSetModelsFilter,
1300
1693
  handleToggleModel,
1694
+ planSynced: !!(status?.config?.planSynced || (status?.config?.dynamicModels && status.config.dynamicModels.length > 0)),
1695
+ planSyncedAt: status?.config?.planSyncedAt || 0,
1696
+ defaultModelWarning: status?.config?.defaultModelWarning || '',
1301
1697
  t,
1302
1698
  }),
1303
1699
 
@@ -1370,7 +1766,7 @@ function PluginCard(props) {
1370
1766
  React.createElement(
1371
1767
  ErrorBoundary,
1372
1768
  null,
1373
- React.createElement(SettingsPage, { ...props, ctx: (props && props.ctx) || ctx })
1769
+ React.createElement(SettingsPage, { ...props, ctx: props && props.ctx })
1374
1770
  )
1375
1771
  )
1376
1772
  : null