@nextclaw/ui 0.5.32 → 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.
- package/CHANGELOG.md +12 -0
- package/dist/assets/{ChannelsList-CroolNE8.js → ChannelsList-DvhJoVmJ.js} +1 -1
- package/dist/assets/{ChatPage-CNrt5Gfh.js → ChatPage-BSD-DBqC.js} +2 -2
- package/dist/assets/{CronConfig-CqjAHF25.js → CronConfig-JLIc66HM.js} +1 -1
- package/dist/assets/DocBrowser-BCJjJTr9.js +1 -0
- package/dist/assets/{MarketplacePage-CwRpEHu7.js → MarketplacePage-ftvI9vFu.js} +1 -1
- package/dist/assets/ModelConfig-iD7V4upL.js +1 -0
- package/dist/assets/ProvidersList-moYj5oBN.js +1 -0
- package/dist/assets/{RuntimeConfig-IfToQkFH.js → RuntimeConfig-CvWztear.js} +1 -1
- package/dist/assets/{SecretsConfig-BkizgSNf.js → SecretsConfig-Ds5xip01.js} +1 -1
- package/dist/assets/SessionsConfig-CCbracj_.js +2 -0
- package/dist/assets/{card-FVpEr1qe.js → card-CB1zsVbS.js} +1 -1
- package/dist/assets/{dialog-o8XweWHQ.js → dialog-BgbSAXFu.js} +2 -2
- package/dist/assets/index-B8Wh_FvS.css +1 -0
- package/dist/assets/index-O1Kus7pd.js +2 -0
- package/dist/assets/{label-PpMv4yho.js → label-d0bFiiuu.js} +1 -1
- package/dist/assets/{logos-B2FZM2a7.js → logos-gjlYO0d_.js} +1 -1
- package/dist/assets/{page-layout-DXAYLIUr.js → page-layout-Cf2nfjrs.js} +1 -1
- package/dist/assets/{switch-CGhksmrM.js → switch-CfELV89t.js} +1 -1
- package/dist/assets/{tabs-custom-4AOSVaDz.js → tabs-custom-CJdhCvOt.js} +1 -1
- package/dist/assets/{useConfig-CPpZMJSX.js → useConfig-DnBXFnGL.js} +2 -2
- package/dist/assets/{useConfirmDialog-DGWSgJUB.js → useConfirmDialog-c4_c0EGk.js} +1 -1
- package/dist/assets/{vendor-C8fQ0Vej.js → vendor-CmqkRoMs.js} +79 -74
- package/dist/index.html +3 -3
- package/package.json +1 -1
- package/src/api/client.ts +50 -3
- package/src/api/types.ts +21 -0
- package/src/components/chat/ChatPage.tsx +0 -1
- package/src/components/common/SearchableModelInput.tsx +156 -0
- package/src/components/config/ModelConfig.tsx +181 -21
- package/src/components/config/ProviderForm.tsx +187 -5
- package/src/lib/i18n.ts +12 -0
- package/dist/assets/DocBrowser-JxtaErnQ.js +0 -1
- package/dist/assets/ModelConfig-0bbUHFSf.js +0 -1
- package/dist/assets/ProvidersList-BluNat2q.js +0 -1
- package/dist/assets/SessionsConfig-CjVmna9f.js +0 -2
- package/dist/assets/index-nZ3tdMe1.js +0 -2
- 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-
|
|
10
|
-
<link rel="modulepreload" crossorigin href="/assets/vendor-
|
|
11
|
-
<link rel="stylesheet" crossorigin href="/assets/index-
|
|
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
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
|
|
47
|
-
const
|
|
48
|
-
|
|
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 {
|
|
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 [
|
|
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 (
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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
|
-
|
|
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
|
-
<
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
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="
|
|
290
|
+
className="rounded-xl"
|
|
131
291
|
/>
|
|
132
292
|
</div>
|
|
133
293
|
</div>
|