@nextclaw/ui 0.5.33 → 0.5.34

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 (37) hide show
  1. package/CHANGELOG.md +6 -0
  2. package/dist/assets/{ChannelsList-C6_GL_XC.js → ChannelsList-DvhJoVmJ.js} +1 -1
  3. package/dist/assets/{ChatPage-CeIP0Tot.js → ChatPage-BSD-DBqC.js} +1 -1
  4. package/dist/assets/{CronConfig-UmNwDWoP.js → CronConfig-JLIc66HM.js} +1 -1
  5. package/dist/assets/DocBrowser-BCJjJTr9.js +1 -0
  6. package/dist/assets/{MarketplacePage-DxYm8B87.js → MarketplacePage-ftvI9vFu.js} +1 -1
  7. package/dist/assets/ModelConfig-iD7V4upL.js +1 -0
  8. package/dist/assets/ProvidersList-moYj5oBN.js +1 -0
  9. package/dist/assets/{RuntimeConfig-D_f-7U_f.js → RuntimeConfig-CvWztear.js} +1 -1
  10. package/dist/assets/{SecretsConfig-rb2Q5BWo.js → SecretsConfig-Ds5xip01.js} +1 -1
  11. package/dist/assets/SessionsConfig-CCbracj_.js +2 -0
  12. package/dist/assets/{card-Cn6iWntr.js → card-CB1zsVbS.js} +1 -1
  13. package/dist/assets/{dialog-BZ5jMhG9.js → dialog-BgbSAXFu.js} +2 -2
  14. package/dist/assets/index-B8Wh_FvS.css +1 -0
  15. package/dist/assets/index-O1Kus7pd.js +2 -0
  16. package/dist/assets/{label-N8QPWq03.js → label-d0bFiiuu.js} +1 -1
  17. package/dist/assets/{logos-DLOpl3jc.js → logos-gjlYO0d_.js} +1 -1
  18. package/dist/assets/{page-layout-CLYPd-xa.js → page-layout-Cf2nfjrs.js} +1 -1
  19. package/dist/assets/{switch-B_AgwrJz.js → switch-CfELV89t.js} +1 -1
  20. package/dist/assets/{tabs-custom-Cec2n_Lf.js → tabs-custom-CJdhCvOt.js} +1 -1
  21. package/dist/assets/{useConfig-VlP0ShlX.js → useConfig-DnBXFnGL.js} +2 -2
  22. package/dist/assets/{useConfirmDialog-erF0KI-a.js → useConfirmDialog-c4_c0EGk.js} +1 -1
  23. package/dist/assets/{vendor-C8fQ0Vej.js → vendor-CmqkRoMs.js} +79 -74
  24. package/dist/index.html +3 -3
  25. package/package.json +1 -1
  26. package/src/api/client.ts +50 -3
  27. package/src/api/types.ts +21 -0
  28. package/src/components/common/SearchableModelInput.tsx +156 -0
  29. package/src/components/config/ModelConfig.tsx +181 -21
  30. package/src/components/config/ProviderForm.tsx +187 -5
  31. package/src/lib/i18n.ts +12 -0
  32. package/dist/assets/DocBrowser-CRGPwkiY.js +0 -1
  33. package/dist/assets/ModelConfig-c-DLS2TO.js +0 -1
  34. package/dist/assets/ProvidersList-B7spTkob.js +0 -1
  35. package/dist/assets/SessionsConfig-DqaFA8Nn.js +0 -2
  36. package/dist/assets/index-C2-PvYBh.js +0 -2
  37. package/dist/assets/index-yypHrk9r.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-C2-PvYBh.js"></script>
