@nextclaw/ui 0.5.29 → 0.5.30

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.
Files changed (35) hide show
  1. package/CHANGELOG.md +6 -0
  2. package/dist/assets/ChannelsList-DKiedAFa.js +1 -0
  3. package/dist/assets/{ChatPage-DI2euxZy.js → ChatPage-BwNLpkSy.js} +1 -1
  4. package/dist/assets/{CronConfig-DAlt-x5i.js → CronConfig-DS4SLvdJ.js} +1 -1
  5. package/dist/assets/{DocBrowser-TrMsdXgx.js → DocBrowser-GX0i3zMz.js} +1 -1
  6. package/dist/assets/{MarketplacePage-Dwm527F7.js → MarketplacePage-Cfary3pL.js} +1 -1
  7. package/dist/assets/{ModelConfig-srggzgfA.js → ModelConfig-DqA1Fpq1.js} +1 -1
  8. package/dist/assets/ProvidersList-Bp8j2dHz.js +1 -0
  9. package/dist/assets/{RuntimeConfig-CLbdKAlo.js → RuntimeConfig-BPFqqOmo.js} +1 -1
  10. package/dist/assets/{SecretsConfig-DXCdR0Be.js → SecretsConfig-817qoOc8.js} +1 -1
  11. package/dist/assets/{SessionsConfig-iKpz3Sts.js → SessionsConfig-DSz76nPB.js} +1 -1
  12. package/dist/assets/{card-CVj65Dvi.js → card-DsXZnE66.js} +1 -1
  13. package/dist/assets/{dialog-lK79rlAw.js → dialog-CM-OB-VU.js} +2 -2
  14. package/dist/assets/index-BDnLuH5V.js +2 -0
  15. package/dist/assets/index-yypHrk9r.css +1 -0
  16. package/dist/assets/{label-l-fECYi3.js → label-BryQW6Hh.js} +1 -1
  17. package/dist/assets/logos-DpQfWN7T.js +1 -0
  18. package/dist/assets/{page-layout-BghxFaNt.js → page-layout-CljvdClt.js} +1 -1
  19. package/dist/assets/{switch-B4yFzIbc.js → switch-DK9xeFoj.js} +1 -1
  20. package/dist/assets/{tabs-custom-B4q02QSV.js → tabs-custom-BWSgRC2i.js} +1 -1
  21. package/dist/assets/{useConfig-C9k3TmQk.js → useConfig-Cr9T194r.js} +1 -1
  22. package/dist/assets/{useConfirmDialog-C20D5SYn.js → useConfirmDialog-CeUaLKWy.js} +1 -1
  23. package/dist/assets/{vendor-H2M3a_4Z.js → vendor-CrNxGKd2.js} +77 -72
  24. package/dist/index.html +3 -3
  25. package/package.json +1 -1
  26. package/src/components/config/ProviderForm.tsx +198 -143
  27. package/src/components/config/ProvidersList.tsx +140 -80
  28. package/src/components/ui/status-dot.tsx +1 -1
  29. package/src/lib/i18n.ts +10 -1
  30. package/src/stores/ui.store.ts +0 -9
  31. package/dist/assets/ChannelsList-DEr4kE7H.js +0 -1
  32. package/dist/assets/ProvidersList-8kFCDiqC.js +0 -1
  33. package/dist/assets/action-link-w4jS8X9q.js +0 -1
  34. package/dist/assets/index-BXgULtdk.js +0 -2
  35. package/dist/assets/index-B_OeEGic.css +0 -1
package/dist/index.html CHANGED
@@ -6,9 +6,9 @@
6
6
  <link rel="icon" type="image/svg+xml" href="/logo.svg" />
7
7
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
8
8
  <title>NextClaw - 系统配置</title>
