@goodandready/dsh-clinebot 0.4.0 → 0.4.2

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,12 +14,16 @@ 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',
@@ -60,6 +64,14 @@ const en = {
60
64
  'accounts.add_btn': '+ Add Account',
61
65
  'accounts.label_placeholder': 'Account Label (e.g. Work)',
62
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})',
63
75
  'quota.title': '📊 ClinePass Quota & Rate Limits',
64
76
  'quota.desc': 'Official rolling window request limits from ClinePass',
65
77
  'quota.account': 'Account: {email} · Plan: {plan}',
@@ -80,6 +92,12 @@ const en = {
80
92
  'models.verified': 'Verified with plan',
81
93
  'models.unverified': 'Not synced (fallback catalog)',
82
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',
83
101
  'models.all': 'All',
84
102
  'models.vision': 'Vision Only',
85
103
  'models.coding': 'Coding Only',
@@ -156,6 +174,14 @@ const en = {
156
174
  'accounts.add_btn': '+ 添加账号',
157
175
  'accounts.label_placeholder': '账号标签(例如:工作账号)',
158
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})',
159
185
  'quota.title': '📊 ClinePass 用量配额与限额监控',
160
186
  'quota.desc': '来自 ClinePass 官方的实时滚动窗口用量限额',
161
187
  'quota.account': '账号:{email} · 套餐:{plan}',
