@nextclaw/ui 0.5.29 → 0.5.32

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 (41) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/dist/assets/ChannelsList-CroolNE8.js +1 -0
  3. package/dist/assets/{ChatPage-DI2euxZy.js → ChatPage-CNrt5Gfh.js} +1 -1
  4. package/dist/assets/{CronConfig-DAlt-x5i.js → CronConfig-CqjAHF25.js} +1 -1
  5. package/dist/assets/{DocBrowser-TrMsdXgx.js → DocBrowser-JxtaErnQ.js} +1 -1
  6. package/dist/assets/{MarketplacePage-Dwm527F7.js → MarketplacePage-CwRpEHu7.js} +1 -1
  7. package/dist/assets/ModelConfig-0bbUHFSf.js +1 -0
  8. package/dist/assets/ProvidersList-BluNat2q.js +1 -0
  9. package/dist/assets/{RuntimeConfig-CLbdKAlo.js → RuntimeConfig-IfToQkFH.js} +1 -1
  10. package/dist/assets/{SecretsConfig-DXCdR0Be.js → SecretsConfig-BkizgSNf.js} +2 -2
  11. package/dist/assets/{SessionsConfig-iKpz3Sts.js → SessionsConfig-CjVmna9f.js} +1 -1
  12. package/dist/assets/{card-CVj65Dvi.js → card-FVpEr1qe.js} +1 -1
  13. package/dist/assets/{dialog-lK79rlAw.js → dialog-o8XweWHQ.js} +2 -2
  14. package/dist/assets/index-nZ3tdMe1.js +2 -0
  15. package/dist/assets/index-yypHrk9r.css +1 -0
  16. package/dist/assets/{label-l-fECYi3.js → label-PpMv4yho.js} +1 -1
  17. package/dist/assets/logos-B2FZM2a7.js +1 -0
  18. package/dist/assets/{page-layout-BghxFaNt.js → page-layout-DXAYLIUr.js} +1 -1
  19. package/dist/assets/{switch-B4yFzIbc.js → switch-CGhksmrM.js} +1 -1
  20. package/dist/assets/{tabs-custom-B4q02QSV.js → tabs-custom-4AOSVaDz.js} +1 -1
  21. package/dist/assets/useConfig-CPpZMJSX.js +6 -0
  22. package/dist/assets/{useConfirmDialog-C20D5SYn.js → useConfirmDialog-DGWSgJUB.js} +1 -1
  23. package/dist/assets/{vendor-H2M3a_4Z.js → vendor-C8fQ0Vej.js} +84 -74
  24. package/dist/index.html +3 -3
  25. package/package.json +1 -1
  26. package/src/api/config.ts +17 -0
  27. package/src/api/types.ts +12 -0
  28. package/src/components/config/ModelConfig.tsx +10 -2
  29. package/src/components/config/ProviderForm.tsx +238 -142
  30. package/src/components/config/ProvidersList.tsx +140 -80
  31. package/src/components/ui/status-dot.tsx +1 -1
  32. package/src/hooks/useConfig.ts +8 -0
  33. package/src/lib/i18n.ts +14 -1
  34. package/src/stores/ui.store.ts +0 -9
  35. package/dist/assets/ChannelsList-DEr4kE7H.js +0 -1
  36. package/dist/assets/ModelConfig-srggzgfA.js +0 -1
  37. package/dist/assets/ProvidersList-8kFCDiqC.js +0 -1
  38. package/dist/assets/action-link-w4jS8X9q.js +0 -1
  39. package/dist/assets/index-BXgULtdk.js +0 -2
  40. package/dist/assets/index-B_OeEGic.css +0 -1
  41. package/dist/assets/useConfig-C9k3TmQk.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-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-nZ3tdMe1.js"></script>