9
- <script type="module" crossorigin src="/assets/index-BXgULtdk.js"></script>
10
- <link rel="modulepreload" crossorigin href="/assets/vendor-H2M3a_4Z.js">
11
- <link rel="stylesheet" crossorigin href="/assets/index-B_OeEGic.css">
9
+ <script type="module" crossorigin src="/assets/index-BDnLuH5V.js"></script>
10
+ <link rel="modulepreload" crossorigin href="/assets/vendor-CrNxGKd2.js">
11
+ <link rel="stylesheet" crossorigin href="/assets/index-yypHrk9r.css">
12
12
  </head>
13
13
 
14
14
  <body>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nextclaw/ui",
3
- "version": "0.5.29",
3
+ "version": "0.5.30",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -1,27 +1,57 @@
1
- import { useEffect, useState } from 'react';
1
+ import { useEffect, useMemo, useState } from 'react';
2
2
  import { useConfig, useConfigMeta, useConfigSchema, useUpdateProvider } from '@/hooks/useConfig';
3
- import { useUiStore } from '@/stores/ui.store';
4
- import {
5
- Dialog,
6
- DialogContent,
7
- DialogHeader,
8
- DialogTitle,
9
- DialogDescription,
10
- DialogFooter,
11
- } from '@/components/ui/dialog';
12
3
  import { Button } from '@/components/ui/button';
13
4
  import { Input } from '@/components/ui/input';
14
5
  import { Label } from '@/components/ui/label';
15
6
  import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
16
7
  import { MaskedInput } from '@/components/common/MaskedInput';
17
8
  import { KeyValueEditor } from '@/components/common/KeyValueEditor';
9
+ import { StatusDot } from '@/components/ui/status-dot';
18
10
  import { t } from '@/lib/i18n';
19
11
  import { hintForPath } from '@/lib/config-hints';
20
12
  import type { ProviderConfigUpdate } from '@/api/types';
21
- import { KeyRound, Globe, Hash } from 'lucide-react';
13
+ import { KeyRound, Globe, Hash, RotateCcw } from 'lucide-react';
22
14
 