10
- <link rel="modulepreload" crossorigin href="/assets/vendor-C8fQ0Vej.js">
11
- <link rel="stylesheet" crossorigin href="/assets/index-yypHrk9r.css">
9
+ <script type="module" crossorigin src="/assets/index-O1Kus7pd.js"></script>
10
+ <link rel="modulepreload" crossorigin href="/assets/vendor-CmqkRoMs.js">
11
+ <link rel="stylesheet" crossorigin href="/assets/index-B8Wh_FvS.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.33",
3
+ "version": "0.5.34",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "license": "MIT",
package/src/api/client.ts CHANGED
@@ -18,11 +18,36 @@ if (import.meta.env.DEV && !import.meta.env.VITE_API_BASE) {
18
18
 
19
19
  export { API_BASE };
20
20
 
21
+ function compactSnippet(text: string): string {
22
+ return text.replace(/\s+/g, ' ').trim().slice(0, 200);
23
+ }
24
+
25
+ function inferNonJsonHint(endpoint: string, status: number): string | undefined {
26
+ if (
27
+ status === 404 &&
28
+ endpoint.startsWith('/api/config/providers/') &&
29
+ endpoint.endsWith('/test')
30
+ ) {
31
+ return 'Provider test endpoint is missing. This usually means nextclaw runtime version is outdated.';
32
+ }
33
+ if (status === 401 || status === 403) {
34
+ return 'Authentication failed. Check apiKey and custom headers.';
35
+ }
36
+ if (status === 429) {
37
+ return 'Rate limited by upstream provider. Retry later or switch model/provider.';
38
+ }
39
+ if (status >= 500) {
40
+ return 'Upstream service error. Retry later and inspect server logs if it persists.';
41
+ }
42
+ return undefined;
43
+ }
44
+
21
45
  async function apiRequest<T>(
22
46
  endpoint: string,
23
47
  options: RequestInit = {}
24
48
  ): Promise<ApiResponse<T>> {
25
49
  const url = `${API_BASE}${endpoint}`;
50
+ const method = (options.method || 'GET').toUpperCase();
26
51
 
27
52
  const response = await fetch(url, {
28
53
  headers: {
@@ -43,9 +68,31 @@ async function apiRequest<T>(
43
68
  }
44
69
 
45
70
  if (!data) {
46
- const snippet = text ? text.slice(0, 200) : '';
47
- const message = `Non-JSON response (${response.status} ${response.statusText})${snippet ? `: ${snippet}` : ''}`;
48
- return { ok: false, error: { code: 'INVALID_RESPONSE', message } };
71
+ const snippet = text ? compactSnippet(text) : '';
72
+ const hint = inferNonJsonHint(endpoint, response.status);
73
+ const parts = [`Non-JSON response (${response.status} ${response.statusText}) on ${method} ${endpoint}`];
74
+ if (snippet) {
75
+ parts.push(`body=${snippet}`);
76
+ }
77
+ if (hint) {
78
+ parts.push(`hint=${hint}`);
79
+ }
80
+ return {
81
+ ok: false,
82
+ error: {
83
+ code: 'INVALID_RESPONSE',
84
+ message: parts.join(' | '),
85
+ details: {
86
+ status: response.status,
87
+ statusText: response.statusText,
88
+ method,
89
+ endpoint,
90
+ url,
91
+ bodySnippet: snippet || undefined,
92
+ hint
93
+ }
94
+ }
95
+ };
49
96
  }
50
97
 
51
98
  if (!response.ok) {
package/src/api/types.ts CHANGED
@@ -15,6 +15,7 @@ export type ProviderConfigView = {
15
15
  apiBase?: string | null;
16
16
  extraHeaders?: Record<string, string> | null;
17
17
  wireApi?: "auto" | "chat" | "responses" | null;
18
+ models?: string[];
18
19
  };
19
20
 
20
21
  export type ProviderConfigUpdate = {
@@ -22,18 +23,36 @@ export type ProviderConfigUpdate = {
22
23
  apiBase?: string | null;
23
24
  extraHeaders?: Record<string, string> | null;
24
25
  wireApi?: "auto" | "chat" | "responses" | null;
26
+ models?: string[] | null;
25
27
  };
26
28
 
27
29
  export type ProviderConnectionTestRequest = ProviderConfigUpdate & {
28
30
  model?: string | null;
29
31
  };
30
32
 
33
+ export type ProviderConnectionTestErrorCode =
34
+ | 'API_KEY_REQUIRED'
35
+ | 'MODEL_REQUIRED'
36
+ | 'AUTH_FAILED'
37
+ | 'PERMISSION_DENIED'
38
+ | 'RATE_LIMITED'
39
+ | 'MODEL_NOT_FOUND'
40
+ | 'INVALID_ENDPOINT'
41
+ | 'INVALID_REQUEST'
42
+ | 'NETWORK_ERROR'
43
+ | 'SERVER_ERROR'
44
+ | 'UNKNOWN_ERROR';
45
+
31
46
  export type ProviderConnectionTestResult = {
32
47
  success: boolean;
33
48
  provider: string;
34
49
  model?: string;
35
50
  latencyMs: number;
36
51
  message: string;
52
+ errorCode?: ProviderConnectionTestErrorCode;
53
+ httpStatus?: number;
54
+ endpoint?: string;
55
+ hint?: string;
37
56
  };
38
57
 
39
58
  export type AgentProfileView = {
@@ -306,11 +325,13 @@ export type ConfigView = {
306
325
  export type ProviderSpecView = {
307
326
  name: string;
308
327
  displayName?: string;
328
+ modelPrefix?: string;
309
329
  keywords: string[];
310
330
  envKey: string;
311
331
  isGateway?: boolean;
312
332
  isLocal?: boolean;
313
333
  defaultApiBase?: string;
334
+ defaultModels?: string[];
314
335
  supportsWireApi?: boolean;
315
336
  wireApiOptions?: Array<"auto" | "chat" | "responses">;
316
337
  defaultWireApi?: "auto" | "chat" | "responses";
@@ -0,0 +1,156 @@
1
+ import { useMemo, useState } from 'react';
2
+ import { Check, ChevronsUpDown } from 'lucide-react';
3
+ import { Input } from '@/components/ui/input';
4
+ import { cn } from '@/lib/utils';
5
+
6
+ type SearchableModelInputProps = {
7
+ id?: string;
8
+ value: string;
9
+ onChange: (value: string) => void;
10
+ options: string[];
11
+ placeholder?: string;
12
+ className?: string;
13
+ inputClassName?: string;
14
+ emptyText?: string;
15
+ createText?: string;
16
+ maxItems?: number;
17
+ onEnter?: () => void;
18
+ };
19
+
20
+ function normalizeOptions(options: string[]): string[] {
21
+ const deduped = new Set<string>();
22
+ for (const option of options) {
23
+ const trimmed = option.trim();
24
+ if (trimmed.length > 0) {
25
+ deduped.add(trimmed);
26
+ }
27
+ }
28
+ return [...deduped];
29
+ }
30
+
31
+ export function SearchableModelInput({
32
+ id,
33
+ value,
34
+ onChange,
35
+ options,
36
+ placeholder,
37
+ className,
38
+ inputClassName,
39
+ emptyText,
40
+ createText,
41
+ maxItems = Number.POSITIVE_INFINITY,
42
+ onEnter
43
+ }: SearchableModelInputProps) {
44
+ const [open, setOpen] = useState(false);
45
+
46
+ const normalizedOptions = useMemo(() => normalizeOptions(options), [options]);
47
+ const query = value.trim().toLowerCase();
48
+
49
+ const orderedOptions = useMemo(() => {
50
+ const indexed = normalizedOptions.map((option, index) => ({ option, index }));
51
+ if (query.length > 0) {
52
+ indexed.sort((left, right) => {
53
+ const leftText = left.option.toLowerCase();
54
+ const rightText = right.option.toLowerCase();
55
+ const leftRank = leftText === query ? 0 : leftText.startsWith(query) ? 1 : leftText.includes(query) ? 2 : 3;
56
+ const rightRank =
57
+ rightText === query ? 0 : rightText.startsWith(query) ? 1 : rightText.includes(query) ? 2 : 3;
58
+ if (leftRank !== rightRank) {
59
+ return leftRank - rightRank;
60
+ }
61
+ return left.index - right.index;
62
+ });
63
+ }
64
+ const sorted = indexed.map((item) => item.option);
65
+ if (Number.isFinite(maxItems)) {
66
+ return sorted.slice(0, Math.max(1, maxItems));
67
+ }
68
+ return sorted;
69
+ }, [normalizedOptions, query, maxItems]);
70
+
71
+ const hasExactMatch = value.trim().length > 0 && normalizedOptions.some((option) => option === value.trim());
72
+
73
+ return (
74
+ <div
75
+ className={cn('relative', className)}
76
+ onBlur={() => {
77
+ setTimeout(() => setOpen(false), 120);
78
+ }}
79
+ >
80
+ <Input
81
+ id={id}
82
+ value={value}
83
+ onFocus={() => setOpen(true)}
84
+ onChange={(event) => {
85
+ onChange(event.target.value);
86
+ if (!open) {
87
+ setOpen(true);
88
+ }
89
+ }}
90
+ onKeyDown={(event) => {
91
+ if (event.key === 'Enter') {
92
+ if (onEnter) {
93
+ event.preventDefault();
94
+ onEnter();
95
+ }
96
+ setOpen(false);
97
+ }
98
+ }}
99
+ placeholder={placeholder}
100
+ className={cn('pr-10', inputClassName)}
101
+ />
102
+ <button
103
+ type="button"
104
+ onMouseDown={(event) => event.preventDefault()}
105
+ onClick={() => setOpen((prev) => !prev)}
106
+ className="absolute inset-y-0 right-0 inline-flex w-10 items-center justify-center text-gray-400 hover:text-gray-600"
107
+ aria-label="toggle model options"
108
+ >
109
+ <ChevronsUpDown className="h-4 w-4" />
110
+ </button>
111
+
112
+ {open && (
113
+ <div className="absolute z-20 mt-1 w-full overflow-hidden rounded-xl border border-gray-200 bg-white shadow-lg">
114
+ <div className="max-h-60 overflow-y-auto py-1">
115
+ {!hasExactMatch && value.trim().length > 0 && (
116
+ <button
117
+ type="button"
118
+ onMouseDown={(event) => event.preventDefault()}
119
+ onClick={() => {
120
+ onChange(value.trim());
121
+ setOpen(false);
122
+ }}
123
+ className="flex w-full items-center gap-2 px-3 py-2 text-left text-sm hover:bg-gray-50"
124
+ >
125
+ <Check className="h-4 w-4 text-transparent" />
126
+ <span className="truncate text-gray-700">
127
+ {createText ? createText.replace('{value}', value.trim()) : value.trim()}
128
+ </span>
129
+ </button>
130
+ )}
131
+
132
+ {orderedOptions.map((option) => (
133
+ <button
134
+ key={option}
135
+ type="button"
136
+ onMouseDown={(event) => event.preventDefault()}
137
+ onClick={() => {
138
+ onChange(option);
139
+ setOpen(false);
140
+ }}
141
+ className="flex w-full items-center gap-2 px-3 py-2 text-left text-sm hover:bg-gray-50"
142
+ >
143
+ <Check className={cn('h-4 w-4', option === value.trim() ? 'text-primary' : 'text-transparent')} />
144
+ <span className="truncate text-gray-700">{option}</span>
145
+ </button>
146
+ ))}
147
+
148
+ {orderedOptions.length === 0 && value.trim().length === 0 && (
149
+ <div className="px-3 py-2 text-sm text-gray-500">{emptyText ?? 'No models available'}</div>
150
+ )}
151
+ </div>
152
+ </div>
153
+ )}
154
+ </div>
155
+ );
156
+ }
@@ -3,20 +3,87 @@ import { Card } from '@/components/ui/card';
3
3
  import { Input } from '@/components/ui/input';
4
4
  import { Label } from '@/components/ui/label';
5
5
  import { Skeleton } from '@/components/ui/skeleton';
6
- import { useConfig, useConfigSchema, useUpdateModel } from '@/hooks/useConfig';
6
+ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
7
+ import { SearchableModelInput } from '@/components/common/SearchableModelInput';
8
+ import { useConfig, useConfigMeta, useConfigSchema, useUpdateModel } from '@/hooks/useConfig';
7
9
  import { hintForPath } from '@/lib/config-hints';
8
10
  import { formatNumber, t } from '@/lib/i18n';
9
11
  import { PageLayout, PageHeader } from '@/components/layout/page-layout';
10
12
  import { DOCS_DEFAULT_BASE_URL } from '@/components/doc-browser/DocBrowserContext';
11
13
  import { BookOpen, Folder, Loader2, Sliders, Sparkles } from 'lucide-react';
12
- import { useEffect, useState } from 'react';
14
+ import { useEffect, useMemo, useState } from 'react';
15
+
16
+ function normalizeStringList(input: string[] | null | undefined): string[] {
17
+ if (!input || input.length === 0) {
18
+ return [];
19
+ }
20
+ const deduped = new Set<string>();
21
+ for (const item of input) {
22
+ const trimmed = item.trim();
23
+ if (trimmed) {
24
+ deduped.add(trimmed);
25
+ }
26
+ }
27
+ return [...deduped];
28
+ }
29
+
30
+ function stripProviderPrefix(model: string, prefix: string): string {
31
+ const trimmed = model.trim();
32
+ const cleanPrefix = prefix.trim();
33
+ if (!trimmed || !cleanPrefix) {
34
+ return trimmed;
35
+ }
36
+ const withSlash = `${cleanPrefix}/`;
37
+ if (trimmed.startsWith(withSlash)) {
38
+ return trimmed.slice(withSlash.length);
39
+ }
40
+ return trimmed;
41
+ }
42
+
43
+ function toProviderLocalModel(model: string, aliases: string[]): string {
44
+ let normalized = model.trim();
45
+ if (!normalized) {
46
+ return '';
47
+ }
48
+ for (const alias of aliases) {
49
+ normalized = stripProviderPrefix(normalized, alias);
50
+ }
51
+ return normalized.trim();
52
+ }
53
+
54
+ function findProviderByModel(
55
+ model: string,
56
+ providerCatalog: Array<{ name: string; aliases: string[] }>
57
+ ): string | null {
58
+ const trimmed = model.trim();
59
+ if (!trimmed) {
60
+ return null;
61
+ }
62
+ let bestMatch: { name: string; score: number } | null = null;
63
+ for (const provider of providerCatalog) {
64
+ for (const alias of provider.aliases) {
65
+ const cleanAlias = alias.trim();
66
+ if (!cleanAlias) {
67
+ continue;
68
+ }
69
+ if (trimmed === cleanAlias || trimmed.startsWith(`${cleanAlias}/`)) {
70
+ if (!bestMatch || cleanAlias.length > bestMatch.score) {
71
+ bestMatch = { name: provider.name, score: cleanAlias.length };
72
+ }
73
+ }
74
+ }
75
+ }
76
+ return bestMatch?.name ?? null;
77
+ }
13
78
 
14
79
  export function ModelConfig() {
15
80
  const { data: config, isLoading } = useConfig();
81
+ const { data: meta } = useConfigMeta();
16
82
  const { data: schema } = useConfigSchema();
17
83
  const updateModel = useUpdateModel();
18
84
 
19
- const [model, setModel] = useState('');
85
+ const [providerName, setProviderName] = useState('');
86
+ const [modelId, setModelId] = useState('');
20
87
  const [workspace, setWorkspace] = useState('');
21
88
  const [maxTokens, setMaxTokens] = useState(8192);
22
89
  const uiHints = schema?.uiHints;
@@ -24,17 +91,91 @@ export function ModelConfig() {
24
91
  const workspaceHint = hintForPath('agents.defaults.workspace', uiHints);
25
92
  const maxTokensHint = hintForPath('agents.defaults.maxTokens', uiHints);
26
93
 
94
+ const providerCatalog = useMemo(() => {
95
+ return (meta?.providers ?? []).map((provider) => {
96
+ const prefix = (provider.modelPrefix || provider.name || '').trim();
97
+ const aliases = normalizeStringList([provider.modelPrefix || '', provider.name || '']);
98
+ const defaultModels = normalizeStringList((provider.defaultModels ?? []).map((model) => toProviderLocalModel(model, aliases)));
99
+ const customModels = normalizeStringList(
100
+ (config?.providers?.[provider.name]?.models ?? []).map((model) => toProviderLocalModel(model, aliases))
101
+ );
102
+ const allModels = normalizeStringList([...defaultModels, ...customModels]);
103
+ return {
104
+ name: provider.name,
105
+ displayName: provider.displayName || provider.name,
106
+ prefix,
107
+ aliases,
108
+ models: allModels
109
+ };
110
+ });
111
+ }, [meta, config]);
112
+
113
+ const providerMap = useMemo(() => new Map(providerCatalog.map((provider) => [provider.name, provider])), [providerCatalog]);
114
+ const selectedProvider = providerMap.get(providerName) ?? providerCatalog[0];
115
+ const selectedProviderName = selectedProvider?.name ?? '';
116
+ const selectedProviderAliases = useMemo(() => selectedProvider?.aliases ?? [], [selectedProvider]);
117
+ const selectedProviderModels = useMemo(() => selectedProvider?.models ?? [], [selectedProvider]);
118
+
27
119
  useEffect(() => {
28
- if (config?.agents?.defaults) {
29
- setModel(config.agents.defaults.model || '');
30
- setWorkspace(config.agents.defaults.workspace || '');
31
- setMaxTokens(config.agents.defaults.maxTokens || 8192);
120
+ if (providerName || providerCatalog.length === 0) {
121
+ return;
122
+ }
123
+ setProviderName(providerCatalog[0].name);
124
+ }, [providerName, providerCatalog]);
125
+
126
+ useEffect(() => {
127
+ if (!config?.agents?.defaults) {
128
+ return;
129
+ }
130
+ const currentModel = (config.agents.defaults.model || '').trim();
131
+ const matchedProvider = findProviderByModel(currentModel, providerCatalog);
132
+ const effectiveProvider = matchedProvider ?? providerCatalog[0]?.name ?? '';
133
+ const aliases = providerMap.get(effectiveProvider)?.aliases ?? [];
134
+ setProviderName(effectiveProvider);
135
+ setModelId(toProviderLocalModel(currentModel, aliases));
136
+ setWorkspace(config.agents.defaults.workspace || '');
137
+ setMaxTokens(config.agents.defaults.maxTokens || 8192);
138
+ }, [config, providerCatalog, providerMap]);
139
+
140
+ const modelOptions = useMemo(() => {
141
+ const deduped = new Set<string>();
142
+ for (const modelName of selectedProviderModels) {
143
+ const trimmed = modelName.trim();
144
+ if (trimmed) {
145
+ deduped.add(trimmed);
146
+ }
147
+ }
148
+ return [...deduped];
149
+ }, [selectedProviderModels]);
150
+
151
+ const composedModel = useMemo(() => {
152
+ const normalizedModelId = toProviderLocalModel(modelId, selectedProviderAliases);
153
+ if (!normalizedModelId) {
154
+ return '';
155
+ }
156
+ if (!selectedProvider) {
157
+ return normalizedModelId;
32
158
  }
33
- }, [config]);
159
+ if (!selectedProvider.prefix) {
160
+ return normalizedModelId;
161
+ }
162
+ return `${selectedProvider.prefix}/${normalizedModelId}`;
163
+ }, [modelId, selectedProvider, selectedProviderAliases]);
164
+
165
+ const modelHelpText = modelHint?.help ?? '';
166
+
167
+ const handleProviderChange = (nextProvider: string) => {
168
+ setProviderName(nextProvider);
169
+ setModelId('');
170
+ };
171
+
172
+ const handleModelChange = (nextModelId: string) => {
173
+ setModelId(toProviderLocalModel(nextModelId, selectedProviderAliases));
174
+ };
34
175
 
35
176
  const handleSubmit = (e: React.FormEvent) => {
36
177
  e.preventDefault();
37
- updateModel.mutate({ model, maxTokens });
178
+ updateModel.mutate({ model: composedModel, maxTokens });
38
179
  };
39
180
 
40
181
  if (isLoading) {
@@ -88,17 +229,36 @@ export function ModelConfig() {
88
229
  <Label htmlFor="model" className="text-xs font-semibold text-gray-500 uppercase tracking-wider">
89
230
  {modelHint?.label ?? 'Model Name'}
90
231
  </Label>
91
- <Input
92
- id="model"
93
- value={model}
94
- onChange={(e) => setModel(e.target.value)}
95
- placeholder={modelHint?.placeholder ?? 'minimax/MiniMax-M2.1'}
96
- className="h-12 px-4 rounded-xl"
97
- />
98
- <p className="text-xs text-gray-400">
99
- {modelHint?.help ??
100
- 'Examples: gpt-5.1 · claude-opus-4-1 · deepseek/deepseek-chat · dashscope/qwen-max-latest · openrouter/openai/gpt-5.3-codex'}
101
- </p>
232
+ <div className="flex flex-col gap-2 sm:flex-row sm:items-center">
233
+ <div className="sm:w-[38%] sm:min-w-[170px]">
234
+ <Select value={selectedProviderName} onValueChange={handleProviderChange}>
235
+ <SelectTrigger className="h-10 w-full rounded-xl">
236
+ <SelectValue placeholder={t('providersSelectPlaceholder')} />
237
+ </SelectTrigger>
238
+ <SelectContent>
239
+ {providerCatalog.map((provider) => (
240
+ <SelectItem key={provider.name} value={provider.name}>
241
+ {provider.displayName}
242
+ </SelectItem>
243
+ ))}
244
+ </SelectContent>
245
+ </Select>
246
+ </div>
247
+ <span className="hidden h-10 items-center justify-center leading-none text-lg font-semibold text-gray-300 sm:inline-flex">/</span>
248
+ <SearchableModelInput
249
+ key={selectedProviderName}
250
+ id="model"
251
+ value={modelId}
252
+ onChange={handleModelChange}
253
+ options={modelOptions}
254
+ placeholder={modelHint?.placeholder ?? 'gpt-5.1'}
255
+ className="sm:flex-1"
256
+ inputClassName="h-10 rounded-xl"
257
+ emptyText={t('modelPickerNoOptions')}
258
+ createText={t('modelPickerUseCustom')}
259
+ />
260
+ </div>
261
+ <p className="text-xs text-gray-400">{modelHelpText}</p>
102
262
  <a
103
263
  href={`${DOCS_DEFAULT_BASE_URL}/guide/model-selection`}
104
264
  className="inline-flex items-center gap-1.5 text-xs text-primary hover:text-primary-hover transition-colors"
@@ -127,7 +287,7 @@ export function ModelConfig() {
127
287
  value={workspace}
128
288
  onChange={(e) => setWorkspace(e.target.value)}
129
289
  placeholder={workspaceHint?.placeholder ?? '/path/to/workspace'}
130
- className="h-12 px-4 rounded-xl"
290
+ className="rounded-xl"
131
291
  />
132
292
  </div>
133
293
  </div>