10
+ <link rel="modulepreload" crossorigin href="/assets/vendor-C8fQ0Vej.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.32",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "license": "MIT",
package/src/api/config.ts CHANGED
@@ -6,6 +6,8 @@ import type {
6
6
  ProviderConfigView,
7
7
  ChannelConfigUpdate,
8
8
  ProviderConfigUpdate,
9
+ ProviderConnectionTestRequest,
10
+ ProviderConnectionTestResult,
9
11
  RuntimeConfigUpdate,
10
12
  SecretsConfigUpdate,
11
13
  SecretsView,
@@ -79,6 +81,21 @@ export async function updateProvider(
79
81
  return response.data;
80
82
  }
81
83
 
84
+ // POST /api/config/providers/:provider/test
85
+ export async function testProviderConnection(
86
+ provider: string,
87
+ data: ProviderConnectionTestRequest
88
+ ): Promise<ProviderConnectionTestResult> {
89
+ const response = await api.post<ProviderConnectionTestResult>(
90
+ `/api/config/providers/${provider}/test`,
91
+ data
92
+ );
93
+ if (!response.ok) {
94
+ throw new Error(response.error.message);
95
+ }
96
+ return response.data;
97
+ }
98
+
82
99
  // PUT /api/config/channels/:channel
83
100
  export async function updateChannel(
84
101
  channel: string,
package/src/api/types.ts CHANGED
@@ -24,6 +24,18 @@ export type ProviderConfigUpdate = {
24
24
  wireApi?: "auto" | "chat" | "responses" | null;
25
25
  };
26
26
 
27
+ export type ProviderConnectionTestRequest = ProviderConfigUpdate & {
28
+ model?: string | null;
29
+ };
30
+
31
+ export type ProviderConnectionTestResult = {
32
+ success: boolean;
33
+ provider: string;
34
+ model?: string;
35
+ latencyMs: number;
36
+ message: string;
37
+ };
38
+
27
39
  export type AgentProfileView = {
28
40
  id: string;
29
41
  default?: boolean;
@@ -7,7 +7,8 @@ import { useConfig, useConfigSchema, useUpdateModel } from '@/hooks/useConfig';
7
7
  import { hintForPath } from '@/lib/config-hints';
8
8
  import { formatNumber, t } from '@/lib/i18n';
9
9
  import { PageLayout, PageHeader } from '@/components/layout/page-layout';
10
- import { Folder, Loader2, Sliders, Sparkles } from 'lucide-react';
10
+ import { DOCS_DEFAULT_BASE_URL } from '@/components/doc-browser/DocBrowserContext';
11
+ import { BookOpen, Folder, Loader2, Sliders, Sparkles } from 'lucide-react';
11
12
  import { useEffect, useState } from 'react';
12
13
 
13
14
  export function ModelConfig() {
@@ -96,8 +97,15 @@ export function ModelConfig() {
96
97
  />
97
98
  <p className="text-xs text-gray-400">
98
99
  {modelHint?.help ??
99
- 'Examples: minimax/MiniMax-M2.5 · minimax/MiniMax-M2.1 · openrouter/anthropic/claude-3.5-sonnet · openrouter/openai/gpt-4o-mini'}
100
+ 'Examples: gpt-5.1 · claude-opus-4-1 · deepseek/deepseek-chat · dashscope/qwen-max-latest · openrouter/openai/gpt-5.3-codex'}
100
101
  </p>
102
+ <a
103
+ href={`${DOCS_DEFAULT_BASE_URL}/guide/model-selection`}
104
+ className="inline-flex items-center gap-1.5 text-xs text-primary hover:text-primary-hover transition-colors"
105
+ >
106
+ <BookOpen className="h-3.5 w-3.5" />
107
+ {t('channelsGuideTitle')}
108
+ </a>
101
109
  </div>
102
110
  </div>
103
111
 
@@ -1,203 +1,299 @@
1
- import { useEffect, useState } from 'react';
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';
1
+ import { useEffect, useMemo, useState } from 'react';
2
+ import { useConfig, useConfigMeta, useConfigSchema, useTestProviderConnection, useUpdateProvider } from '@/hooks/useConfig';
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
- import type { ProviderConfigUpdate } from '@/api/types';
21
- import { KeyRound, Globe, Hash } from 'lucide-react';
12
+ import type { ProviderConfigUpdate, ProviderConnectionTestRequest } from '@/api/types';
13
+ import { KeyRound, Globe, Hash, RotateCcw, CircleDotDashed } from 'lucide-react';
14
+ import { toast } from 'sonner';
22
15
 
23
- export function ProviderForm() {
24
- const { providerModal, closeProviderModal } = useUiStore();
16
+ type WireApiType = 'auto' | 'chat' | 'responses';
17
+
18
+ type ProviderFormProps = {
19
+ providerName?: string;
20
+ };
21
+
22
+ function normalizeHeaders(input: Record<string, string> | null | undefined): Record<string, string> | null {
23
+ if (!input) {
24
+ return null;
25
+ }
26
+ const entries = Object.entries(input)
27
+ .map(([key, value]) => [key.trim(), value] as const)
28
+ .filter(([key]) => key.length > 0);
29
+ if (entries.length === 0) {
30
+ return null;
31
+ }
32
+ return Object.fromEntries(entries);
33
+ }
34
+
35
+ function headersEqual(
36
+ left: Record<string, string> | null | undefined,
37
+ right: Record<string, string> | null | undefined
38
+ ): boolean {
39
+ const a = normalizeHeaders(left);
40
+ const b = normalizeHeaders(right);
41
+ if (a === null && b === null) {
42
+ return true;
43
+ }
44
+ if (!a || !b) {
45
+ return false;
46
+ }
47
+ const aEntries = Object.entries(a).sort(([ak], [bk]) => ak.localeCompare(bk));
48
+ const bEntries = Object.entries(b).sort(([ak], [bk]) => ak.localeCompare(bk));
49
+ if (aEntries.length !== bEntries.length) {
50
+ return false;
51
+ }
52
+ return aEntries.every(([key, value], index) => key === bEntries[index][0] && value === bEntries[index][1]);
53
+ }
54
+
55
+ export function ProviderForm({ providerName }: ProviderFormProps) {
25
56
  const { data: config } = useConfig();
26
57
  const { data: meta } = useConfigMeta();
27
58
  const { data: schema } = useConfigSchema();
28
59
  const updateProvider = useUpdateProvider();
60
+ const testProviderConnection = useTestProviderConnection();
29
61
 
30
62
  const [apiKey, setApiKey] = useState('');
31
63
  const [apiBase, setApiBase] = useState('');
32
64
  const [extraHeaders, setExtraHeaders] = useState<Record<string, string> | null>(null);
33
- const [wireApi, setWireApi] = useState<'auto' | 'chat' | 'responses'>('auto');
65
+ const [wireApi, setWireApi] = useState<WireApiType>('auto');
34
66
 
35
- const providerName = providerModal.provider;
36
67
  const providerSpec = meta?.providers.find((p) => p.name === providerName);
37
68
  const providerConfig = providerName ? config?.providers[providerName] : null;
38
69
  const uiHints = schema?.uiHints;
70
+
39
71
  const apiKeyHint = providerName ? hintForPath(`providers.${providerName}.apiKey`, uiHints) : undefined;
40
72
  const apiBaseHint = providerName ? hintForPath(`providers.${providerName}.apiBase`, uiHints) : undefined;
41
73
  const extraHeadersHint = providerName ? hintForPath(`providers.${providerName}.extraHeaders`, uiHints) : undefined;
42
74
  const wireApiHint = providerName ? hintForPath(`providers.${providerName}.wireApi`, uiHints) : undefined;
43
75
 
76
+ const providerTitle = providerSpec?.displayName || providerName || t('providersSelectPlaceholder');
77
+ const defaultApiBase = providerSpec?.defaultApiBase || '';
78
+ const currentApiBase = providerConfig?.apiBase || defaultApiBase;
79
+ const currentHeaders = normalizeHeaders(providerConfig?.extraHeaders || null);
80
+ const currentWireApi = (providerConfig?.wireApi || providerSpec?.defaultWireApi || 'auto') as WireApiType;
81
+
44
82
  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');
83
+ if (!providerName) {
84
+ setApiKey('');
85
+ setApiBase('');
86
+ setExtraHeaders(null);
87
+ setWireApi('auto');
88
+ return;
89
+ }
90
+
91
+ setApiKey('');
92
+ setApiBase(currentApiBase);
93
+ setExtraHeaders(providerConfig?.extraHeaders || null);
94
+ setWireApi(currentWireApi);
95
+ }, [providerName, currentApiBase, providerConfig?.extraHeaders, currentWireApi]);
96
+
97
+ const hasChanges = useMemo(() => {
98
+ if (!providerName) {
99
+ return false;
52
100
  }
53
- }, [providerConfig, providerSpec]);
101
+ const apiKeyChanged = apiKey.trim().length > 0;
102
+ const apiBaseChanged = apiBase.trim() !== currentApiBase.trim();
103
+ const headersChanged = !headersEqual(extraHeaders, currentHeaders);
104
+ const wireApiChanged = providerSpec?.supportsWireApi ? wireApi !== currentWireApi : false;
105
+
106
+ return apiKeyChanged || apiBaseChanged || headersChanged || wireApiChanged;
107
+ }, [
108
+ providerName,
109
+ apiKey,
110
+ apiBase,
111
+ currentApiBase,
112
+ extraHeaders,
113
+ currentHeaders,
114
+ providerSpec?.supportsWireApi,
115
+ wireApi,
116
+ currentWireApi
117
+ ]);
118
+
119
+ const resetToDefault = () => {
120
+ setApiKey('');
121
+ setApiBase(defaultApiBase);
122
+ setExtraHeaders(null);
123
+ setWireApi((providerSpec?.defaultWireApi || 'auto') as WireApiType);
124
+ };
54
125
 
55
126
  const handleSubmit = (e: React.FormEvent) => {
56
127
  e.preventDefault();
57
128
 
129
+ if (!providerName) {
130
+ return;
131
+ }
132
+
58
133
  const payload: ProviderConfigUpdate = {};
134
+ const trimmedApiKey = apiKey.trim();
135
+ const trimmedApiBase = apiBase.trim();
136
+ const normalizedHeaders = normalizeHeaders(extraHeaders);
59
137
 
60
- // Only include apiKey if user has entered something
61
- if (apiKey !== '') {
62
- payload.apiKey = apiKey;
138
+ if (trimmedApiKey.length > 0) {
139
+ payload.apiKey = trimmedApiKey;
63
140
  }
64
141
 
65
- if (apiBase && apiBase !== providerSpec?.defaultApiBase) {
66
- payload.apiBase = apiBase;
142
+ if (trimmedApiBase !== currentApiBase.trim()) {
143
+ payload.apiBase = trimmedApiBase.length > 0 && trimmedApiBase !== defaultApiBase ? trimmedApiBase : null;
67
144
  }
68
145
 
69
- if (extraHeaders && Object.keys(extraHeaders).length > 0) {
70
- payload.extraHeaders = extraHeaders;
146
+ if (!headersEqual(normalizedHeaders, currentHeaders)) {
147
+ payload.extraHeaders = normalizedHeaders;
148
+ }
149
+
150
+ if (providerSpec?.supportsWireApi && wireApi !== currentWireApi) {
151
+ payload.wireApi = wireApi;
152
+ }
153
+
154
+ updateProvider.mutate({ provider: providerName, data: payload });
155
+ };
156
+
157
+ const handleTestConnection = async () => {
158
+ if (!providerName) {
159
+ return;
71
160
  }
72
161
 
162
+ const payload: ProviderConnectionTestRequest = {
163
+ apiBase: apiBase.trim(),
164
+ extraHeaders: normalizeHeaders(extraHeaders),
165
+ model: config?.agents.defaults.model ?? null
166
+ };
167
+ if (apiKey.trim().length > 0) {
168
+ payload.apiKey = apiKey.trim();
169
+ }
73
170
  if (providerSpec?.supportsWireApi) {
74
- const currentWireApi =
75
- providerConfig?.wireApi || providerSpec.defaultWireApi || 'auto';
76
- if (wireApi !== currentWireApi) {
77
- payload.wireApi = wireApi;
78
- }
171
+ payload.wireApi = wireApi;
79
172
  }
80
173
 
81
- if (!providerName) return;
174
+ try {
175
+ const result = await testProviderConnection.mutateAsync({
176
+ provider: providerName,
177
+ data: payload
178
+ });
179
+ if (result.success) {
180
+ toast.success(`${t('providerTestConnectionSuccess')} (${result.latencyMs}ms)`);
181
+ return;
182
+ }
183
+ toast.error(`${t('providerTestConnectionFailed')}: ${result.message}`);
184
+ } catch (error) {
185
+ const message = error instanceof Error ? error.message : String(error);
186
+ toast.error(`${t('providerTestConnectionFailed')}: ${message}`);
187
+ }
188
+ };
82
189
 
83
- updateProvider.mutate(
84
- { provider: providerName, data: payload },
85
- { onSuccess: () => closeProviderModal() }
190
+ if (!providerName || !providerSpec || !providerConfig) {
191
+ return (
192
+ <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]">
193
+ <div>
194
+ <h3 className="text-base font-semibold text-gray-900">{t('providersSelectTitle')}</h3>
195
+ <p className="mt-2 text-sm text-gray-500">{t('providersSelectDescription')}</p>
196
+ </div>
197
+ </div>
86
198
  );
87
- };
199
+ }
200
+
201
+ const statusLabel = providerConfig.apiKeySet ? t('statusReady') : t('statusSetup');
88
202
 
89
203
  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>
204
+ <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]">
205
+ <div className="border-b border-gray-100 px-6 py-5">
206
+ <div className="flex flex-wrap items-center justify-between gap-3">
207
+ <div className="min-w-0">
208
+ <h3 className="truncate text-lg font-semibold text-gray-900">{providerTitle}</h3>
209
+ <p className="mt-1 text-sm text-gray-500">{t('providerFormDescription')}</p>
101
210
  </div>
102
- </DialogHeader>
211
+ <StatusDot status={providerConfig.apiKeySet ? 'ready' : 'setup'} label={statusLabel} />
212
+ </div>
213
+ </div>
103
214
 
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>
215
+ <form onSubmit={handleSubmit} className="flex min-h-0 flex-1 flex-col">
216
+ <div className="min-h-0 flex-1 space-y-6 overflow-y-auto px-6 py-5">
217
+ <div className="space-y-2.5">
218
+ <Label htmlFor="apiKey" className="flex items-center gap-2 text-sm font-medium text-gray-900">
219
+ <KeyRound className="h-3.5 w-3.5 text-gray-500" />
220
+ {apiKeyHint?.label ?? t('apiKey')}
221
+ </Label>
222
+ <MaskedInput
223
+ id="apiKey"
224
+ value={apiKey}
225
+ isSet={providerConfig.apiKeySet}
226
+ onChange={(e) => setApiKey(e.target.value)}
227
+ placeholder={providerConfig.apiKeySet ? t('apiKeySet') : apiKeyHint?.placeholder ?? t('enterApiKey')}
228
+ className="rounded-xl"
229
+ />
230
+ <p className="text-xs text-gray-500">{t('leaveBlankToKeepUnchanged')}</p>
231
+ </div>
146
232
 
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
- )}
233
+ <div className="space-y-2.5">
234
+ <Label htmlFor="apiBase" className="flex items-center gap-2 text-sm font-medium text-gray-900">
235
+ <Globe className="h-3.5 w-3.5 text-gray-500" />
236
+ {apiBaseHint?.label ?? t('apiBase')}
237
+ </Label>
238
+ <Input
239
+ id="apiBase"
240
+ type="text"
241
+ value={apiBase}
242
+ onChange={(e) => setApiBase(e.target.value)}
243
+ placeholder={defaultApiBase || apiBaseHint?.placeholder || 'https://api.example.com'}
244
+ className="rounded-xl"
245
+ />
246
+ <p className="text-xs text-gray-500">{apiBaseHint?.help || t('providerApiBaseHelp')}</p>
247
+ </div>
171
248
 
249
+ {providerSpec.supportsWireApi && (
172
250
  <div className="space-y-2.5">
173
- <Label className="text-sm font-medium text-gray-900 flex items-center gap-2">
251
+ <Label htmlFor="wireApi" className="flex items-center gap-2 text-sm font-medium text-gray-900">
174
252
  <Hash className="h-3.5 w-3.5 text-gray-500" />
175
- {extraHeadersHint?.label ?? t('extraHeaders')}
253
+ {wireApiHint?.label ?? t('wireApi')}
176
254
  </Label>
177
- <KeyValueEditor
178
- value={extraHeaders}
179
- onChange={setExtraHeaders}
180
- />
255
+ <Select value={wireApi} onValueChange={(v) => setWireApi(v as WireApiType)}>
256
+ <SelectTrigger className="rounded-xl">
257
+ <SelectValue />
258
+ </SelectTrigger>
259
+ <SelectContent>
260
+ {(providerSpec.wireApiOptions || ['auto', 'chat', 'responses']).map((option) => (
261
+ <SelectItem key={option} value={option}>
262
+ {option === 'chat' ? t('wireApiChat') : option === 'responses' ? t('wireApiResponses') : t('wireApiAuto')}
263
+ </SelectItem>
264
+ ))}
265
+ </SelectContent>
266
+ </Select>
267
+ {wireApiHint?.help && <p className="text-xs text-gray-500">{wireApiHint.help}</p>}
181
268
  </div>
269
+ )}
270
+
271
+ <div className="space-y-2.5">
272
+ <Label className="flex items-center gap-2 text-sm font-medium text-gray-900">
273
+ <Hash className="h-3.5 w-3.5 text-gray-500" />
274
+ {extraHeadersHint?.label ?? t('extraHeaders')}
275
+ </Label>
276
+ <KeyValueEditor value={extraHeaders} onChange={setExtraHeaders} />
277
+ <p className="text-xs text-gray-500">{extraHeadersHint?.help || t('providerExtraHeadersHelp')}</p>
182
278
  </div>
279
+ </div>
183
280
 
184
- <DialogFooter className="pt-4 flex-shrink-0">
185
- <Button
186
- type="button"
187
- variant="outline"
188
- onClick={closeProviderModal}
189
- >
190
- {t('cancel')}
281
+ <div className="flex flex-wrap items-center justify-between gap-3 border-t border-gray-100 px-6 py-4">
282
+ <div className="flex flex-wrap items-center gap-2">
283
+ <Button type="button" variant="outline" onClick={resetToDefault}>
284
+ <RotateCcw className="mr-2 h-4 w-4" />
285
+ {t('resetToDefault')}
191
286
  </Button>
192
- <Button
193
- type="submit"
194
- disabled={updateProvider.isPending}
195
- >
196
- {updateProvider.isPending ? t('saving') : t('save')}
287
+ <Button type="button" variant="outline" onClick={handleTestConnection} disabled={testProviderConnection.isPending}>
288
+ <CircleDotDashed className="mr-2 h-4 w-4" />
289
+ {testProviderConnection.isPending ? t('providerTestingConnection') : t('providerTestConnection')}
197
290
  </Button>
198
- </DialogFooter>
199
- </form>
200
- </DialogContent>
201
- </Dialog>
291
+ </div>
292
+ <Button type="submit" disabled={updateProvider.isPending || !hasChanges}>
293
+ {updateProvider.isPending ? t('saving') : hasChanges ? t('save') : t('unchanged')}
294
+ </Button>
295
+ </div>
296
+ </form>
297
+ </div>
202
298
  );
203
299
  }