@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/CHANGELOG.md +22 -0
- package/lib/client.js +415 -137
- package/lib/cline-client.js +20 -1
- package/lib/index.js +1 -3
- package/lib/models.js +10 -3
- package/lib/provider-sync.js +56 -20
- package/lib/routes/accounts.js +170 -2
- package/lib/routes/auth.js +15 -1
- package/lib/routes/models.js +1 -1
- package/lib/routes/settings.js +35 -3
- package/lib/slash-command.js +128 -64
- package/package.json +1 -1
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
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
|
|
1099
|
-
|
|
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
|
-
}
|
|
1119
|
-
setErr(String(e.message || e))
|
|
1120
|
-
} finally {
|
|
1121
|
-
setBusy('')
|
|
1122
|
-
}
|
|
1383
|
+
})
|
|
1123
1384
|
}
|
|
1124
1385
|
|
|
1125
1386
|
async function handleRefreshQuota() {
|
|
1126
|
-
|
|
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
|
-
}
|
|
1136
|
-
setErr(String(e.message || e))
|
|
1137
|
-
} finally {
|
|
1138
|
-
setBusy('')
|
|
1139
|
-
}
|
|
1393
|
+
})
|
|
1140
1394
|
}
|
|
1141
1395
|
|
|
1142
1396
|
async function handleSyncPlanModels() {
|
|
1143
|
-
|
|
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
|
-
}
|
|
1153
|
-
setErr(String(e.message || e))
|
|
1154
|
-
} finally {
|
|
1155
|
-
setBusy('')
|
|
1156
|
-
}
|
|
1403
|
+
})
|
|
1157
1404
|
}
|
|
1158
1405
|
|
|
1159
1406
|
async function handleRegister() {
|
|
1160
|
-
|
|
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
|
-
}
|
|
1171
|
-
setErr(String(e.message || e))
|
|
1172
|
-
} finally {
|
|
1173
|
-
setBusy('')
|
|
1174
|
-
}
|
|
1414
|
+
})
|
|
1175
1415
|
}
|
|
1176
1416
|
|
|
1177
1417
|
async function handleUnregister() {
|
|
1178
|
-
|
|
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
|
-
}
|
|
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
|
-
|
|
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
|
-
}
|
|
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
|
-
|
|
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
|
-
}
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
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
|
-
|
|
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, {
|
|
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 }),
|