@nextclaw/ui 0.5.28 → 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 (38) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/dist/assets/ChannelsList-DKiedAFa.js +1 -0
  3. package/dist/assets/{ChatPage-CjzR-76f.js → ChatPage-BwNLpkSy.js} +16 -16
  4. package/dist/assets/{CronConfig-DREWLzKD.js → CronConfig-DS4SLvdJ.js} +1 -1
  5. package/dist/assets/{DocBrowser-fXdmzwRi.js → DocBrowser-GX0i3zMz.js} +1 -1
  6. package/dist/assets/{MarketplacePage-zKC-b_O_.js → MarketplacePage-Cfary3pL.js} +1 -1
  7. package/dist/assets/{ModelConfig-BdogzFBq.js → ModelConfig-DqA1Fpq1.js} +1 -1
  8. package/dist/assets/ProvidersList-Bp8j2dHz.js +1 -0
  9. package/dist/assets/{RuntimeConfig-CGsoLtsV.js → RuntimeConfig-BPFqqOmo.js} +1 -1
  10. package/dist/assets/{SecretsConfig-B8ZDpRgB.js → SecretsConfig-817qoOc8.js} +1 -1
  11. package/dist/assets/{SessionsConfig-CcpfGazP.js → SessionsConfig-DSz76nPB.js} +1 -1
  12. package/dist/assets/{card-Bt8T3JA3.js → card-DsXZnE66.js} +1 -1
  13. package/dist/assets/{dialog-BNi5ymWD.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-BDCnjFl3.js → label-BryQW6Hh.js} +1 -1
  17. package/dist/assets/logos-DpQfWN7T.js +1 -0
  18. package/dist/assets/{page-layout-DWVnm0X8.js → page-layout-CljvdClt.js} +1 -1
  19. package/dist/assets/{switch-CrmdAOBw.js → switch-DK9xeFoj.js} +1 -1
  20. package/dist/assets/{tabs-custom-WJxQwDQy.js → tabs-custom-BWSgRC2i.js} +1 -1
  21. package/dist/assets/useConfig-Cr9T194r.js +6 -0
  22. package/dist/assets/{useConfirmDialog-C5hEiEeb.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/chat/ChatPage.tsx +34 -4
  27. package/src/components/config/ProviderForm.tsx +198 -143
  28. package/src/components/config/ProvidersList.tsx +140 -80
  29. package/src/components/ui/status-dot.tsx +1 -1
  30. package/src/hooks/useConfig.ts +2 -1
  31. package/src/lib/i18n.ts +10 -1
  32. package/src/stores/ui.store.ts +0 -9
  33. package/dist/assets/ChannelsList-DgkIu7_t.js +0 -1
  34. package/dist/assets/ProvidersList-BLScOe9j.js +0 -1
  35. package/dist/assets/action-link-RjYHzlQk.js +0 -1
  36. package/dist/assets/index-B_OeEGic.css +0 -1
  37. package/dist/assets/index-CtNWUrVR.js +0 -2
  38. package/dist/assets/useConfig-COoN7EVf.js +0 -6
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-CtNWUrVR.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.28",
3
+ "version": "0.5.30",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -94,10 +94,12 @@ export function ChatPage() {
94
94
  const [draft, setDraft] = useState('');
95
95
  const [selectedSessionKey, setSelectedSessionKey] = useState<string | null>(() => readStoredSessionKey());
96
96
  const [selectedAgentId, setSelectedAgentId] = useState('main');
97
+ const [optimisticUserEvent, setOptimisticUserEvent] = useState<SessionEventView | null>(null);
97
98
  const [streamingSessionEvents, setStreamingSessionEvents] = useState<SessionEventView[]>([]);
98
99
  const [streamingAssistantText, setStreamingAssistantText] = useState('');
99
100
  const [streamingAssistantTimestamp, setStreamingAssistantTimestamp] = useState<string | null>(null);
100
101
  const [isSending, setIsSending] = useState(false);
102
+ const [isAwaitingAssistantOutput, setIsAwaitingAssistantOutput] = useState(false);
101
103
  const [queuedMessages, setQueuedMessages] = useState<PendingChatMessage[]>([]);
102
104
 
103
105
  const { confirm, ConfirmDialog } = useConfirmDialog();
@@ -152,7 +154,11 @@ export function ChatPage() {
152
154
  ? historyData.events
153
155
  : buildFallbackEventsFromMessages(historyMessages);
154
156
  const mergedEvents = useMemo(() => {
155
- const next = [...historyEvents, ...streamingSessionEvents];
157
+ const next = [...historyEvents];
158
+ if (optimisticUserEvent) {
159
+ next.push(optimisticUserEvent);
160
+ }
161
+ next.push(...streamingSessionEvents);
156
162
  if (streamingAssistantText.trim()) {
157
163
  const maxSeq = next.reduce((max, event) => {
158
164
  const seq = Number.isFinite(event.seq) ? event.seq : 0;
@@ -170,7 +176,7 @@ export function ChatPage() {
170
176
  });
171
177
  }
172
178
  return next;
173
- }, [historyEvents, streamingAssistantText, streamingAssistantTimestamp, streamingSessionEvents]);
179
+ }, [historyEvents, optimisticUserEvent, streamingAssistantText, streamingAssistantTimestamp, streamingSessionEvents]);
174
180
 
175
181
  useEffect(() => {
176
182
  if (!selectedSessionKey && filteredSessions.length > 0) {
@@ -214,9 +220,11 @@ export function ChatPage() {
214
220
  streamRunIdRef.current += 1;
215
221
  setIsSending(false);
216
222
  setQueuedMessages([]);
223
+ setOptimisticUserEvent(null);
217
224
  setStreamingSessionEvents([]);
218
225
  setStreamingAssistantText('');
219
226
  setStreamingAssistantTimestamp(null);
227
+ setIsAwaitingAssistantOutput(false);
220
228
  const next = buildNewSessionKey(selectedAgentId);
221
229
  setSelectedSessionKey(next);
222
230
  };
@@ -240,9 +248,11 @@ export function ChatPage() {
240
248
  streamRunIdRef.current += 1;
241
249
  setIsSending(false);
242
250
  setQueuedMessages([]);
251
+ setOptimisticUserEvent(null);
243
252
  setStreamingSessionEvents([]);
244
253
  setStreamingAssistantText('');
245
254
  setStreamingAssistantTimestamp(null);
255
+ setIsAwaitingAssistantOutput(false);
246
256
  setSelectedSessionKey(null);
247
257
  await sessionsQuery.refetch();
248
258
  }
@@ -257,7 +267,18 @@ export function ChatPage() {
257
267
  setStreamingSessionEvents([]);
258
268
  setStreamingAssistantText('');
259
269
  setStreamingAssistantTimestamp(null);
270
+ setOptimisticUserEvent({
271
+ seq: 0,
272
+ type: 'message.user.optimistic',
273
+ timestamp: new Date().toISOString(),
274
+ message: {
275
+ role: 'user',
276
+ content: item.message,
277
+ timestamp: new Date().toISOString()
278
+ }
279
+ });
260
280
  setIsSending(true);
281
+ setIsAwaitingAssistantOutput(true);
261
282
 
262
283
  try {
263
284
  let streamText = '';
@@ -285,11 +306,15 @@ export function ChatPage() {
285
306
  }
286
307
  streamText += event.delta;
287
308
  setStreamingAssistantText(streamText);
309
+ setIsAwaitingAssistantOutput(false);
288
310
  },
289
311
  onSessionEvent: (event) => {
290
312
  if (runId !== streamRunIdRef.current) {
291
313
  return;
292
314
  }
315
+ if (event.data.message?.role === 'user') {
316
+ setOptimisticUserEvent(null);
317
+ }
293
318
  setStreamingSessionEvents((prev) => {
294
319
  const next = [...prev];
295
320
  const hit = next.findIndex((item) => item.seq === event.data.seq);
@@ -302,12 +327,14 @@ export function ChatPage() {
302
327
  });
303
328
  if (event.data.message?.role === 'assistant') {
304
329
  setStreamingAssistantText('');
330
+ setIsAwaitingAssistantOutput(false);
305
331
  }
306
332
  }
307
333
  });
308
334
  if (runId !== streamRunIdRef.current) {
309
335
  return;
310
336
  }
337
+ setOptimisticUserEvent(null);
311
338
  if (result.sessionKey !== item.sessionKey) {
312
339
  setSelectedSessionKey(result.sessionKey);
313
340
  }
@@ -319,6 +346,7 @@ export function ChatPage() {
319
346
  setStreamingSessionEvents([]);
320
347
  setStreamingAssistantText('');
321
348
  setStreamingAssistantTimestamp(null);
349
+ setIsAwaitingAssistantOutput(false);
322
350
  setIsSending(false);
323
351
  } catch {
324
352
  if (runId !== streamRunIdRef.current) {
@@ -326,9 +354,11 @@ export function ChatPage() {
326
354
  }
327
355
  streamRunIdRef.current += 1;
328
356
  setIsSending(false);
357
+ setOptimisticUserEvent(null);
329
358
  setStreamingSessionEvents([]);
330
359
  setStreamingAssistantText('');
331
360
  setStreamingAssistantTimestamp(null);
361
+ setIsAwaitingAssistantOutput(false);
332
362
  if (options?.restoreDraftOnError) {
333
363
  setDraft((prev) => prev.trim().length === 0 ? item.message : prev);
334
364
  }
@@ -512,14 +542,14 @@ export function ChatPage() {
512
542
  <div className="text-xs mt-1">{t('chatNoSessionHint')}</div>
513
543
  </div>
514
544
  </div>
515
- ) : historyQuery.isLoading ? (
545
+ ) : historyQuery.isLoading && mergedEvents.length === 0 && !isSending && !isAwaitingAssistantOutput && !streamingAssistantText.trim() ? (
516
546
  <div className="text-sm text-gray-500">{t('chatHistoryLoading')}</div>
517
547
  ) : (
518
548
  <>
519
549
  {mergedEvents.length === 0 ? (
520
550
  <div className="text-sm text-gray-500">{t('chatNoMessages')}</div>
521
551
  ) : (
522
- <ChatThread events={mergedEvents} isSending={isSending && !streamingAssistantText.trim()} />
552
+ <ChatThread events={mergedEvents} isSending={isSending && isAwaitingAssistantOutput} />
523
553
  )}
524
554
  </>
525
555
  )}
@@ -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
  }