23
- export function ProviderForm() {
24
- const { providerModal, closeProviderModal } = useUiStore();
15
+ type WireApiType = 'auto' | 'chat' | 'responses';
16
+
17
+ type ProviderFormProps = {
18
+ providerName?: string;
19
+ };
20
+
21
+ function normalizeHeaders(input: Record<string, string> | null | undefined): Record<string, string> | null {
22
+ if (!input) {
23
+ return null;
24
+ }
25
+ const entries = Object.entries(input)
26
+ .map(([key, value]) => [key.trim(), value] as const)
27
+ .filter(([key]) => key.length > 0);
28
+ if (entries.length === 0) {
29
+ return null;
30
+ }
31
+ return Object.fromEntries(entries);
32
+ }
33
+
34
+ function headersEqual(
35
+ left: Record<string, string> | null | undefined,
36
+ right: Record<string, string> | null | undefined
37
+ ): boolean {
38
+ const a = normalizeHeaders(left);
39
+ const b = normalizeHeaders(right);
40
+ if (a === null && b === null) {
41
+ return true;
42
+ }
43
+ if (!a || !b) {
44
+ return false;
45
+ }
46
+ const aEntries = Object.entries(a).sort(([ak], [bk]) => ak.localeCompare(bk));
47
+ const bEntries = Object.entries(b).sort(([ak], [bk]) => ak.localeCompare(bk));
48
+ if (aEntries.length !== bEntries.length) {
49
+ return false;
50
+ }
51
+ return aEntries.every(([key, value], index) => key === bEntries[index][0] && value === bEntries[index][1]);
52
+ }
53
+
54
+ export function ProviderForm({ providerName }: ProviderFormProps) {
25
55
  const { data: config } = useConfig();
26
56
  const { data: meta } = useConfigMeta();
27
57
  const { data: schema } = useConfigSchema();
@@ -30,174 +60,199 @@ export function ProviderForm() {
30
60
  const [apiKey, setApiKey] = useState('');
31
61
  const [apiBase, setApiBase] = useState('');
32
62
  const [extraHeaders, setExtraHeaders] = useState<Record<string, string> | null>(null);
33
- const [wireApi, setWireApi] = useState<'auto' | 'chat' | 'responses'>('auto');
63
+ const [wireApi, setWireApi] = useState<WireApiType>('auto');
34
64
 
35
- const providerName = providerModal.provider;
36
65
  const providerSpec = meta?.providers.find((p) => p.name === providerName);
37
66
  const providerConfig = providerName ? config?.providers[providerName] : null;
38
67
  const uiHints = schema?.uiHints;
68
+
39
69
  const apiKeyHint = providerName ? hintForPath(`providers.${providerName}.apiKey`, uiHints) : undefined;
40
70
  const apiBaseHint = providerName ? hintForPath(`providers.${providerName}.apiBase`, uiHints) : undefined;
41
71
  const extraHeadersHint = providerName ? hintForPath(`providers.${providerName}.extraHeaders`, uiHints) : undefined;
42
72
  const wireApiHint = providerName ? hintForPath(`providers.${providerName}.wireApi`, uiHints) : undefined;
43
73
 
74
+ const providerTitle = providerSpec?.displayName || providerName || t('providersSelectPlaceholder');
75
+ const defaultApiBase = providerSpec?.defaultApiBase || '';
76
+ const currentApiBase = providerConfig?.apiBase || defaultApiBase;
77
+ const currentHeaders = normalizeHeaders(providerConfig?.extraHeaders || null);
78
+ const currentWireApi = (providerConfig?.wireApi || providerSpec?.defaultWireApi || 'auto') as WireApiType;
79
+
44
80
  useEffect(() => {
45
- if (providerConfig) {
46
- setApiBase(providerConfig.apiBase || providerSpec?.defaultApiBase || '');
47
- setExtraHeaders(providerConfig.extraHeaders || null);
48
- setApiKey(''); // Always start with empty for security
49
- const nextWireApi =
50
- providerConfig.wireApi || providerSpec?.defaultWireApi || 'auto';
51
- setWireApi(nextWireApi as 'auto' | 'chat' | 'responses');
81
+ if (!providerName) {
82
+ setApiKey('');
83
+ setApiBase('');
84
+ setExtraHeaders(null);
85
+ setWireApi('auto');
86
+ return;
52
87
  }
53
- }, [providerConfig, providerSpec]);
88
+
89
+ setApiKey('');
90
+ setApiBase(currentApiBase);
91
+ setExtraHeaders(providerConfig?.extraHeaders || null);
92
+ setWireApi(currentWireApi);
93
+ }, [providerName, currentApiBase, providerConfig?.extraHeaders, currentWireApi]);
94
+
95
+ const hasChanges = useMemo(() => {
96
+ if (!providerName) {
97
+ return false;
98
+ }
99
+ const apiKeyChanged = apiKey.trim().length > 0;
100
+ const apiBaseChanged = apiBase.trim() !== currentApiBase.trim();
101
+ const headersChanged = !headersEqual(extraHeaders, currentHeaders);
102
+ const wireApiChanged = providerSpec?.supportsWireApi ? wireApi !== currentWireApi : false;
103
+
104
+ return apiKeyChanged || apiBaseChanged || headersChanged || wireApiChanged;
105
+ }, [
106
+ providerName,
107
+ apiKey,
108
+ apiBase,
109
+ currentApiBase,
110
+ extraHeaders,
111
+ currentHeaders,
112
+ providerSpec?.supportsWireApi,
113
+ wireApi,
114
+ currentWireApi
115
+ ]);
116
+
117
+ const resetToDefault = () => {
118
+ setApiKey('');
119
+ setApiBase(defaultApiBase);
120
+ setExtraHeaders(null);
121
+ setWireApi((providerSpec?.defaultWireApi || 'auto') as WireApiType);
122
+ };
54
123
 
55
124
  const handleSubmit = (e: React.FormEvent) => {
56
125
  e.preventDefault();
57
126
 
127
+ if (!providerName) {
128
+ return;
129
+ }
130
+
58
131
  const payload: ProviderConfigUpdate = {};
132
+ const trimmedApiKey = apiKey.trim();
133
+ const trimmedApiBase = apiBase.trim();
134
+ const normalizedHeaders = normalizeHeaders(extraHeaders);
59
135
 
60
- // Only include apiKey if user has entered something
61
- if (apiKey !== '') {
62
- payload.apiKey = apiKey;
136
+ if (trimmedApiKey.length > 0) {
137
+ payload.apiKey = trimmedApiKey;
63
138
  }
64
139
 
65
- if (apiBase && apiBase !== providerSpec?.defaultApiBase) {
66
- payload.apiBase = apiBase;
140
+ if (trimmedApiBase !== currentApiBase.trim()) {
141
+ payload.apiBase = trimmedApiBase.length > 0 && trimmedApiBase !== defaultApiBase ? trimmedApiBase : null;
67
142
  }
68
143
 
69
- if (extraHeaders && Object.keys(extraHeaders).length > 0) {
70
- payload.extraHeaders = extraHeaders;
144
+ if (!headersEqual(normalizedHeaders, currentHeaders)) {
145
+ payload.extraHeaders = normalizedHeaders;
71
146
  }
72
147
 
73
- if (providerSpec?.supportsWireApi) {
74
- const currentWireApi =
75
- providerConfig?.wireApi || providerSpec.defaultWireApi || 'auto';
76
- if (wireApi !== currentWireApi) {
77
- payload.wireApi = wireApi;
78
- }
148
+ if (providerSpec?.supportsWireApi && wireApi !== currentWireApi) {
149
+ payload.wireApi = wireApi;
79
150
  }
80
151
 
81
- if (!providerName) return;
152
+ updateProvider.mutate({ provider: providerName, data: payload });
153
+ };
82
154
 
83
- updateProvider.mutate(
84
- { provider: providerName, data: payload },
85
- { onSuccess: () => closeProviderModal() }
155
+ if (!providerName || !providerSpec || !providerConfig) {
156
+ return (
157
+ <div className="flex min-h-[520px] items-center justify-center rounded-2xl border border-gray-200/70 bg-white px-6 text-center xl:h-[calc(100vh-180px)] xl:min-h-[600px] xl:max-h-[860px]">
158
+ <div>
159
+ <h3 className="text-base font-semibold text-gray-900">{t('providersSelectTitle')}</h3>
160
+ <p className="mt-2 text-sm text-gray-500">{t('providersSelectDescription')}</p>
161
+ </div>
162
+ </div>
86
163
  );
87
- };
164
+ }
165
+
166
+ const statusLabel = providerConfig.apiKeySet ? t('statusReady') : t('statusSetup');
88
167
 
89
168
  return (
90
- <Dialog open={providerModal.open} onOpenChange={closeProviderModal}>
91
- <DialogContent className="sm:max-w-[500px]">
92
- <DialogHeader>
93
- <div className="flex items-center gap-3">
94
- <div className="h-10 w-10 rounded-xl bg-primary flex items-center justify-center">
95
- <KeyRound className="h-5 w-5 text-white" />
96
- </div>
97
- <div>
98
- <DialogTitle>{providerSpec?.displayName || providerName}</DialogTitle>
99
- <DialogDescription>{t('providerFormDescription')}</DialogDescription>
100
- </div>
169
+ <div className="flex min-h-[520px] flex-col rounded-2xl border border-gray-200/70 bg-white shadow-card xl:h-[calc(100vh-180px)] xl:min-h-[600px] xl:max-h-[860px]">
170
+ <div className="border-b border-gray-100 px-6 py-5">
171
+ <div className="flex flex-wrap items-center justify-between gap-3">
172
+ <div className="min-w-0">
173
+ <h3 className="truncate text-lg font-semibold text-gray-900">{providerTitle}</h3>
174
+ <p className="mt-1 text-sm text-gray-500">{t('providerFormDescription')}</p>
101
175
  </div>
102
- </DialogHeader>
176
+ <StatusDot status={providerConfig.apiKeySet ? 'ready' : 'setup'} label={statusLabel} />
177
+ </div>
178
+ </div>
103
179
 
104
- <form onSubmit={handleSubmit} className="flex flex-col pt-2">
105
- <div className="space-y-5 max-h-[60vh] overflow-y-auto pr-1">
106
- <div className="space-y-2.5">
107
- <Label htmlFor="apiKey" className="text-sm font-medium text-gray-900 flex items-center gap-2">
108
- <KeyRound className="h-3.5 w-3.5 text-gray-500" />
109
- {apiKeyHint?.label ?? t('apiKey')}
110
- </Label>
111
- <MaskedInput
112
- id="apiKey"
113
- value={apiKey}
114
- isSet={providerConfig?.apiKeySet}
115
- onChange={(e) => setApiKey(e.target.value)}
116
- placeholder={
117
- providerConfig?.apiKeySet
118
- ? t('apiKeySet')
119
- : apiKeyHint?.placeholder ?? t('enterApiKey')
120
- }
121
- className="rounded-xl"
122
- />
123
- </div>
124
-
125
- <div className="space-y-2.5">
126
- <Label htmlFor="apiBase" className="text-sm font-medium text-gray-900 flex items-center gap-2">
127
- <Globe className="h-3.5 w-3.5 text-gray-500" />
128
- {apiBaseHint?.label ?? t('apiBase')}
129
- </Label>
130
- <Input
131
- id="apiBase"
132
- type="text"
133
- value={apiBase}
134
- onChange={(e) => setApiBase(e.target.value)}
135
- placeholder={
136
- providerSpec?.defaultApiBase ||
137
- apiBaseHint?.placeholder ||
138
- 'https://api.example.com'
139
- }
140
- className="rounded-xl"
141
- />
142
- {apiBaseHint?.help && (
143
- <p className="text-xs text-gray-500">{apiBaseHint.help}</p>
144
- )}
145
- </div>
180
+ <form onSubmit={handleSubmit} className="flex min-h-0 flex-1 flex-col">
181
+ <div className="min-h-0 flex-1 space-y-6 overflow-y-auto px-6 py-5">
182
+ <div className="space-y-2.5">
183
+ <Label htmlFor="apiKey" className="flex items-center gap-2 text-sm font-medium text-gray-900">
184
+ <KeyRound className="h-3.5 w-3.5 text-gray-500" />
185
+ {apiKeyHint?.label ?? t('apiKey')}
186
+ </Label>
187
+ <MaskedInput
188
+ id="apiKey"
189
+ value={apiKey}
190
+ isSet={providerConfig.apiKeySet}
191
+ onChange={(e) => setApiKey(e.target.value)}
192
+ placeholder={providerConfig.apiKeySet ? t('apiKeySet') : apiKeyHint?.placeholder ?? t('enterApiKey')}
193
+ className="rounded-xl"
194
+ />
195
+ <p className="text-xs text-gray-500">{t('leaveBlankToKeepUnchanged')}</p>
196
+ </div>
146
197
 
147
- {providerSpec?.supportsWireApi && (
148
- <div className="space-y-2.5">
149
- <Label htmlFor="wireApi" className="text-sm font-medium text-gray-900 flex items-center gap-2">
150
- <Hash className="h-3.5 w-3.5 text-gray-500" />
151
- {wireApiHint?.label ?? t('wireApi')}
152
- </Label>
153
- <Select value={wireApi} onValueChange={(v) => setWireApi(v as 'auto' | 'chat' | 'responses')}>
154
- <SelectTrigger className="rounded-xl">
155
- <SelectValue />
156
- </SelectTrigger>
157
- <SelectContent>
158
- {(providerSpec.wireApiOptions || ['auto', 'chat', 'responses']).map((option) => (
159
- <SelectItem key={option} value={option}>
160
- {option === 'chat'
161
- ? t('wireApiChat')
162
- : option === 'responses'
163
- ? t('wireApiResponses')
164
- : t('wireApiAuto')}
165
- </SelectItem>
166
- ))}
167
- </SelectContent>
168
- </Select>
169
- </div>
170
- )}
198
+ <div className="space-y-2.5">
199
+ <Label htmlFor="apiBase" className="flex items-center gap-2 text-sm font-medium text-gray-900">
200
+ <Globe className="h-3.5 w-3.5 text-gray-500" />
201
+ {apiBaseHint?.label ?? t('apiBase')}
202
+ </Label>
203
+ <Input
204
+ id="apiBase"
205
+ type="text"
206
+ value={apiBase}
207
+ onChange={(e) => setApiBase(e.target.value)}
208
+ placeholder={defaultApiBase || apiBaseHint?.placeholder || 'https://api.example.com'}
209
+ className="rounded-xl"
210
+ />
211
+ <p className="text-xs text-gray-500">{apiBaseHint?.help || t('providerApiBaseHelp')}</p>
212
+ </div>
171
213
 
214
+ {providerSpec.supportsWireApi && (
172
215
  <div className="space-y-2.5">
173
- <Label className="text-sm font-medium text-gray-900 flex items-center gap-2">
216
+ <Label htmlFor="wireApi" className="flex items-center gap-2 text-sm font-medium text-gray-900">
174
217
  <Hash className="h-3.5 w-3.5 text-gray-500" />
175
- {extraHeadersHint?.label ?? t('extraHeaders')}
218
+ {wireApiHint?.label ?? t('wireApi')}
176
219
  </Label>
177
- <KeyValueEditor
178
- value={extraHeaders}
179
- onChange={setExtraHeaders}
180
- />
220
+ <Select value={wireApi} onValueChange={(v) => setWireApi(v as WireApiType)}>
221
+ <SelectTrigger className="rounded-xl">
222
+ <SelectValue />
223
+ </SelectTrigger>
224
+ <SelectContent>
225
+ {(providerSpec.wireApiOptions || ['auto', 'chat', 'responses']).map((option) => (
226
+ <SelectItem key={option} value={option}>
227
+ {option === 'chat' ? t('wireApiChat') : option === 'responses' ? t('wireApiResponses') : t('wireApiAuto')}
228
+ </SelectItem>
229
+ ))}
230
+ </SelectContent>
231
+ </Select>
232
+ {wireApiHint?.help && <p className="text-xs text-gray-500">{wireApiHint.help}</p>}
181
233
  </div>
234
+ )}
235
+
236
+ <div className="space-y-2.5">
237
+ <Label className="flex items-center gap-2 text-sm font-medium text-gray-900">
238
+ <Hash className="h-3.5 w-3.5 text-gray-500" />
239
+ {extraHeadersHint?.label ?? t('extraHeaders')}
240
+ </Label>
241
+ <KeyValueEditor value={extraHeaders} onChange={setExtraHeaders} />
242
+ <p className="text-xs text-gray-500">{extraHeadersHint?.help || t('providerExtraHeadersHelp')}</p>
182
243
  </div>
244
+ </div>
183
245
 
184
- <DialogFooter className="pt-4 flex-shrink-0">
185
- <Button
186
- type="button"
187
- variant="outline"
188
- onClick={closeProviderModal}
189
- >
190
- {t('cancel')}
191
- </Button>
192
- <Button
193
- type="submit"
194
- disabled={updateProvider.isPending}
195
- >
196
- {updateProvider.isPending ? t('saving') : t('save')}
197
- </Button>
198
- </DialogFooter>
199
- </form>
200
- </DialogContent>
201
- </Dialog>
246
+ <div className="flex flex-wrap items-center justify-between gap-3 border-t border-gray-100 px-6 py-4">
247
+ <Button type="button" variant="outline" onClick={resetToDefault}>
248
+ <RotateCcw className="mr-2 h-4 w-4" />
249
+ {t('resetToDefault')}
250
+ </Button>
251
+ <Button type="submit" disabled={updateProvider.isPending || !hasChanges}>
252
+ {updateProvider.isPending ? t('saving') : hasChanges ? t('save') : t('unchanged')}
253
+ </Button>
254
+ </div>
255
+ </form>
256
+ </div>
202
257
  );
203
258
  }