@nextclaw/ui 0.5.33 → 0.5.35
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 +15 -0
- package/LICENSE +21 -0
- package/dist/assets/ChannelsList-DGoIQT1t.js +1 -0
- package/dist/assets/{ChatPage-CeIP0Tot.js → ChatPage-C_wANEY9.js} +1 -1
- package/dist/assets/{CronConfig-UmNwDWoP.js → CronConfig-Q7faThLl.js} +1 -1
- package/dist/assets/DocBrowser-CdX5oDgu.js +1 -0
- package/dist/assets/{MarketplacePage-DxYm8B87.js → MarketplacePage-DXoPkFYk.js} +1 -1
- package/dist/assets/ModelConfig-DagIPD4R.js +1 -0
- package/dist/assets/ProvidersList-2aHarRNe.js +1 -0
- package/dist/assets/{RuntimeConfig-D_f-7U_f.js → RuntimeConfig-BDyqfVSA.js} +1 -1
- package/dist/assets/{SecretsConfig-rb2Q5BWo.js → SecretsConfig-De2IZ7GX.js} +1 -1
- package/dist/assets/SessionsConfig-BK0xx6EF.js +2 -0
- package/dist/assets/{card-Cn6iWntr.js → card-CWG4Tz0Y.js} +1 -1
- package/dist/assets/{dialog-BZ5jMhG9.js → dialog-ssdjbutm.js} +2 -2
- package/dist/assets/index-B8Wh_FvS.css +1 -0
- package/dist/assets/index-q2B1bssI.js +2 -0
- package/dist/assets/{label-N8QPWq03.js → label-LbWa2Yzc.js} +1 -1
- package/dist/assets/{logos-DLOpl3jc.js → logos-DncMldHC.js} +1 -1
- package/dist/assets/{page-layout-CLYPd-xa.js → page-layout-Bz8CAEiD.js} +1 -1
- package/dist/assets/{switch-B_AgwrJz.js → switch-BwbfObfA.js} +1 -1
- package/dist/assets/{tabs-custom-Cec2n_Lf.js → tabs-custom-VeX6BYro.js} +1 -1
- package/dist/assets/{useConfig-VlP0ShlX.js → useConfig-CFFZ66EV.js} +2 -2
- package/dist/assets/{useConfirmDialog-erF0KI-a.js → useConfirmDialog-ClpvgpHh.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 +26 -0
- package/src/components/common/SearchableModelInput.tsx +156 -0
- package/src/components/config/ChannelForm.tsx +15 -2
- package/src/components/config/ChannelsList.tsx +4 -4
- package/src/components/config/ModelConfig.tsx +181 -21
- package/src/components/config/ProviderForm.tsx +187 -5
- package/src/lib/channel-tutorials.ts +8 -0
- package/src/lib/i18n.ts +12 -0
- package/dist/assets/ChannelsList-C6_GL_XC.js +0 -1
- package/dist/assets/DocBrowser-CRGPwkiY.js +0 -1
- package/dist/assets/ModelConfig-c-DLS2TO.js +0 -1
- package/dist/assets/ProvidersList-B7spTkob.js +0 -1
- package/dist/assets/SessionsConfig-DqaFA8Nn.js +0 -2
- package/dist/assets/index-C2-PvYBh.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-q2B1bssI.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";
|
|
@@ -321,6 +342,11 @@ export type ChannelSpecView = {
|
|
|
321
342
|
displayName?: string;
|
|
322
343
|
enabled: boolean;
|
|
323
344
|
tutorialUrl?: string;
|
|
345
|
+
tutorialUrls?: {
|
|
346
|
+
default?: string;
|
|
347
|
+
en?: string;
|
|
348
|
+
zh?: string;
|
|
349
|
+
};
|
|
324
350
|
};
|
|
325
351
|
|
|
326
352
|
export type ConfigMetaView = {
|
|
@@ -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
|
+
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { useEffect, useState } from 'react';
|
|
2
|
-
import { useConfig, useConfigSchema, useUpdateChannel, useExecuteConfigAction } from '@/hooks/useConfig';
|
|
2
|
+
import { useConfig, useConfigMeta, useConfigSchema, useUpdateChannel, useExecuteConfigAction } from '@/hooks/useConfig';
|
|
3
3
|
import { useUiStore } from '@/stores/ui.store';
|
|
4
4
|
import {
|
|
5
5
|
Dialog,
|
|
@@ -18,8 +18,9 @@ import { TagInput } from '@/components/common/TagInput';
|
|
|
18
18
|
import { t } from '@/lib/i18n';
|
|
19
19
|
import { hintForPath } from '@/lib/config-hints';
|
|
20
20
|
import { toast } from 'sonner';
|
|
21
|
-
import { MessageCircle, Settings, ToggleLeft, Hash, Mail, Globe, KeyRound } from 'lucide-react';
|
|
21
|
+
import { MessageCircle, Settings, ToggleLeft, Hash, Mail, Globe, KeyRound, BookOpen } from 'lucide-react';
|
|
22
22
|
import type { ConfigActionManifest } from '@/api/types';
|
|
23
|
+
import { resolveChannelTutorialUrl } from '@/lib/channel-tutorials';
|
|
23
24
|
|
|
24
25
|
type ChannelFieldType = 'boolean' | 'text' | 'email' | 'password' | 'number' | 'tags' | 'select' | 'json';
|
|
25
26
|
type ChannelOption = { value: string; label: string };
|
|
@@ -210,6 +211,7 @@ function buildScopeDraft(scope: string, value: Record<string, unknown>): Record<
|
|
|
210
211
|
export function ChannelForm() {
|
|
211
212
|
const { channelModal, closeChannelModal } = useUiStore();
|
|
212
213
|
const { data: config } = useConfig();
|
|
214
|
+
const { data: meta } = useConfigMeta();
|
|
213
215
|
const { data: schema } = useConfigSchema();
|
|
214
216
|
const updateChannel = useUpdateChannel();
|
|
215
217
|
const executeAction = useExecuteConfigAction();
|
|
@@ -227,6 +229,8 @@ export function ChannelForm() {
|
|
|
227
229
|
const channelLabel = channelName
|
|
228
230
|
? hintForPath(`channels.${channelName}`, uiHints)?.label ?? channelName
|
|
229
231
|
: channelName;
|
|
232
|
+
const channelMeta = meta?.channels.find((item) => item.name === channelName);
|
|
233
|
+
const tutorialUrl = channelMeta ? resolveChannelTutorialUrl(channelMeta) : undefined;
|
|
230
234
|
|
|
231
235
|
useEffect(() => {
|
|
232
236
|
if (channelConfig) {
|
|
@@ -354,6 +358,15 @@ export function ChannelForm() {
|
|
|
354
358
|
<div>
|
|
355
359
|
<DialogTitle className="capitalize">{channelLabel}</DialogTitle>
|
|
356
360
|
<DialogDescription>{t('configureMessageChannelParameters')}</DialogDescription>
|
|
361
|
+
{tutorialUrl && (
|
|
362
|
+
<a
|
|
363
|
+
href={tutorialUrl}
|
|
364
|
+
className="mt-2 inline-flex items-center gap-1.5 text-xs text-primary hover:text-primary-hover transition-colors"
|
|
365
|
+
>
|
|
366
|
+
<BookOpen className="h-3.5 w-3.5" />
|
|
367
|
+
{t('channelsGuideTitle')}
|
|
368
|
+
</a>
|
|
369
|
+
)}
|
|
357
370
|
</div>
|
|
358
371
|
</div>
|
|
359
372
|
</DialogHeader>
|
|
@@ -13,6 +13,7 @@ import { ActionLink } from '@/components/ui/action-link';
|
|
|
13
13
|
import { cn } from '@/lib/utils';
|
|
14
14
|
import { t } from '@/lib/i18n';
|
|
15
15
|
import { PageLayout, PageHeader } from '@/components/layout/page-layout';
|
|
16
|
+
import { resolveChannelTutorialUrl } from '@/lib/channel-tutorials';
|
|
16
17
|
|
|
17
18
|
const channelIcons: Record<string, typeof MessageCircle> = {
|
|
18
19
|
telegram: MessageCircle,
|
|
@@ -66,6 +67,7 @@ export function ChannelsList() {
|
|
|
66
67
|
const enabled = channelConfig?.enabled || false;
|
|
67
68
|
const Icon = channelIcons[channel.name] || channelIcons.default;
|
|
68
69
|
const channelHint = hintForPath(`channels.${channel.name}`, uiHints);
|
|
70
|
+
const tutorialUrl = resolveChannelTutorialUrl(channel);
|
|
69
71
|
const description =
|
|
70
72
|
channelHint?.help ||
|
|
71
73
|
t(channelDescriptionKeys[channel.name] || 'channelDescriptionDefault');
|
|
@@ -98,11 +100,9 @@ export function ChannelsList() {
|
|
|
98
100
|
|
|
99
101
|
<ConfigCardFooter>
|
|
100
102
|
<ActionLink label={enabled ? t('actionConfigure') : t('actionEnable')} />
|
|
101
|
-
{
|
|
103
|
+
{tutorialUrl && (
|
|
102
104
|
<a
|
|
103
|
-
href={
|
|
104
|
-
target="_blank"
|
|
105
|
-
rel="noreferrer"
|
|
105
|
+
href={tutorialUrl}
|
|
106
106
|
onClick={(e) => e.stopPropagation()}
|
|
107
107
|
className="flex items-center justify-center h-6 w-6 rounded-md text-gray-300 hover:text-gray-500 hover:bg-gray-100/60 transition-colors"
|
|
108
108
|
title={t('channelsGuideTitle')}
|
|
@@ -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>
|