@@ -176,6 +202,12 @@ const en = {
176
202
  'models.verified': '已与套餐同步',
177
203
  'models.unverified': '未同步(备用目录)',
178
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': '已禁用',
179
211
  'models.all': '全部模型',
180
212
  'models.vision': '仅视觉 (Vision)',
181
213
  'models.coding': '仅编程 (Coding)',
@@ -574,13 +606,162 @@ function KeySection({ keyPresent, apiKeyInput, setApiKeyInput, showKey, setShowK
574
606
  }
575
607
 
576
608
  // --- src/client/components/accounts-section.js ---
577
- function AccountsSection({ status, busy, handlePinAccount, t }) {
609
+ function AccountsSection({ status, busy, handlePinAccount, handleAddAccount, handleDeleteAccount, t }) {
578
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
+
579
657
  return React.createElement(
580
658
  'div',
581
659
  { className: 'cb-section-card' },
582
- 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
+ ),
583
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
+
584
765
  React.createElement(
585
766
  'table',
586
767
  { className: 'cb-table' },
@@ -603,9 +784,10 @@ function AccountsSection({ status, busy, handlePinAccount, t }) {
603
784
  const envName = typeof acc.apiKeyEnv === 'string' ? acc.apiKeyEnv : ''
604
785
  const label = typeof acc.label === 'string' ? acc.label : ''
605
786
  const isActive = status.activeAccount === envName || (!status.activeAccount && acc.id === 'default')
787
+ const isPrimary = acc.id === 'default' || envName === status.config?.apiKeyEnv
606
788
  return React.createElement(
607
789
  'tr',
608
- { key: acc.id },
790
+ { key: acc.id || envName },
609
791
  React.createElement('td', null, React.createElement('strong', null, label)),
610
792
  React.createElement('td', null, React.createElement('code', null, envName)),
611
793
  React.createElement(
@@ -616,6 +798,12 @@ function AccountsSection({ status, busy, handlePinAccount, t }) {
616
798
  : React.createElement('span', { className: 'cb-badge cb-badge-warn' }, 'Missing Key'),
617
799
  isActive
618
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 }))
619
807
  : null
620
808
  ),
621
809
  React.createElement(
@@ -633,6 +821,19 @@ function AccountsSection({ status, busy, handlePinAccount, t }) {
633
821
  },
634
822
  t('accounts.pin_btn')
635
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
+ )
636
837
  : null
637
838
  )
638
839
  )
@@ -643,7 +844,11 @@ function AccountsSection({ status, busy, handlePinAccount, t }) {
643
844
  ? React.createElement(
644
845
  'div',
645
846
  { className: 'cb-rotation-info', style: { marginTop: '10px', fontSize: '12px', opacity: 0.85 } },
646
- `Last failover: ${status.lastRotation.from} → ${status.lastRotation.to} (${status.lastRotation.reason})`
847
+ t('accounts.last_failover', {
848
+ from: status.lastRotation.from,
849
+ to: status.lastRotation.to,
850
+ reason: status.lastRotation.reason,
851
+ })
647
852
  )
648
853
  : null
649
854
  )
@@ -738,6 +943,29 @@ function ModelsSection({
738
943
  t,
739
944
  }) {
740
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
+ })
741
969
 
742
970
  return React.createElement(
743
971
  'div',
@@ -777,6 +1005,83 @@ function ModelsSection({
777
1005
  )
778
1006
  ),
779
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
+
780
1085
  React.createElement(
781
1086
  'table',
782
1087
  { className: 'cb-table' },
@@ -796,7 +1101,7 @@ function ModelsSection({
796
1101
  React.createElement(
797
1102
  'tbody',
798
1103
  null,
799
- modelsList.map((m) => {
1104
+ filteredModels.map((m) => {
800
1105
  const isEnabled = !disabledSet.has(m.id)
801
1106
  return React.createElement(
802
1107
  'tr',
@@ -949,58 +1254,23 @@ Response: ${smokeResult.preview || '(empty)'}`
949
1254
  }
950
1255
 
951
1256
  // --- src/client/settings-page.js ---
952
- function readConfigForms(ctx) {
953
- if (!ctx || typeof ctx.get !== 'function') return undefined
954
- try {
955
- return ctx.get('configForms') || undefined
956
- } catch (err) {
957
- console.warn('[dsh-clinebot] configForms is not available:', err)
958
- return undefined
959
- }
960
- }
961
-
962
- function SettingsPage(props) {
963
- const ctx = props?.ctx
964
- const t = props?.t || makeT(en, en)
965
-
1257
+ function useConfigFormsSnapshot(ctx) {
966
1258
  const scope = React.useMemo(() => {
967
- const svc = readConfigForms(ctx)
968
- if (!svc || typeof svc.get !== 'function') return undefined
969
- try {
970
- return svc.get(NS)
971
- } catch (err) {
972
- console.warn('[dsh-clinebot] configForms.get failed:', err)
973
- return undefined
974
- }
1259
+ try { return readConfigForms(ctx)?.get?.(NS) || undefined } catch { return undefined }
975
1260
  }, [ctx])
976
-
977
- const subscribe = React.useMemo(() => {
978
- return (cb) => {
979
- if (!scope?.subscribe) return () => {}
980
- try {
981
- return scope.subscribe(cb) || (() => {})
982
- } catch (_) {
983
- return () => {}
984
- }
985
- }
1261
+ const subscribe = React.useMemo(() => (cb) => {
1262
+ try { return scope?.subscribe ? (scope.subscribe(cb) || (() => {})) : () => {} } catch { return () => {} }
986
1263
  }, [scope])
987
-
988
1264
  const getSnapshot = React.useCallback(() => {
989
- if (!scope?.getSnapshot) return SNAPSHOT_READY
990
- try {
991
- return scope.getSnapshot() || SNAPSHOT_READY
992
- } catch (err) {
993
- console.warn('[dsh-clinebot] settings snapshot failed:', err)
994
- return SNAPSHOT_READY
995
- }
1265
+ try { return scope?.getSnapshot?.() || SNAPSHOT_READY } catch { return SNAPSHOT_READY }
996
1266
  }, [scope])
1267
+ return React.useSyncExternalStore(subscribe, getSnapshot, () => SNAPSHOT_READY)?.status || 'loading'
1268
+ }
997
1269
 
998
- const snapshot = React.useSyncExternalStore(
999
- subscribe,
1000
- getSnapshot,
1001
- React.useCallback(() => SNAPSHOT_READY, [])
1002
- )
1003
- 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)
1004
1274
 
1005
1275
  const [status, setStatus] = React.useState(null)
1006
1276
  const [draft, setDraft] = React.useState(null)
@@ -1009,7 +1279,6 @@ function SettingsPage(props) {
1009
1279
  const [msg, setMsg] = React.useState('')
1010
1280
  const [smokeResult, setSmokeResult] = React.useState(null)
1011
1281
 
1012
- // Plugin in-app updater state
1013
1282
  const [updateState, setUpdateState] = React.useState({
1014
1283
  checking: false,
1015
1284
  updating: false,
@@ -1021,13 +1290,10 @@ function SettingsPage(props) {
1021
1290
  notice: '',
1022
1291
  })
1023
1292
 
1024
- // Key input state
1025
1293
  const [apiKeyInput, setApiKeyInput] = React.useState('')
1026
1294
  const [showKey, setShowKey] = React.useState(false)
1027
1295
 
1028
- React.useEffect(() => {
1029
- ensureCss()
1030
- }, [])
1296
+ React.useEffect(() => { ensureCss() }, [])
1031
1297
 
1032
1298
  const load = React.useCallback(async () => {
1033
1299
  setErr('')
@@ -1061,9 +1327,7 @@ function SettingsPage(props) {
1061
1327
  }
1062
1328
  }, [])
1063
1329
 
1064
- React.useEffect(() => {
1065
- checkUpdate()
1066
- }, [checkUpdate])
1330
+ React.useEffect(() => { checkUpdate() }, [checkUpdate])
1067
1331
 
1068
1332
  async function handleTriggerUpdate() {
1069
1333
  if (updateState.updating) return
@@ -1074,9 +1338,7 @@ function SettingsPage(props) {
1074
1338
  headers: { 'x-dsh-plugin-update': '1' },
1075
1339
  })
1076
1340
  const data = await res.json().catch(() => ({}))
1077
- if (!res.ok || data.ok === false || data.error) {
1078
- throw new Error(data.error || `HTTP ${res.status}`)
1079
- }
1341
+ if (!res.ok || data.ok === false || data.error) throw new Error(data.error || `HTTP ${res.status}`)
1080
1342
  const newVer = data.updatedVersion || updateState.latestVersion || updateState.currentVersion
1081
1343
  setUpdateState((s) => ({
1082
1344
  ...s,
@@ -1087,24 +1349,27 @@ function SettingsPage(props) {
1087
1349
  }))
1088
1350
  setTimeout(() => checkUpdate(), 2000)
1089
1351
  } catch (err) {
1090
- setUpdateState((s) => ({
1091
- ...s,
1092
- updating: false,
1093
- error: t('update.failed', { error: String(err.message || err) }),
1094
- }))
1352
+ setUpdateState((s) => ({ ...s, updating: false, error: t('update.failed', { error: String(err.message || err) }) }))
1095
1353
  }
1096
1354
  }
1097
1355
 
1098
- async function handleSaveKey() {
1099
- const keyVal = String(apiKeyInput || '').trim()
1100
- if (!keyVal) {
1101
- setErr(t('key.empty_err'))
1102
- return
1103
- }
1104
- setBusy('save-key')
1356
+ async function performAction(busyKey, fn) {
1357
+ setBusy(busyKey)
1105
1358
  setErr('')
1106
1359
  setMsg('')
1107
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 () => {
1108
1373
  const res = await fetch(`${ROUTE_PREFIX}/save-key`, {
1109
1374
  method: 'POST',
1110
1375
  headers: { 'Content-Type': 'application/json' },
@@ -1115,87 +1380,53 @@ function SettingsPage(props) {
1115
1380
  setApiKeyInput('')
1116
1381
  setMsg(t('key.saved_msg', { status: data.validated ? 'OK' : 'Notice (check console)' }))
1117
1382
  await load()
1118
- } catch (e) {
1119
- setErr(String(e.message || e))
1120
- } finally {
1121
- setBusy('')
1122
- }
1383
+ })
1123
1384
  }
1124
1385
 
1125
1386
  async function handleRefreshQuota() {
1126
- setBusy('refresh-quota')
1127
- setErr('')
1128
- setMsg('')
1129
- try {
1387
+ await performAction('refresh-quota', async () => {
1130
1388
  const res = await fetch(`${ROUTE_PREFIX}/usage`)
1131
1389
  const data = await res.json().catch(() => ({}))
1132
1390
  if (!res.ok || !data.ok) throw new Error(data.error || `HTTP ${res.status}`)
1133
1391
  setStatus((prev) => (prev ? { ...prev, usage: data } : prev))
1134
1392
  setMsg(t('quota.refreshed_msg'))
1135
- } catch (e) {
1136
- setErr(String(e.message || e))
1137
- } finally {
1138
- setBusy('')
1139
- }
1393
+ })
1140
1394
  }
1141
1395
 
1142
1396
  async function handleSyncPlanModels() {
1143
- setBusy('sync-models')
1144
- setErr('')
1145
- setMsg('')
1146
- try {
1397
+ await performAction('sync-models', async () => {
1147
1398
  const res = await fetch(`${ROUTE_PREFIX}/models/sync`, { method: 'POST' })
1148
1399
  const data = await res.json().catch(() => ({}))
1149
1400
  if (!res.ok || !data.ok) throw new Error(data.error || `HTTP ${res.status}`)
1150
1401
  setMsg(t('models.synced_msg', { total: data.totalModelsCount, discovered: data.discoveredCount }))
1151
1402
  await load()
1152
- } catch (e) {
1153
- setErr(String(e.message || e))
1154
- } finally {
1155
- setBusy('')
1156
- }
1403
+ })
1157
1404
  }
1158
1405
 
1159
1406
  async function handleRegister() {
1160
- setBusy('register')
1161
- setErr('')
1162
- setMsg('')
1163
- try {
1407
+ await performAction('register', async () => {
1164
1408
  const res = await fetch(`${ROUTE_PREFIX}/register`, { method: 'POST' })
1165
1409
  const data = await res.json().catch(() => ({}))
1166
1410
  if (!res.ok || !data.ok) throw new Error(data.error || `HTTP ${res.status}`)
1167
1411
  const count = status?.availableModels?.filter((m) => !(draft?.disabledModels || []).includes(m.id)).length || 0
1168
1412
  setMsg(t('diag.resynced_msg', { count }))
1169
1413
  await load()
1170
- } catch (e) {
1171
- setErr(String(e.message || e))
1172
- } finally {
1173
- setBusy('')
1174
- }
1414
+ })
1175
1415
  }
1176
1416
 
1177
1417
  async function handleUnregister() {
1178
- setBusy('unregister')
1179
- setErr('')
1180
- setMsg('')
1181
- try {
1418
+ await performAction('unregister', async () => {
1182
1419
  const res = await fetch(`${ROUTE_PREFIX}/unregister`, { method: 'POST' })
1183
1420
  const data = await res.json().catch(() => ({}))
1184
1421
  if (!res.ok || !data.ok) throw new Error(data.error || `HTTP ${res.status}`)
1185
1422
  setMsg(t('diag.unregistered_msg'))
1186
1423
  await load()
1187
- } catch (e) {
1188
- setErr(String(e.message || e))
1189
- } finally {
1190
- setBusy('')
1191
- }
1424
+ })
1192
1425
  }
1193
1426
 
1194
1427
  async function handleSmoke() {
1195
- setBusy('smoke')
1196
- setErr('')
1197
1428
  setSmokeResult(null)
1198
- try {
1429
+ await performAction('smoke', async () => {
1199
1430
  const res = await fetch(`${ROUTE_PREFIX}/smoke`, {
1200
1431
  method: 'POST',
1201
1432
  headers: { 'Content-Type': 'application/json' },
@@ -1204,11 +1435,7 @@ function SettingsPage(props) {
1204
1435
  const data = await res.json().catch(() => ({}))
1205
1436
  if (!res.ok || !data.ok) throw new Error(data.error || `HTTP ${res.status}`)
1206
1437
  setSmokeResult(data)
1207
- } catch (e) {
1208
- setErr(String(e.message || e))
1209
- } finally {
1210
- setBusy('')
1211
- }
1438
+ })
1212
1439
  }
1213
1440
 
1214
1441
  const [verifyStatus, setVerifyStatus] = React.useState({ state: 'idle', email: '', plan: '', error: '' })
@@ -1249,9 +1476,7 @@ function SettingsPage(props) {
1249
1476
  }
1250
1477
 
1251
1478
  async function handlePinAccount(accountEnv) {
1252
- setBusy('pin-account')
1253
- setErr('')
1254
- try {
1479
+ await performAction('pin-account', async () => {
1255
1480
  const res = await fetch(`${ROUTE_PREFIX}/accounts/active`, {
1256
1481
  method: 'POST',
1257
1482
  headers: { 'Content-Type': 'application/json' },
@@ -1260,25 +1485,65 @@ function SettingsPage(props) {
1260
1485
  const data = await res.json().catch(() => ({}))
1261
1486
  if (!res.ok || !data.ok) throw new Error(data.error || `HTTP ${res.status}`)
1262
1487
  await load()
1263
- } catch (e) {
1264
- setErr(String(e.message || e))
1265
- } finally {
1266
- setBusy('')
1267
- }
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
+ })
1268
1516
  }
1269
1517
 
1270
1518
  const debounceTimerRef = typeof React.useRef === 'function' ? React.useRef(null) : { current: null }
1271
- function debounceSaveDisabledModels(nextDisabled) {
1519
+
1520
+ React.useEffect(() => {
1521
+ return () => {
1522
+ if (debounceTimerRef.current) clearTimeout(debounceTimerRef.current)
1523
+ }
1524
+ }, [])
1525
+
1526
+ function debounceSaveDisabledModels(nextDisabled, previousDisabled) {
1272
1527
  if (debounceTimerRef.current) clearTimeout(debounceTimerRef.current)
1273
1528
  debounceTimerRef.current = setTimeout(async () => {
1274
1529
  try {
1275
- await fetch(`${ROUTE_PREFIX}/models/toggle`, {
1530
+ const res = await fetch(`${ROUTE_PREFIX}/models/toggle`, {
1276
1531
  method: 'POST',
1277
1532
  headers: { 'Content-Type': 'application/json' },
1278
1533
  body: JSON.stringify({ disabledModels: nextDisabled }),
1279
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()
1280
1538
  } catch (err) {
1281
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
+ })
1282
1547
  }
1283
1548
  }, 400)
1284
1549
  }
@@ -1290,7 +1555,7 @@ function SettingsPage(props) {
1290
1555
  const all = status?.availableModels || []
1291
1556
  const enabledIds = all.map((m) => m.id).filter((mId) => !next.includes(mId))
1292
1557
  setDraft({ ...draft, disabledModels: next, enabledModels: enabledIds })
1293
- debounceSaveDisabledModels(next)
1558
+ debounceSaveDisabledModels(next, curr)
1294
1559
  }
1295
1560
 
1296
1561
  function handleSetModelsFilter(type) {
@@ -1306,10 +1571,11 @@ function SettingsPage(props) {
1306
1571
  allowed = new Set(all.filter((m) => m.recommended).map((m) => m.id))
1307
1572
  }
1308
1573
 
1574
+ const curr = draft?.disabledModels || []
1309
1575
  const nextDisabled = all.map((m) => m.id).filter((id) => !allowed.has(id))
1310
1576
  const nextEnabled = Array.from(allowed)
1311
1577
  setDraft({ ...draft, disabledModels: nextDisabled, enabledModels: nextEnabled })
1312
- debounceSaveDisabledModels(nextDisabled)
1578
+ debounceSaveDisabledModels(nextDisabled, curr)
1313
1579
  }
1314
1580
 
1315
1581
  if (!status || !draft) {
@@ -1378,6 +1644,11 @@ function SettingsPage(props) {
1378
1644
  // In-app Update Bar
1379
1645
  React.createElement(UpdateBanner, { updateState, handleTriggerUpdate, t }),
1380
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
+
1381
1652
  // Notifications
1382
1653
  err ? React.createElement('div', { className: 'cb-alert-bad' }, err) : null,
1383
1654
  msg ? React.createElement('div', { className: 'cb-alert-ok' }, msg) : null,
@@ -1398,7 +1669,14 @@ function SettingsPage(props) {
1398
1669
  }),
1399
1670
 
1400
1671
  // Accounts Pool Card
1401
- React.createElement(AccountsSection, { status, busy, handlePinAccount, t }),
1672
+ React.createElement(AccountsSection, {
1673
+ status,
1674
+ busy,
1675
+ handlePinAccount,
1676
+ handleAddAccount,
1677
+ handleDeleteAccount,
1678
+ t,
1679
+ }),
1402
1680
 
1403
1681
  // Quota Warning & Dashboard Card
1404
1682
  React.createElement(QuotaSection, { keyPresent, status, usage, busy, handleRefreshQuota, t }),