bingocode 1.0.14 → 1.0.15
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/LICENSE +29 -38
- package/package.json +3 -3
- package/src/cli/ProviderPanel.tsx +725 -725
- package/src/server/config/providerPresets.ts +93 -93
- package/src/server/config/providers.yaml +145 -145
|
@@ -1,725 +1,725 @@
|
|
|
1
|
-
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
|
2
|
-
import { Box, Text, useApp } from 'ink';
|
|
3
|
-
import SelectInput from 'ink-select-input';
|
|
4
|
-
import TextInput from 'ink-text-input';
|
|
5
|
-
import axios from 'axios';
|
|
6
|
-
|
|
7
|
-
type ProviderField = {
|
|
8
|
-
key: string;
|
|
9
|
-
label: string;
|
|
10
|
-
required?: boolean;
|
|
11
|
-
secret?: boolean;
|
|
12
|
-
placeholder?: string;
|
|
13
|
-
default?: string;
|
|
14
|
-
};
|
|
15
|
-
|
|
16
|
-
type Provider = {
|
|
17
|
-
id: string;
|
|
18
|
-
name?: string;
|
|
19
|
-
baseUrl?: string;
|
|
20
|
-
notes?: string;
|
|
21
|
-
isCurrent?: boolean;
|
|
22
|
-
};
|
|
23
|
-
|
|
24
|
-
type Preset = {
|
|
25
|
-
id: string;
|
|
26
|
-
label?: string;
|
|
27
|
-
name?: string;
|
|
28
|
-
desc?: string;
|
|
29
|
-
baseUrl?: string;
|
|
30
|
-
apiFormat?: string;
|
|
31
|
-
needsApiKey?: boolean;
|
|
32
|
-
websiteUrl?: string;
|
|
33
|
-
defaultModels?: { main: string; haiku: string; sonnet: string; opus: string };
|
|
34
|
-
fields?: ProviderField[];
|
|
35
|
-
};
|
|
36
|
-
|
|
37
|
-
type Stage =
|
|
38
|
-
| 'list'
|
|
39
|
-
| 'add_select_preset'
|
|
40
|
-
| 'add_input_fields'
|
|
41
|
-
| 'switch'
|
|
42
|
-
| 'test_select'
|
|
43
|
-
| 'delete_select'
|
|
44
|
-
| 'delete_confirm'
|
|
45
|
-
| 'testing'
|
|
46
|
-
| 'creating'
|
|
47
|
-
| 'removing'
|
|
48
|
-
| 'edit_select'
|
|
49
|
-
| 'edit_input_name'
|
|
50
|
-
| 'edit_input_key'
|
|
51
|
-
| 'editing'
|
|
52
|
-
| 'slot_config'
|
|
53
|
-
| 'slot_loading'
|
|
54
|
-
| 'slot_select_model';
|
|
55
|
-
|
|
56
|
-
export const ProviderPanel: React.FC<{
|
|
57
|
-
apiUrl: string;
|
|
58
|
-
onBack?: () => void;
|
|
59
|
-
}> = ({ apiUrl, onBack }) => {
|
|
60
|
-
const { exit } = useApp();
|
|
61
|
-
const [loading, setLoading] = useState(false);
|
|
62
|
-
const [err, setErr] = useState<string | null>(null);
|
|
63
|
-
|
|
64
|
-
const [providers, setProviders] = useState<Provider[]>([]);
|
|
65
|
-
const [currentId, setCurrentId] = useState<string | null>(null);
|
|
66
|
-
|
|
67
|
-
const [presets, setPresets] = useState<Preset[]>([]);
|
|
68
|
-
|
|
69
|
-
const [stage, setStage] = useState<Stage>('list');
|
|
70
|
-
|
|
71
|
-
// 新增流程(动态字段)
|
|
72
|
-
const [selectedPresetId, setSelectedPresetId] = useState<string | null>(null);
|
|
73
|
-
const [addFields, setAddFields] = useState<ProviderField[]>([]);
|
|
74
|
-
const [addFieldValues, setAddFieldValues] = useState<Record<string, string>>({});
|
|
75
|
-
const [addFieldIndex, setAddFieldIndex] = useState(0);
|
|
76
|
-
|
|
77
|
-
const [opMsg, setOpMsg] = useState<string | null>(null);
|
|
78
|
-
const [selectedId, setSelectedId] = useState<string | null>(null);
|
|
79
|
-
|
|
80
|
-
// 编辑所需状态
|
|
81
|
-
const [editId, setEditId] = useState<string | null>(null);
|
|
82
|
-
const [editName, setEditName] = useState('');
|
|
83
|
-
const [editKey, setEditKey] = useState('');
|
|
84
|
-
|
|
85
|
-
// 槽位配置状态
|
|
86
|
-
type SlotEntry = { providerId: string; modelId: string } | null;
|
|
87
|
-
const [slotTable, setSlotTable] = useState<Record<string, SlotEntry>>({});
|
|
88
|
-
const [slotProviderModels, setSlotProviderModels] = useState<Record<string, string[]>>({});
|
|
89
|
-
const [currentSlotName, setCurrentSlotName] = useState<string>('main');
|
|
90
|
-
const [slotLoadingMsg, setSlotLoadingMsg] = useState('');
|
|
91
|
-
|
|
92
|
-
const base = apiUrl.replace(/\/+$/, '');
|
|
93
|
-
|
|
94
|
-
const parseListResp = (data: any): { list: Provider[]; currentId: string | null } => {
|
|
95
|
-
if (Array.isArray(data)) {
|
|
96
|
-
const cur = data.find((p: any) => p.isCurrent)?.id ?? null;
|
|
97
|
-
return { list: data, currentId: cur };
|
|
98
|
-
}
|
|
99
|
-
const list = data?.providers || data?.list || data?.items || [];
|
|
100
|
-
const cur =
|
|
101
|
-
data?.currentId ??
|
|
102
|
-
list.find((p: any) => p.isCurrent)?.id ??
|
|
103
|
-
null;
|
|
104
|
-
return { list, currentId: cur };
|
|
105
|
-
};
|
|
106
|
-
|
|
107
|
-
const loadProviders = useCallback(async () => {
|
|
108
|
-
setLoading(true); setErr(null);
|
|
109
|
-
try {
|
|
110
|
-
const res = await axios.get(`${base}/api/providers`);
|
|
111
|
-
const { list, currentId } = parseListResp(res.data);
|
|
112
|
-
setProviders(list || []);
|
|
113
|
-
setCurrentId(currentId);
|
|
114
|
-
} catch (e: any) {
|
|
115
|
-
setErr(e?.message || '获取 Provider 列表失败');
|
|
116
|
-
} finally {
|
|
117
|
-
setLoading(false);
|
|
118
|
-
}
|
|
119
|
-
}, [base]);
|
|
120
|
-
|
|
121
|
-
const loadPresets = useCallback(async () => {
|
|
122
|
-
try {
|
|
123
|
-
const res = await axios.get(`${base}/api/providers/presets`);
|
|
124
|
-
const data = Array.isArray(res.data) ? res.data : (res.data?.presets || res.data?.list || []);
|
|
125
|
-
setPresets(data || []);
|
|
126
|
-
} catch (e) {
|
|
127
|
-
setPresets([]);
|
|
128
|
-
}
|
|
129
|
-
}, [base]);
|
|
130
|
-
|
|
131
|
-
useEffect(() => {
|
|
132
|
-
loadProviders();
|
|
133
|
-
loadPresets();
|
|
134
|
-
}, [loadProviders, loadPresets]);
|
|
135
|
-
|
|
136
|
-
// ESC 处理:子页返回列表;列表再触发 onBack(或退出)
|
|
137
|
-
useEffect(() => {
|
|
138
|
-
const handler = (buf: Buffer) => {
|
|
139
|
-
const key = buf.toString();
|
|
140
|
-
if (key === '\u001b') {
|
|
141
|
-
if (stage === 'slot_select_model' || stage === 'slot_loading') {
|
|
142
|
-
setStage('slot_config');
|
|
143
|
-
setErr(null);
|
|
144
|
-
} else if (stage === 'slot_config') {
|
|
145
|
-
setStage('list');
|
|
146
|
-
setErr(null);
|
|
147
|
-
} else if (stage !== 'list') {
|
|
148
|
-
setStage('list');
|
|
149
|
-
setSelectedPresetId(null);
|
|
150
|
-
setAddFields([]);
|
|
151
|
-
setAddFieldValues({});
|
|
152
|
-
setAddFieldIndex(0);
|
|
153
|
-
setSelectedId(null);
|
|
154
|
-
setOpMsg(null);
|
|
155
|
-
setErr(null);
|
|
156
|
-
setEditId(null);
|
|
157
|
-
setEditName('');
|
|
158
|
-
setEditKey('');
|
|
159
|
-
} else {
|
|
160
|
-
onBack ? onBack() : exit();
|
|
161
|
-
}
|
|
162
|
-
}
|
|
163
|
-
};
|
|
164
|
-
process.stdin.on('data', handler);
|
|
165
|
-
return () => process.stdin.off('data', handler);
|
|
166
|
-
}, [stage, onBack, exit]);
|
|
167
|
-
|
|
168
|
-
const currentProvider = useMemo(
|
|
169
|
-
() => providers.find(p => (currentId ? p.id === currentId : p.isCurrent)),
|
|
170
|
-
[providers, currentId]
|
|
171
|
-
);
|
|
172
|
-
|
|
173
|
-
// Actions
|
|
174
|
-
const doSetCurrent = async (id: string) => {
|
|
175
|
-
setErr(null); setOpMsg(null);
|
|
176
|
-
try {
|
|
177
|
-
await axios.post(`${base}/api/providers/current`, { id });
|
|
178
|
-
setOpMsg(`已切换当前 Provider -> ${id}`);
|
|
179
|
-
await loadProviders();
|
|
180
|
-
setStage('list');
|
|
181
|
-
} catch (e: any) {
|
|
182
|
-
setErr(e?.response?.data?.message || e?.message || '切换失败');
|
|
183
|
-
}
|
|
184
|
-
};
|
|
185
|
-
|
|
186
|
-
const doCreate = async (
|
|
187
|
-
presetId: string,
|
|
188
|
-
name: string,
|
|
189
|
-
apiKey: string,
|
|
190
|
-
baseUrl?: string,
|
|
191
|
-
extra?: Record<string, string>,
|
|
192
|
-
) => {
|
|
193
|
-
setStage('creating');
|
|
194
|
-
setErr(null); setOpMsg(null);
|
|
195
|
-
try {
|
|
196
|
-
// 从预设补全 baseUrl 和 models(前端填的 baseUrl 优先)
|
|
197
|
-
const preset = presets.find(p => p.id === presetId);
|
|
198
|
-
const resolvedBaseUrl = baseUrl || preset?.baseUrl || '';
|
|
199
|
-
const resolvedModels = preset?.defaultModels || { main: '', haiku: '', sonnet: '', opus: '' };
|
|
200
|
-
|
|
201
|
-
const body: Record<string, unknown> = {
|
|
202
|
-
presetId,
|
|
203
|
-
name,
|
|
204
|
-
apiKey,
|
|
205
|
-
baseUrl: resolvedBaseUrl,
|
|
206
|
-
models: resolvedModels,
|
|
207
|
-
...(preset?.apiFormat && { apiFormat: preset.apiFormat }),
|
|
208
|
-
};
|
|
209
|
-
if (extra && Object.keys(extra).length > 0) body.extra = extra;
|
|
210
|
-
await axios.post(`${base}/api/providers`, body);
|
|
211
|
-
setOpMsg(`创建成功 -> ${name}`);
|
|
212
|
-
await loadProviders();
|
|
213
|
-
setStage('list');
|
|
214
|
-
} catch (e: any) {
|
|
215
|
-
setErr(e?.response?.data?.message || e?.message || '创建失败');
|
|
216
|
-
// 回到最后一个字段让用户看到错误,而不是触发再次提交
|
|
217
|
-
setAddFieldIndex(Math.max(0, addFields.length - 1));
|
|
218
|
-
setStage('add_input_fields');
|
|
219
|
-
}
|
|
220
|
-
};
|
|
221
|
-
|
|
222
|
-
const doTest = async (id: string) => {
|
|
223
|
-
setStage('testing');
|
|
224
|
-
setErr(null); setOpMsg('测试中...');
|
|
225
|
-
try {
|
|
226
|
-
const res = await axios.post(`${base}/api/providers/${encodeURIComponent(id)}/test`);
|
|
227
|
-
const ok = res?.data?.ok ?? true;
|
|
228
|
-
setOpMsg(ok ? `连通性正常 -> ${id}` : `连通性异常 -> ${id}`);
|
|
229
|
-
} catch (e: any) {
|
|
230
|
-
setErr(e?.response?.data?.message || e?.message || `测试失败 -> ${id}`);
|
|
231
|
-
} finally {
|
|
232
|
-
setStage('list');
|
|
233
|
-
await loadProviders();
|
|
234
|
-
}
|
|
235
|
-
};
|
|
236
|
-
|
|
237
|
-
const doEdit = async (id: string, name: string, apiKey: string) => {
|
|
238
|
-
setStage('editing');
|
|
239
|
-
setErr(null); setOpMsg(null);
|
|
240
|
-
try {
|
|
241
|
-
const updates: Record<string, string> = {};
|
|
242
|
-
if (name.trim()) updates.name = name.trim();
|
|
243
|
-
if (apiKey.trim()) updates.apiKey = apiKey.trim();
|
|
244
|
-
await axios.put(`${base}/api/providers/${encodeURIComponent(id)}`, updates);
|
|
245
|
-
setOpMsg(`已更新 Provider -> ${name.trim() || id}`);
|
|
246
|
-
await loadProviders();
|
|
247
|
-
setStage('list');
|
|
248
|
-
} catch (e: any) {
|
|
249
|
-
setErr(e?.response?.data?.message || e?.message || '编辑失败');
|
|
250
|
-
setStage('edit_input_key');
|
|
251
|
-
}
|
|
252
|
-
};
|
|
253
|
-
|
|
254
|
-
const doRemove = async (id: string) => {
|
|
255
|
-
setStage('removing');
|
|
256
|
-
setErr(null); setOpMsg(null);
|
|
257
|
-
try {
|
|
258
|
-
await axios.delete(`${base}/api/providers/${encodeURIComponent(id)}`);
|
|
259
|
-
setOpMsg(`已删除 Provider -> ${id}`);
|
|
260
|
-
await loadProviders();
|
|
261
|
-
setStage('list');
|
|
262
|
-
} catch (e: any) {
|
|
263
|
-
setErr(e?.response?.data?.message || e?.message || '删除失败');
|
|
264
|
-
setStage('list');
|
|
265
|
-
}
|
|
266
|
-
};
|
|
267
|
-
|
|
268
|
-
const MAX_LIST = 5;
|
|
269
|
-
|
|
270
|
-
// 渲染块
|
|
271
|
-
const renderList = () => {
|
|
272
|
-
const visibleProviders = providers.slice(0, MAX_LIST);
|
|
273
|
-
const overflow = providers.length - MAX_LIST;
|
|
274
|
-
return (
|
|
275
|
-
<Box flexDirection="column">
|
|
276
|
-
<Text color="cyan">Provider 列表</Text>
|
|
277
|
-
{!providers.length && !loading && <Text>暂无 Provider</Text>}
|
|
278
|
-
{visibleProviders.map(p => {
|
|
279
|
-
const isCur = currentProvider && (currentProvider.id === p.id);
|
|
280
|
-
return (
|
|
281
|
-
<Text key={p.id} color={isCur ? 'green' : undefined} bold={isCur}>
|
|
282
|
-
{p.name || '-'}{isCur ? ' ← 当前' : ''}
|
|
283
|
-
</Text>
|
|
284
|
-
);
|
|
285
|
-
})}
|
|
286
|
-
{overflow > 0 && <Text dimColor> ...还有 {overflow} 个</Text>}
|
|
287
|
-
{currentProvider && (
|
|
288
|
-
<Text dimColor>
|
|
289
|
-
当前 Provider: {currentProvider.name || currentProvider.id}
|
|
290
|
-
</Text>
|
|
291
|
-
)}
|
|
292
|
-
{loading && <Text color="yellow">加载中...</Text>}
|
|
293
|
-
{err && <Text color="red">{err}</Text>}
|
|
294
|
-
{opMsg && <Text color="green">{opMsg}</Text>}
|
|
295
|
-
|
|
296
|
-
<Box marginTop={1} flexDirection="column">
|
|
297
|
-
<SelectInput
|
|
298
|
-
items={[
|
|
299
|
-
{ label: '新增 Provider', value: 'add' },
|
|
300
|
-
{ label: '编辑 Provider(名称/API Key)', value: 'edit' },
|
|
301
|
-
{ label: '配置槽位(main/haiku/sonnet/opus)', value: 'slots' },
|
|
302
|
-
{ label: '切换当前 Provider', value: 'switch' },
|
|
303
|
-
{ label: '连通性测试', value: 'test' },
|
|
304
|
-
{ label: '删除 Provider', value: 'delete' },
|
|
305
|
-
{ label: '刷新', value: 'refresh' },
|
|
306
|
-
{ label: '返回主菜单(ESC)', value: 'back' },
|
|
307
|
-
]}
|
|
308
|
-
onSelect={item => {
|
|
309
|
-
switch (item.value) {
|
|
310
|
-
case 'add':
|
|
311
|
-
setSelectedPresetId(null);
|
|
312
|
-
setAddFields([]);
|
|
313
|
-
setAddFieldValues({});
|
|
314
|
-
setAddFieldIndex(0);
|
|
315
|
-
setStage('add_select_preset');
|
|
316
|
-
break;
|
|
317
|
-
case 'edit':
|
|
318
|
-
setEditId(null);
|
|
319
|
-
setEditName('');
|
|
320
|
-
setEditKey('');
|
|
321
|
-
setStage('edit_select');
|
|
322
|
-
break;
|
|
323
|
-
case 'slots':
|
|
324
|
-
axios.get(`${base}/api/providers/slots`)
|
|
325
|
-
.then(r => setSlotTable(r.data as Record<string, SlotEntry>))
|
|
326
|
-
.catch(() => {});
|
|
327
|
-
setStage('slot_config');
|
|
328
|
-
break;
|
|
329
|
-
case 'switch':
|
|
330
|
-
setStage('switch');
|
|
331
|
-
break;
|
|
332
|
-
case 'test':
|
|
333
|
-
setStage('test_select');
|
|
334
|
-
break;
|
|
335
|
-
case 'delete':
|
|
336
|
-
setStage('delete_select');
|
|
337
|
-
break;
|
|
338
|
-
case 'refresh':
|
|
339
|
-
loadProviders();
|
|
340
|
-
break;
|
|
341
|
-
case 'back':
|
|
342
|
-
onBack ? onBack() : null;
|
|
343
|
-
break;
|
|
344
|
-
}
|
|
345
|
-
}}
|
|
346
|
-
/>
|
|
347
|
-
<Text dimColor>提示:ESC 返回。↑↓/回车 选择操作</Text>
|
|
348
|
-
</Box>
|
|
349
|
-
</Box>
|
|
350
|
-
);
|
|
351
|
-
};
|
|
352
|
-
|
|
353
|
-
if (stage === 'list') return renderList();
|
|
354
|
-
|
|
355
|
-
if (stage === 'add_select_preset') {
|
|
356
|
-
const items = (presets || []).map(pr => ({
|
|
357
|
-
label: pr.websiteUrl
|
|
358
|
-
? `${pr.label || pr.name || pr.id} ${pr.websiteUrl}`
|
|
359
|
-
: `${pr.label || pr.name || pr.id}`,
|
|
360
|
-
value: pr.id
|
|
361
|
-
}));
|
|
362
|
-
if (!items.length) {
|
|
363
|
-
return (
|
|
364
|
-
<Box flexDirection="column">
|
|
365
|
-
<Text color="yellow">未获取到预设,请先确保主服务已启动。</Text>
|
|
366
|
-
<SelectInput
|
|
367
|
-
items={[{ label: '← 返回', value: 'back' }]}
|
|
368
|
-
onSelect={() => setStage('list')}
|
|
369
|
-
/>
|
|
370
|
-
</Box>
|
|
371
|
-
);
|
|
372
|
-
}
|
|
373
|
-
return (
|
|
374
|
-
<Box flexDirection="column">
|
|
375
|
-
<Text>选择预设:</Text>
|
|
376
|
-
<SelectInput
|
|
377
|
-
items={items}
|
|
378
|
-
onSelect={it => {
|
|
379
|
-
const preset = presets.find(p => p.id === (it.value as string));
|
|
380
|
-
// 从 preset.fields 获取字段列表;若为空则用默认最小集
|
|
381
|
-
const fields: ProviderField[] =
|
|
382
|
-
preset?.fields && preset.fields.length > 0
|
|
383
|
-
? preset.fields
|
|
384
|
-
: [
|
|
385
|
-
{ key: 'name', label: 'Provider 昵称', required: true },
|
|
386
|
-
{ key: 'apiKey', label: 'API Key', required: true, secret: true },
|
|
387
|
-
];
|
|
388
|
-
setSelectedPresetId(it.value as string);
|
|
389
|
-
setAddFields(fields);
|
|
390
|
-
setAddFieldValues({});
|
|
391
|
-
setAddFieldIndex(0);
|
|
392
|
-
setStage('add_input_fields');
|
|
393
|
-
}}
|
|
394
|
-
/>
|
|
395
|
-
<Text dimColor>ESC 返回</Text>
|
|
396
|
-
</Box>
|
|
397
|
-
);
|
|
398
|
-
}
|
|
399
|
-
|
|
400
|
-
if (stage === 'add_input_fields') {
|
|
401
|
-
const field = addFields[addFieldIndex];
|
|
402
|
-
if (!field) {
|
|
403
|
-
// 防御性分支:所有字段已填完但还没触发提交(一般不应走到这里)
|
|
404
|
-
return <Text color="yellow">创建中...</Text>;
|
|
405
|
-
}
|
|
406
|
-
|
|
407
|
-
const currentVal = addFieldValues[field.key] ?? field.default ?? '';
|
|
408
|
-
|
|
409
|
-
const handleSubmit = (submittedVal: string) => {
|
|
410
|
-
// ink-text-input passes the current value to onSubmit
|
|
411
|
-
const val = submittedVal;
|
|
412
|
-
if (field.required && !val.trim()) return; // 必填不允许空
|
|
413
|
-
|
|
414
|
-
// 确保最新值已写入
|
|
415
|
-
const merged = { ...addFieldValues, [field.key]: val };
|
|
416
|
-
|
|
417
|
-
const nextIndex = addFieldIndex + 1;
|
|
418
|
-
if (nextIndex < addFields.length) {
|
|
419
|
-
setAddFieldValues(merged);
|
|
420
|
-
setAddFieldIndex(nextIndex);
|
|
421
|
-
} else {
|
|
422
|
-
// 最后一个字段提交,触发创建
|
|
423
|
-
const name = merged['name'] || '';
|
|
424
|
-
const apiKey = merged['apiKey'] || '';
|
|
425
|
-
const baseUrl = merged['baseUrl'] || '';
|
|
426
|
-
const extra: Record<string, string> = {};
|
|
427
|
-
for (const [k, v] of Object.entries(merged)) {
|
|
428
|
-
if (!['name', 'apiKey', 'baseUrl'].includes(k) && v) extra[k] = v;
|
|
429
|
-
}
|
|
430
|
-
setAddFieldValues(merged);
|
|
431
|
-
void doCreate(selectedPresetId!, name, apiKey, baseUrl || undefined, extra);
|
|
432
|
-
}
|
|
433
|
-
};
|
|
434
|
-
|
|
435
|
-
return (
|
|
436
|
-
<Box flexDirection="column">
|
|
437
|
-
<Text color="cyan">
|
|
438
|
-
新增 Provider — 字段 {addFieldIndex + 1}/{addFields.length}
|
|
439
|
-
</Text>
|
|
440
|
-
<Text>
|
|
441
|
-
{field.label}{field.required ? <Text color="red"> *</Text> : ''}
|
|
442
|
-
{field.placeholder ? <Text dimColor> ({field.placeholder})</Text> : ''}:
|
|
443
|
-
</Text>
|
|
444
|
-
<TextInput
|
|
445
|
-
value={currentVal}
|
|
446
|
-
onChange={v => setAddFieldValues(prev => ({ ...prev, [field.key]: v }))}
|
|
447
|
-
// @ts-ignore - ink-text-input supports mask prop
|
|
448
|
-
mask={field.secret ? '*' : undefined}
|
|
449
|
-
onSubmit={handleSubmit}
|
|
450
|
-
/>
|
|
451
|
-
{err && <Text color="red">{err}</Text>}
|
|
452
|
-
<Text dimColor>回车继续,ESC 返回列表</Text>
|
|
453
|
-
</Box>
|
|
454
|
-
);
|
|
455
|
-
}
|
|
456
|
-
|
|
457
|
-
if (stage === 'creating') {
|
|
458
|
-
return <Text color="yellow">创建中...</Text>;
|
|
459
|
-
}
|
|
460
|
-
|
|
461
|
-
if (stage === 'switch') {
|
|
462
|
-
const items = providers.map(p => ({
|
|
463
|
-
label: `${p.name || p.id}${(currentId === p.id || p.isCurrent) ? ' ← 当前' : ''}`,
|
|
464
|
-
value: p.id
|
|
465
|
-
}));
|
|
466
|
-
return (
|
|
467
|
-
<Box flexDirection="column">
|
|
468
|
-
<Text>选择要设为当前的 Provider:</Text>
|
|
469
|
-
<SelectInput
|
|
470
|
-
items={items}
|
|
471
|
-
onSelect={it => doSetCurrent(it.value as string)}
|
|
472
|
-
/>
|
|
473
|
-
{err && <Text color="red">{err}</Text>}
|
|
474
|
-
<Text dimColor>ESC 返回</Text>
|
|
475
|
-
</Box>
|
|
476
|
-
);
|
|
477
|
-
}
|
|
478
|
-
|
|
479
|
-
if (stage === 'test_select') {
|
|
480
|
-
const items = providers.map(p => ({
|
|
481
|
-
label: `${p.name || p.id}`,
|
|
482
|
-
value: p.id
|
|
483
|
-
}));
|
|
484
|
-
return (
|
|
485
|
-
<Box flexDirection="column">
|
|
486
|
-
<Text>选择要测试的 Provider:</Text>
|
|
487
|
-
<SelectInput
|
|
488
|
-
items={items}
|
|
489
|
-
onSelect={it => doTest(it.value as string)}
|
|
490
|
-
/>
|
|
491
|
-
{err && <Text color="red">{err}</Text>}
|
|
492
|
-
<Text dimColor>ESC 返回</Text>
|
|
493
|
-
</Box>
|
|
494
|
-
);
|
|
495
|
-
}
|
|
496
|
-
|
|
497
|
-
if (stage === 'testing') {
|
|
498
|
-
return <Text color="yellow">测试中...</Text>;
|
|
499
|
-
}
|
|
500
|
-
|
|
501
|
-
if (stage === 'delete_select') {
|
|
502
|
-
const items = providers.map(p => ({
|
|
503
|
-
label: `${p.name || p.id}`,
|
|
504
|
-
value: p.id
|
|
505
|
-
}));
|
|
506
|
-
return (
|
|
507
|
-
<Box flexDirection="column">
|
|
508
|
-
<Text color="red">选择要删除的 Provider:</Text>
|
|
509
|
-
<SelectInput
|
|
510
|
-
items={items}
|
|
511
|
-
onSelect={it => {
|
|
512
|
-
setSelectedId(it.value as string);
|
|
513
|
-
setStage('delete_confirm');
|
|
514
|
-
}}
|
|
515
|
-
/>
|
|
516
|
-
<Text dimColor>ESC 返回</Text>
|
|
517
|
-
</Box>
|
|
518
|
-
);
|
|
519
|
-
}
|
|
520
|
-
|
|
521
|
-
if (stage === 'delete_confirm') {
|
|
522
|
-
if (!selectedId) { setStage('list'); return null; }
|
|
523
|
-
return (
|
|
524
|
-
<Box flexDirection="column">
|
|
525
|
-
<Text color="red">确认删除 Provider:{selectedId} ?</Text>
|
|
526
|
-
<SelectInput
|
|
527
|
-
items={[
|
|
528
|
-
{ label: '是,删除', value: 'yes' },
|
|
529
|
-
{ label: '否,返回', value: 'no' }
|
|
530
|
-
]}
|
|
531
|
-
onSelect={it => {
|
|
532
|
-
if (it.value === 'no') {
|
|
533
|
-
setStage('list');
|
|
534
|
-
} else {
|
|
535
|
-
void doRemove(selectedId);
|
|
536
|
-
}
|
|
537
|
-
}}
|
|
538
|
-
/>
|
|
539
|
-
{err && <Text color="red">{err}</Text>}
|
|
540
|
-
<Text dimColor>ESC 返回</Text>
|
|
541
|
-
</Box>
|
|
542
|
-
);
|
|
543
|
-
}
|
|
544
|
-
|
|
545
|
-
if (stage === 'removing') {
|
|
546
|
-
return <Text color="yellow">删除中...</Text>;
|
|
547
|
-
}
|
|
548
|
-
|
|
549
|
-
if (stage === 'edit_select') {
|
|
550
|
-
const items = providers.map(p => ({
|
|
551
|
-
label: `${p.name || p.id}${(currentId === p.id || p.isCurrent) ? ' ← 当前' : ''}`,
|
|
552
|
-
value: p.id,
|
|
553
|
-
}));
|
|
554
|
-
if (!items.length) {
|
|
555
|
-
return (
|
|
556
|
-
<Box flexDirection="column">
|
|
557
|
-
<Text color="yellow">暂无 Provider 可编辑。</Text>
|
|
558
|
-
<SelectInput items={[{ label: '← 返回', value: 'back' }]} onSelect={() => setStage('list')} />
|
|
559
|
-
</Box>
|
|
560
|
-
);
|
|
561
|
-
}
|
|
562
|
-
return (
|
|
563
|
-
<Box flexDirection="column">
|
|
564
|
-
<Text>选择要编辑的 Provider:</Text>
|
|
565
|
-
<SelectInput
|
|
566
|
-
items={items}
|
|
567
|
-
onSelect={it => {
|
|
568
|
-
const p = providers.find(p => p.id === it.value);
|
|
569
|
-
setEditId(it.value as string);
|
|
570
|
-
setEditName(p?.name || '');
|
|
571
|
-
setEditKey('');
|
|
572
|
-
setStage('edit_input_name');
|
|
573
|
-
}}
|
|
574
|
-
/>
|
|
575
|
-
<Text dimColor>ESC 返回</Text>
|
|
576
|
-
</Box>
|
|
577
|
-
);
|
|
578
|
-
}
|
|
579
|
-
|
|
580
|
-
if (stage === 'edit_input_name') {
|
|
581
|
-
return (
|
|
582
|
-
<Box flexDirection="column">
|
|
583
|
-
<Text>编辑名称(当前:<Text color="cyan">{editName}</Text>,回车保留不变):</Text>
|
|
584
|
-
<TextInput
|
|
585
|
-
value={editName}
|
|
586
|
-
onChange={setEditName}
|
|
587
|
-
onSubmit={() => setStage('edit_input_key')}
|
|
588
|
-
/>
|
|
589
|
-
<Text dimColor>回车继续,ESC 返回</Text>
|
|
590
|
-
</Box>
|
|
591
|
-
);
|
|
592
|
-
}
|
|
593
|
-
|
|
594
|
-
if (stage === 'edit_input_key') {
|
|
595
|
-
return (
|
|
596
|
-
<Box flexDirection="column">
|
|
597
|
-
<Text>输入新 API Key(留空则不修改):</Text>
|
|
598
|
-
<TextInput
|
|
599
|
-
value={editKey}
|
|
600
|
-
onChange={setEditKey}
|
|
601
|
-
// @ts-ignore
|
|
602
|
-
mask="*"
|
|
603
|
-
onSubmit={() => {
|
|
604
|
-
if (!editId) { setStage('list'); return; }
|
|
605
|
-
doEdit(editId, editName, editKey);
|
|
606
|
-
}}
|
|
607
|
-
/>
|
|
608
|
-
{err && <Text color="red">{err}</Text>}
|
|
609
|
-
<Text dimColor>回车保存,ESC 返回</Text>
|
|
610
|
-
</Box>
|
|
611
|
-
);
|
|
612
|
-
}
|
|
613
|
-
|
|
614
|
-
if (stage === 'editing') {
|
|
615
|
-
return <Text color="yellow">保存中...</Text>;
|
|
616
|
-
}
|
|
617
|
-
|
|
618
|
-
if (stage === 'slot_config') {
|
|
619
|
-
const SLOTS = ['main', 'haiku', 'sonnet', 'opus'] as const;
|
|
620
|
-
const SLOT_DESCS: Record<string, string> = {
|
|
621
|
-
main: '主力模型,复杂推理、长上下文、代码生成等高要求任务',
|
|
622
|
-
haiku: '快速轻量,简单问答、自动补全、低延迟响应',
|
|
623
|
-
sonnet: '均衡模型,兼顾质量与速度,适合日常对话',
|
|
624
|
-
opus: '最强模型,深度推理与复杂分析(调用量通常最少)',
|
|
625
|
-
};
|
|
626
|
-
const items = SLOTS.map(s => {
|
|
627
|
-
const entry = slotTable[s];
|
|
628
|
-
const providerName = entry
|
|
629
|
-
? (providers.find(p => p.id === entry.providerId)?.name || entry.providerId)
|
|
630
|
-
: null;
|
|
631
|
-
const status = entry ? `${providerName} / ${entry.modelId}` : '未配置';
|
|
632
|
-
const label = `[${s}] ${status} — ${SLOT_DESCS[s]}`;
|
|
633
|
-
return { label, value: s };
|
|
634
|
-
});
|
|
635
|
-
return (
|
|
636
|
-
<Box flexDirection="column">
|
|
637
|
-
<Text color="cyan">配置槽位(选中槽位后选择模型)</Text>
|
|
638
|
-
{err && <Text color="red">{err}</Text>}
|
|
639
|
-
{opMsg && <Text color="green">{opMsg}</Text>}
|
|
640
|
-
<SelectInput
|
|
641
|
-
items={[...items, { label: '← 返回主菜单', value: 'back' }]}
|
|
642
|
-
onSelect={it => {
|
|
643
|
-
if (it.value === 'back') { setStage('list'); setErr(null); return; }
|
|
644
|
-
setCurrentSlotName(it.value as string);
|
|
645
|
-
setErr(null);
|
|
646
|
-
setSlotLoadingMsg('正在获取模型列表...');
|
|
647
|
-
setStage('slot_loading');
|
|
648
|
-
Promise.all(
|
|
649
|
-
providers.map(p =>
|
|
650
|
-
axios.get(`${base}/api/providers/${encodeURIComponent(p.id)}/models`)
|
|
651
|
-
.then(r => ({ id: p.id, models: (r.data as { models: string[] }).models }))
|
|
652
|
-
.catch(() => ({ id: p.id, models: [] as string[] }))
|
|
653
|
-
)
|
|
654
|
-
).then(results => {
|
|
655
|
-
const map: Record<string, string[]> = {};
|
|
656
|
-
results.forEach(r => { map[r.id] = r.models; });
|
|
657
|
-
setSlotProviderModels(map);
|
|
658
|
-
setStage('slot_select_model');
|
|
659
|
-
});
|
|
660
|
-
}}
|
|
661
|
-
/>
|
|
662
|
-
<Text dimColor>ESC 返回</Text>
|
|
663
|
-
</Box>
|
|
664
|
-
);
|
|
665
|
-
}
|
|
666
|
-
|
|
667
|
-
if (stage === 'slot_loading') {
|
|
668
|
-
return <Text color="yellow">{slotLoadingMsg}</Text>;
|
|
669
|
-
}
|
|
670
|
-
|
|
671
|
-
if (stage === 'slot_select_model') {
|
|
672
|
-
const items: Array<{ label: string; value: string }> = [];
|
|
673
|
-
providers.forEach(p => {
|
|
674
|
-
const models = slotProviderModels[p.id] || [];
|
|
675
|
-
if (models.length === 0) return;
|
|
676
|
-
items.push({ label: `── ${p.name || p.id} ──`, value: `__header__${p.id}` });
|
|
677
|
-
models.forEach(m => items.push({ label: ` ${m}`, value: `${p.id}::${m}` }));
|
|
678
|
-
});
|
|
679
|
-
|
|
680
|
-
if (items.length === 0) {
|
|
681
|
-
return (
|
|
682
|
-
<Box flexDirection="column">
|
|
683
|
-
<Text color="red">没有可用的模型(所有 Provider 连接失败或未返回模型列表)</Text>
|
|
684
|
-
<SelectInput
|
|
685
|
-
items={[{ label: '← 返回', value: 'back' }]}
|
|
686
|
-
onSelect={() => setStage('slot_config')}
|
|
687
|
-
/>
|
|
688
|
-
</Box>
|
|
689
|
-
);
|
|
690
|
-
}
|
|
691
|
-
|
|
692
|
-
return (
|
|
693
|
-
<Box flexDirection="column">
|
|
694
|
-
<Text color="cyan">配置槽位 [{currentSlotName}] — 选择模型</Text>
|
|
695
|
-
{err && <Text color="red">{err}</Text>}
|
|
696
|
-
<SelectInput
|
|
697
|
-
items={items}
|
|
698
|
-
onSelect={it => {
|
|
699
|
-
const val = it.value as string;
|
|
700
|
-
if (val.startsWith('__header__')) return;
|
|
701
|
-
const sepIdx = val.indexOf('::');
|
|
702
|
-
const providerId = val.slice(0, sepIdx);
|
|
703
|
-
const modelId = val.slice(sepIdx + 2);
|
|
704
|
-
axios.put(`${base}/api/providers/slots/${currentSlotName}`, { providerId, modelId })
|
|
705
|
-
.then(() => {
|
|
706
|
-
setSlotTable(prev => ({ ...prev, [currentSlotName]: { providerId, modelId } }));
|
|
707
|
-
setOpMsg(`已配置 [${currentSlotName}] -> ${providers.find(p => p.id === providerId)?.name || providerId} / ${modelId}`);
|
|
708
|
-
setErr(null);
|
|
709
|
-
setStage('slot_config');
|
|
710
|
-
})
|
|
711
|
-
.catch(e => {
|
|
712
|
-
setErr((e as any)?.response?.data?.message || (e as any)?.message || '保存失败');
|
|
713
|
-
setStage('slot_config');
|
|
714
|
-
});
|
|
715
|
-
}}
|
|
716
|
-
/>
|
|
717
|
-
<Text dimColor>↑↓ 选择模型,回车确认,ESC 返回</Text>
|
|
718
|
-
</Box>
|
|
719
|
-
);
|
|
720
|
-
}
|
|
721
|
-
|
|
722
|
-
return null;
|
|
723
|
-
};
|
|
724
|
-
|
|
725
|
-
export default ProviderPanel;
|
|
1
|
+
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
|
2
|
+
import { Box, Text, useApp } from 'ink';
|
|
3
|
+
import SelectInput from 'ink-select-input';
|
|
4
|
+
import TextInput from 'ink-text-input';
|
|
5
|
+
import axios from 'axios';
|
|
6
|
+
|
|
7
|
+
type ProviderField = {
|
|
8
|
+
key: string;
|
|
9
|
+
label: string;
|
|
10
|
+
required?: boolean;
|
|
11
|
+
secret?: boolean;
|
|
12
|
+
placeholder?: string;
|
|
13
|
+
default?: string;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
type Provider = {
|
|
17
|
+
id: string;
|
|
18
|
+
name?: string;
|
|
19
|
+
baseUrl?: string;
|
|
20
|
+
notes?: string;
|
|
21
|
+
isCurrent?: boolean;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
type Preset = {
|
|
25
|
+
id: string;
|
|
26
|
+
label?: string;
|
|
27
|
+
name?: string;
|
|
28
|
+
desc?: string;
|
|
29
|
+
baseUrl?: string;
|
|
30
|
+
apiFormat?: string;
|
|
31
|
+
needsApiKey?: boolean;
|
|
32
|
+
websiteUrl?: string;
|
|
33
|
+
defaultModels?: { main: string; haiku: string; sonnet: string; opus: string };
|
|
34
|
+
fields?: ProviderField[];
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
type Stage =
|
|
38
|
+
| 'list'
|
|
39
|
+
| 'add_select_preset'
|
|
40
|
+
| 'add_input_fields'
|
|
41
|
+
| 'switch'
|
|
42
|
+
| 'test_select'
|
|
43
|
+
| 'delete_select'
|
|
44
|
+
| 'delete_confirm'
|
|
45
|
+
| 'testing'
|
|
46
|
+
| 'creating'
|
|
47
|
+
| 'removing'
|
|
48
|
+
| 'edit_select'
|
|
49
|
+
| 'edit_input_name'
|
|
50
|
+
| 'edit_input_key'
|
|
51
|
+
| 'editing'
|
|
52
|
+
| 'slot_config'
|
|
53
|
+
| 'slot_loading'
|
|
54
|
+
| 'slot_select_model';
|
|
55
|
+
|
|
56
|
+
export const ProviderPanel: React.FC<{
|
|
57
|
+
apiUrl: string;
|
|
58
|
+
onBack?: () => void;
|
|
59
|
+
}> = ({ apiUrl, onBack }) => {
|
|
60
|
+
const { exit } = useApp();
|
|
61
|
+
const [loading, setLoading] = useState(false);
|
|
62
|
+
const [err, setErr] = useState<string | null>(null);
|
|
63
|
+
|
|
64
|
+
const [providers, setProviders] = useState<Provider[]>([]);
|
|
65
|
+
const [currentId, setCurrentId] = useState<string | null>(null);
|
|
66
|
+
|
|
67
|
+
const [presets, setPresets] = useState<Preset[]>([]);
|
|
68
|
+
|
|
69
|
+
const [stage, setStage] = useState<Stage>('list');
|
|
70
|
+
|
|
71
|
+
// 新增流程(动态字段)
|
|
72
|
+
const [selectedPresetId, setSelectedPresetId] = useState<string | null>(null);
|
|
73
|
+
const [addFields, setAddFields] = useState<ProviderField[]>([]);
|
|
74
|
+
const [addFieldValues, setAddFieldValues] = useState<Record<string, string>>({});
|
|
75
|
+
const [addFieldIndex, setAddFieldIndex] = useState(0);
|
|
76
|
+
|
|
77
|
+
const [opMsg, setOpMsg] = useState<string | null>(null);
|
|
78
|
+
const [selectedId, setSelectedId] = useState<string | null>(null);
|
|
79
|
+
|
|
80
|
+
// 编辑所需状态
|
|
81
|
+
const [editId, setEditId] = useState<string | null>(null);
|
|
82
|
+
const [editName, setEditName] = useState('');
|
|
83
|
+
const [editKey, setEditKey] = useState('');
|
|
84
|
+
|
|
85
|
+
// 槽位配置状态
|
|
86
|
+
type SlotEntry = { providerId: string; modelId: string } | null;
|
|
87
|
+
const [slotTable, setSlotTable] = useState<Record<string, SlotEntry>>({});
|
|
88
|
+
const [slotProviderModels, setSlotProviderModels] = useState<Record<string, string[]>>({});
|
|
89
|
+
const [currentSlotName, setCurrentSlotName] = useState<string>('main');
|
|
90
|
+
const [slotLoadingMsg, setSlotLoadingMsg] = useState('');
|
|
91
|
+
|
|
92
|
+
const base = apiUrl.replace(/\/+$/, '');
|
|
93
|
+
|
|
94
|
+
const parseListResp = (data: any): { list: Provider[]; currentId: string | null } => {
|
|
95
|
+
if (Array.isArray(data)) {
|
|
96
|
+
const cur = data.find((p: any) => p.isCurrent)?.id ?? null;
|
|
97
|
+
return { list: data, currentId: cur };
|
|
98
|
+
}
|
|
99
|
+
const list = data?.providers || data?.list || data?.items || [];
|
|
100
|
+
const cur =
|
|
101
|
+
data?.currentId ??
|
|
102
|
+
list.find((p: any) => p.isCurrent)?.id ??
|
|
103
|
+
null;
|
|
104
|
+
return { list, currentId: cur };
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
const loadProviders = useCallback(async () => {
|
|
108
|
+
setLoading(true); setErr(null);
|
|
109
|
+
try {
|
|
110
|
+
const res = await axios.get(`${base}/api/providers`);
|
|
111
|
+
const { list, currentId } = parseListResp(res.data);
|
|
112
|
+
setProviders(list || []);
|
|
113
|
+
setCurrentId(currentId);
|
|
114
|
+
} catch (e: any) {
|
|
115
|
+
setErr(e?.message || '获取 Provider 列表失败');
|
|
116
|
+
} finally {
|
|
117
|
+
setLoading(false);
|
|
118
|
+
}
|
|
119
|
+
}, [base]);
|
|
120
|
+
|
|
121
|
+
const loadPresets = useCallback(async () => {
|
|
122
|
+
try {
|
|
123
|
+
const res = await axios.get(`${base}/api/providers/presets`);
|
|
124
|
+
const data = Array.isArray(res.data) ? res.data : (res.data?.presets || res.data?.list || []);
|
|
125
|
+
setPresets(data || []);
|
|
126
|
+
} catch (e) {
|
|
127
|
+
setPresets([]);
|
|
128
|
+
}
|
|
129
|
+
}, [base]);
|
|
130
|
+
|
|
131
|
+
useEffect(() => {
|
|
132
|
+
loadProviders();
|
|
133
|
+
loadPresets();
|
|
134
|
+
}, [loadProviders, loadPresets]);
|
|
135
|
+
|
|
136
|
+
// ESC 处理:子页返回列表;列表再触发 onBack(或退出)
|
|
137
|
+
useEffect(() => {
|
|
138
|
+
const handler = (buf: Buffer) => {
|
|
139
|
+
const key = buf.toString();
|
|
140
|
+
if (key === '\u001b') {
|
|
141
|
+
if (stage === 'slot_select_model' || stage === 'slot_loading') {
|
|
142
|
+
setStage('slot_config');
|
|
143
|
+
setErr(null);
|
|
144
|
+
} else if (stage === 'slot_config') {
|
|
145
|
+
setStage('list');
|
|
146
|
+
setErr(null);
|
|
147
|
+
} else if (stage !== 'list') {
|
|
148
|
+
setStage('list');
|
|
149
|
+
setSelectedPresetId(null);
|
|
150
|
+
setAddFields([]);
|
|
151
|
+
setAddFieldValues({});
|
|
152
|
+
setAddFieldIndex(0);
|
|
153
|
+
setSelectedId(null);
|
|
154
|
+
setOpMsg(null);
|
|
155
|
+
setErr(null);
|
|
156
|
+
setEditId(null);
|
|
157
|
+
setEditName('');
|
|
158
|
+
setEditKey('');
|
|
159
|
+
} else {
|
|
160
|
+
onBack ? onBack() : exit();
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
};
|
|
164
|
+
process.stdin.on('data', handler);
|
|
165
|
+
return () => process.stdin.off('data', handler);
|
|
166
|
+
}, [stage, onBack, exit]);
|
|
167
|
+
|
|
168
|
+
const currentProvider = useMemo(
|
|
169
|
+
() => providers.find(p => (currentId ? p.id === currentId : p.isCurrent)),
|
|
170
|
+
[providers, currentId]
|
|
171
|
+
);
|
|
172
|
+
|
|
173
|
+
// Actions
|
|
174
|
+
const doSetCurrent = async (id: string) => {
|
|
175
|
+
setErr(null); setOpMsg(null);
|
|
176
|
+
try {
|
|
177
|
+
await axios.post(`${base}/api/providers/current`, { id });
|
|
178
|
+
setOpMsg(`已切换当前 Provider -> ${id}`);
|
|
179
|
+
await loadProviders();
|
|
180
|
+
setStage('list');
|
|
181
|
+
} catch (e: any) {
|
|
182
|
+
setErr(e?.response?.data?.message || e?.message || '切换失败');
|
|
183
|
+
}
|
|
184
|
+
};
|
|
185
|
+
|
|
186
|
+
const doCreate = async (
|
|
187
|
+
presetId: string,
|
|
188
|
+
name: string,
|
|
189
|
+
apiKey: string,
|
|
190
|
+
baseUrl?: string,
|
|
191
|
+
extra?: Record<string, string>,
|
|
192
|
+
) => {
|
|
193
|
+
setStage('creating');
|
|
194
|
+
setErr(null); setOpMsg(null);
|
|
195
|
+
try {
|
|
196
|
+
// 从预设补全 baseUrl 和 models(前端填的 baseUrl 优先)
|
|
197
|
+
const preset = presets.find(p => p.id === presetId);
|
|
198
|
+
const resolvedBaseUrl = baseUrl || preset?.baseUrl || '';
|
|
199
|
+
const resolvedModels = preset?.defaultModels || { main: '', haiku: '', sonnet: '', opus: '' };
|
|
200
|
+
|
|
201
|
+
const body: Record<string, unknown> = {
|
|
202
|
+
presetId,
|
|
203
|
+
name,
|
|
204
|
+
apiKey,
|
|
205
|
+
baseUrl: resolvedBaseUrl,
|
|
206
|
+
models: resolvedModels,
|
|
207
|
+
...(preset?.apiFormat && { apiFormat: preset.apiFormat }),
|
|
208
|
+
};
|
|
209
|
+
if (extra && Object.keys(extra).length > 0) body.extra = extra;
|
|
210
|
+
await axios.post(`${base}/api/providers`, body);
|
|
211
|
+
setOpMsg(`创建成功 -> ${name}`);
|
|
212
|
+
await loadProviders();
|
|
213
|
+
setStage('list');
|
|
214
|
+
} catch (e: any) {
|
|
215
|
+
setErr(e?.response?.data?.message || e?.message || '创建失败');
|
|
216
|
+
// 回到最后一个字段让用户看到错误,而不是触发再次提交
|
|
217
|
+
setAddFieldIndex(Math.max(0, addFields.length - 1));
|
|
218
|
+
setStage('add_input_fields');
|
|
219
|
+
}
|
|
220
|
+
};
|
|
221
|
+
|
|
222
|
+
const doTest = async (id: string) => {
|
|
223
|
+
setStage('testing');
|
|
224
|
+
setErr(null); setOpMsg('测试中...');
|
|
225
|
+
try {
|
|
226
|
+
const res = await axios.post(`${base}/api/providers/${encodeURIComponent(id)}/test`);
|
|
227
|
+
const ok = res?.data?.ok ?? true;
|
|
228
|
+
setOpMsg(ok ? `连通性正常 -> ${id}` : `连通性异常 -> ${id}`);
|
|
229
|
+
} catch (e: any) {
|
|
230
|
+
setErr(e?.response?.data?.message || e?.message || `测试失败 -> ${id}`);
|
|
231
|
+
} finally {
|
|
232
|
+
setStage('list');
|
|
233
|
+
await loadProviders();
|
|
234
|
+
}
|
|
235
|
+
};
|
|
236
|
+
|
|
237
|
+
const doEdit = async (id: string, name: string, apiKey: string) => {
|
|
238
|
+
setStage('editing');
|
|
239
|
+
setErr(null); setOpMsg(null);
|
|
240
|
+
try {
|
|
241
|
+
const updates: Record<string, string> = {};
|
|
242
|
+
if (name.trim()) updates.name = name.trim();
|
|
243
|
+
if (apiKey.trim()) updates.apiKey = apiKey.trim();
|
|
244
|
+
await axios.put(`${base}/api/providers/${encodeURIComponent(id)}`, updates);
|
|
245
|
+
setOpMsg(`已更新 Provider -> ${name.trim() || id}`);
|
|
246
|
+
await loadProviders();
|
|
247
|
+
setStage('list');
|
|
248
|
+
} catch (e: any) {
|
|
249
|
+
setErr(e?.response?.data?.message || e?.message || '编辑失败');
|
|
250
|
+
setStage('edit_input_key');
|
|
251
|
+
}
|
|
252
|
+
};
|
|
253
|
+
|
|
254
|
+
const doRemove = async (id: string) => {
|
|
255
|
+
setStage('removing');
|
|
256
|
+
setErr(null); setOpMsg(null);
|
|
257
|
+
try {
|
|
258
|
+
await axios.delete(`${base}/api/providers/${encodeURIComponent(id)}`);
|
|
259
|
+
setOpMsg(`已删除 Provider -> ${id}`);
|
|
260
|
+
await loadProviders();
|
|
261
|
+
setStage('list');
|
|
262
|
+
} catch (e: any) {
|
|
263
|
+
setErr(e?.response?.data?.message || e?.message || '删除失败');
|
|
264
|
+
setStage('list');
|
|
265
|
+
}
|
|
266
|
+
};
|
|
267
|
+
|
|
268
|
+
const MAX_LIST = 5;
|
|
269
|
+
|
|
270
|
+
// 渲染块
|
|
271
|
+
const renderList = () => {
|
|
272
|
+
const visibleProviders = providers.slice(0, MAX_LIST);
|
|
273
|
+
const overflow = providers.length - MAX_LIST;
|
|
274
|
+
return (
|
|
275
|
+
<Box flexDirection="column">
|
|
276
|
+
<Text color="cyan">Provider 列表</Text>
|
|
277
|
+
{!providers.length && !loading && <Text>暂无 Provider</Text>}
|
|
278
|
+
{visibleProviders.map(p => {
|
|
279
|
+
const isCur = currentProvider && (currentProvider.id === p.id);
|
|
280
|
+
return (
|
|
281
|
+
<Text key={p.id} color={isCur ? 'green' : undefined} bold={isCur}>
|
|
282
|
+
{p.name || '-'}{isCur ? ' ← 当前' : ''}
|
|
283
|
+
</Text>
|
|
284
|
+
);
|
|
285
|
+
})}
|
|
286
|
+
{overflow > 0 && <Text dimColor> ...还有 {overflow} 个</Text>}
|
|
287
|
+
{currentProvider && (
|
|
288
|
+
<Text dimColor>
|
|
289
|
+
当前 Provider: {currentProvider.name || currentProvider.id}
|
|
290
|
+
</Text>
|
|
291
|
+
)}
|
|
292
|
+
{loading && <Text color="yellow">加载中...</Text>}
|
|
293
|
+
{err && <Text color="red">{err}</Text>}
|
|
294
|
+
{opMsg && <Text color="green">{opMsg}</Text>}
|
|
295
|
+
|
|
296
|
+
<Box marginTop={1} flexDirection="column">
|
|
297
|
+
<SelectInput
|
|
298
|
+
items={[
|
|
299
|
+
{ label: '新增 Provider', value: 'add' },
|
|
300
|
+
{ label: '编辑 Provider(名称/API Key)', value: 'edit' },
|
|
301
|
+
{ label: '配置槽位(main/haiku/sonnet/opus)', value: 'slots' },
|
|
302
|
+
{ label: '切换当前 Provider', value: 'switch' },
|
|
303
|
+
{ label: '连通性测试', value: 'test' },
|
|
304
|
+
{ label: '删除 Provider', value: 'delete' },
|
|
305
|
+
{ label: '刷新', value: 'refresh' },
|
|
306
|
+
{ label: '返回主菜单(ESC)', value: 'back' },
|
|
307
|
+
]}
|
|
308
|
+
onSelect={item => {
|
|
309
|
+
switch (item.value) {
|
|
310
|
+
case 'add':
|
|
311
|
+
setSelectedPresetId(null);
|
|
312
|
+
setAddFields([]);
|
|
313
|
+
setAddFieldValues({});
|
|
314
|
+
setAddFieldIndex(0);
|
|
315
|
+
setStage('add_select_preset');
|
|
316
|
+
break;
|
|
317
|
+
case 'edit':
|
|
318
|
+
setEditId(null);
|
|
319
|
+
setEditName('');
|
|
320
|
+
setEditKey('');
|
|
321
|
+
setStage('edit_select');
|
|
322
|
+
break;
|
|
323
|
+
case 'slots':
|
|
324
|
+
axios.get(`${base}/api/providers/slots`)
|
|
325
|
+
.then(r => setSlotTable(r.data as Record<string, SlotEntry>))
|
|
326
|
+
.catch(() => {});
|
|
327
|
+
setStage('slot_config');
|
|
328
|
+
break;
|
|
329
|
+
case 'switch':
|
|
330
|
+
setStage('switch');
|
|
331
|
+
break;
|
|
332
|
+
case 'test':
|
|
333
|
+
setStage('test_select');
|
|
334
|
+
break;
|
|
335
|
+
case 'delete':
|
|
336
|
+
setStage('delete_select');
|
|
337
|
+
break;
|
|
338
|
+
case 'refresh':
|
|
339
|
+
loadProviders();
|
|
340
|
+
break;
|
|
341
|
+
case 'back':
|
|
342
|
+
onBack ? onBack() : null;
|
|
343
|
+
break;
|
|
344
|
+
}
|
|
345
|
+
}}
|
|
346
|
+
/>
|
|
347
|
+
<Text dimColor>提示:ESC 返回。↑↓/回车 选择操作</Text>
|
|
348
|
+
</Box>
|
|
349
|
+
</Box>
|
|
350
|
+
);
|
|
351
|
+
};
|
|
352
|
+
|
|
353
|
+
if (stage === 'list') return renderList();
|
|
354
|
+
|
|
355
|
+
if (stage === 'add_select_preset') {
|
|
356
|
+
const items = (presets || []).map(pr => ({
|
|
357
|
+
label: pr.websiteUrl
|
|
358
|
+
? `${pr.label || pr.name || pr.id} ${pr.websiteUrl}`
|
|
359
|
+
: `${pr.label || pr.name || pr.id}`,
|
|
360
|
+
value: pr.id
|
|
361
|
+
}));
|
|
362
|
+
if (!items.length) {
|
|
363
|
+
return (
|
|
364
|
+
<Box flexDirection="column">
|
|
365
|
+
<Text color="yellow">未获取到预设,请先确保主服务已启动。</Text>
|
|
366
|
+
<SelectInput
|
|
367
|
+
items={[{ label: '← 返回', value: 'back' }]}
|
|
368
|
+
onSelect={() => setStage('list')}
|
|
369
|
+
/>
|
|
370
|
+
</Box>
|
|
371
|
+
);
|
|
372
|
+
}
|
|
373
|
+
return (
|
|
374
|
+
<Box flexDirection="column">
|
|
375
|
+
<Text>选择预设:</Text>
|
|
376
|
+
<SelectInput
|
|
377
|
+
items={items}
|
|
378
|
+
onSelect={it => {
|
|
379
|
+
const preset = presets.find(p => p.id === (it.value as string));
|
|
380
|
+
// 从 preset.fields 获取字段列表;若为空则用默认最小集
|
|
381
|
+
const fields: ProviderField[] =
|
|
382
|
+
preset?.fields && preset.fields.length > 0
|
|
383
|
+
? preset.fields
|
|
384
|
+
: [
|
|
385
|
+
{ key: 'name', label: 'Provider 昵称', required: true },
|
|
386
|
+
{ key: 'apiKey', label: 'API Key', required: true, secret: true },
|
|
387
|
+
];
|
|
388
|
+
setSelectedPresetId(it.value as string);
|
|
389
|
+
setAddFields(fields);
|
|
390
|
+
setAddFieldValues({});
|
|
391
|
+
setAddFieldIndex(0);
|
|
392
|
+
setStage('add_input_fields');
|
|
393
|
+
}}
|
|
394
|
+
/>
|
|
395
|
+
<Text dimColor>ESC 返回</Text>
|
|
396
|
+
</Box>
|
|
397
|
+
);
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
if (stage === 'add_input_fields') {
|
|
401
|
+
const field = addFields[addFieldIndex];
|
|
402
|
+
if (!field) {
|
|
403
|
+
// 防御性分支:所有字段已填完但还没触发提交(一般不应走到这里)
|
|
404
|
+
return <Text color="yellow">创建中...</Text>;
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
const currentVal = addFieldValues[field.key] ?? field.default ?? '';
|
|
408
|
+
|
|
409
|
+
const handleSubmit = (submittedVal: string) => {
|
|
410
|
+
// ink-text-input passes the current value to onSubmit
|
|
411
|
+
const val = submittedVal;
|
|
412
|
+
if (field.required && !val.trim()) return; // 必填不允许空
|
|
413
|
+
|
|
414
|
+
// 确保最新值已写入
|
|
415
|
+
const merged = { ...addFieldValues, [field.key]: val };
|
|
416
|
+
|
|
417
|
+
const nextIndex = addFieldIndex + 1;
|
|
418
|
+
if (nextIndex < addFields.length) {
|
|
419
|
+
setAddFieldValues(merged);
|
|
420
|
+
setAddFieldIndex(nextIndex);
|
|
421
|
+
} else {
|
|
422
|
+
// 最后一个字段提交,触发创建
|
|
423
|
+
const name = merged['name'] || '';
|
|
424
|
+
const apiKey = merged['apiKey'] || '';
|
|
425
|
+
const baseUrl = merged['baseUrl'] || '';
|
|
426
|
+
const extra: Record<string, string> = {};
|
|
427
|
+
for (const [k, v] of Object.entries(merged)) {
|
|
428
|
+
if (!['name', 'apiKey', 'baseUrl'].includes(k) && v) extra[k] = v;
|
|
429
|
+
}
|
|
430
|
+
setAddFieldValues(merged);
|
|
431
|
+
void doCreate(selectedPresetId!, name, apiKey, baseUrl || undefined, extra);
|
|
432
|
+
}
|
|
433
|
+
};
|
|
434
|
+
|
|
435
|
+
return (
|
|
436
|
+
<Box flexDirection="column">
|
|
437
|
+
<Text color="cyan">
|
|
438
|
+
新增 Provider — 字段 {addFieldIndex + 1}/{addFields.length}
|
|
439
|
+
</Text>
|
|
440
|
+
<Text>
|
|
441
|
+
{field.label}{field.required ? <Text color="red"> *</Text> : ''}
|
|
442
|
+
{field.placeholder ? <Text dimColor> ({field.placeholder})</Text> : ''}:
|
|
443
|
+
</Text>
|
|
444
|
+
<TextInput
|
|
445
|
+
value={currentVal}
|
|
446
|
+
onChange={v => setAddFieldValues(prev => ({ ...prev, [field.key]: v }))}
|
|
447
|
+
// @ts-ignore - ink-text-input supports mask prop
|
|
448
|
+
mask={field.secret ? '*' : undefined}
|
|
449
|
+
onSubmit={handleSubmit}
|
|
450
|
+
/>
|
|
451
|
+
{err && <Text color="red">{err}</Text>}
|
|
452
|
+
<Text dimColor>回车继续,ESC 返回列表</Text>
|
|
453
|
+
</Box>
|
|
454
|
+
);
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
if (stage === 'creating') {
|
|
458
|
+
return <Text color="yellow">创建中...</Text>;
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
if (stage === 'switch') {
|
|
462
|
+
const items = providers.map(p => ({
|
|
463
|
+
label: `${p.name || p.id}${(currentId === p.id || p.isCurrent) ? ' ← 当前' : ''}`,
|
|
464
|
+
value: p.id
|
|
465
|
+
}));
|
|
466
|
+
return (
|
|
467
|
+
<Box flexDirection="column">
|
|
468
|
+
<Text>选择要设为当前的 Provider:</Text>
|
|
469
|
+
<SelectInput
|
|
470
|
+
items={items}
|
|
471
|
+
onSelect={it => doSetCurrent(it.value as string)}
|
|
472
|
+
/>
|
|
473
|
+
{err && <Text color="red">{err}</Text>}
|
|
474
|
+
<Text dimColor>ESC 返回</Text>
|
|
475
|
+
</Box>
|
|
476
|
+
);
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
if (stage === 'test_select') {
|
|
480
|
+
const items = providers.map(p => ({
|
|
481
|
+
label: `${p.name || p.id}`,
|
|
482
|
+
value: p.id
|
|
483
|
+
}));
|
|
484
|
+
return (
|
|
485
|
+
<Box flexDirection="column">
|
|
486
|
+
<Text>选择要测试的 Provider:</Text>
|
|
487
|
+
<SelectInput
|
|
488
|
+
items={items}
|
|
489
|
+
onSelect={it => doTest(it.value as string)}
|
|
490
|
+
/>
|
|
491
|
+
{err && <Text color="red">{err}</Text>}
|
|
492
|
+
<Text dimColor>ESC 返回</Text>
|
|
493
|
+
</Box>
|
|
494
|
+
);
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
if (stage === 'testing') {
|
|
498
|
+
return <Text color="yellow">测试中...</Text>;
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
if (stage === 'delete_select') {
|
|
502
|
+
const items = providers.map(p => ({
|
|
503
|
+
label: `${p.name || p.id}`,
|
|
504
|
+
value: p.id
|
|
505
|
+
}));
|
|
506
|
+
return (
|
|
507
|
+
<Box flexDirection="column">
|
|
508
|
+
<Text color="red">选择要删除的 Provider:</Text>
|
|
509
|
+
<SelectInput
|
|
510
|
+
items={items}
|
|
511
|
+
onSelect={it => {
|
|
512
|
+
setSelectedId(it.value as string);
|
|
513
|
+
setStage('delete_confirm');
|
|
514
|
+
}}
|
|
515
|
+
/>
|
|
516
|
+
<Text dimColor>ESC 返回</Text>
|
|
517
|
+
</Box>
|
|
518
|
+
);
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
if (stage === 'delete_confirm') {
|
|
522
|
+
if (!selectedId) { setStage('list'); return null; }
|
|
523
|
+
return (
|
|
524
|
+
<Box flexDirection="column">
|
|
525
|
+
<Text color="red">确认删除 Provider:{selectedId} ?</Text>
|
|
526
|
+
<SelectInput
|
|
527
|
+
items={[
|
|
528
|
+
{ label: '是,删除', value: 'yes' },
|
|
529
|
+
{ label: '否,返回', value: 'no' }
|
|
530
|
+
]}
|
|
531
|
+
onSelect={it => {
|
|
532
|
+
if (it.value === 'no') {
|
|
533
|
+
setStage('list');
|
|
534
|
+
} else {
|
|
535
|
+
void doRemove(selectedId);
|
|
536
|
+
}
|
|
537
|
+
}}
|
|
538
|
+
/>
|
|
539
|
+
{err && <Text color="red">{err}</Text>}
|
|
540
|
+
<Text dimColor>ESC 返回</Text>
|
|
541
|
+
</Box>
|
|
542
|
+
);
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
if (stage === 'removing') {
|
|
546
|
+
return <Text color="yellow">删除中...</Text>;
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
if (stage === 'edit_select') {
|
|
550
|
+
const items = providers.map(p => ({
|
|
551
|
+
label: `${p.name || p.id}${(currentId === p.id || p.isCurrent) ? ' ← 当前' : ''}`,
|
|
552
|
+
value: p.id,
|
|
553
|
+
}));
|
|
554
|
+
if (!items.length) {
|
|
555
|
+
return (
|
|
556
|
+
<Box flexDirection="column">
|
|
557
|
+
<Text color="yellow">暂无 Provider 可编辑。</Text>
|
|
558
|
+
<SelectInput items={[{ label: '← 返回', value: 'back' }]} onSelect={() => setStage('list')} />
|
|
559
|
+
</Box>
|
|
560
|
+
);
|
|
561
|
+
}
|
|
562
|
+
return (
|
|
563
|
+
<Box flexDirection="column">
|
|
564
|
+
<Text>选择要编辑的 Provider:</Text>
|
|
565
|
+
<SelectInput
|
|
566
|
+
items={items}
|
|
567
|
+
onSelect={it => {
|
|
568
|
+
const p = providers.find(p => p.id === it.value);
|
|
569
|
+
setEditId(it.value as string);
|
|
570
|
+
setEditName(p?.name || '');
|
|
571
|
+
setEditKey('');
|
|
572
|
+
setStage('edit_input_name');
|
|
573
|
+
}}
|
|
574
|
+
/>
|
|
575
|
+
<Text dimColor>ESC 返回</Text>
|
|
576
|
+
</Box>
|
|
577
|
+
);
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
if (stage === 'edit_input_name') {
|
|
581
|
+
return (
|
|
582
|
+
<Box flexDirection="column">
|
|
583
|
+
<Text>编辑名称(当前:<Text color="cyan">{editName}</Text>,回车保留不变):</Text>
|
|
584
|
+
<TextInput
|
|
585
|
+
value={editName}
|
|
586
|
+
onChange={setEditName}
|
|
587
|
+
onSubmit={() => setStage('edit_input_key')}
|
|
588
|
+
/>
|
|
589
|
+
<Text dimColor>回车继续,ESC 返回</Text>
|
|
590
|
+
</Box>
|
|
591
|
+
);
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
if (stage === 'edit_input_key') {
|
|
595
|
+
return (
|
|
596
|
+
<Box flexDirection="column">
|
|
597
|
+
<Text>输入新 API Key(留空则不修改):</Text>
|
|
598
|
+
<TextInput
|
|
599
|
+
value={editKey}
|
|
600
|
+
onChange={setEditKey}
|
|
601
|
+
// @ts-ignore
|
|
602
|
+
mask="*"
|
|
603
|
+
onSubmit={() => {
|
|
604
|
+
if (!editId) { setStage('list'); return; }
|
|
605
|
+
doEdit(editId, editName, editKey);
|
|
606
|
+
}}
|
|
607
|
+
/>
|
|
608
|
+
{err && <Text color="red">{err}</Text>}
|
|
609
|
+
<Text dimColor>回车保存,ESC 返回</Text>
|
|
610
|
+
</Box>
|
|
611
|
+
);
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
if (stage === 'editing') {
|
|
615
|
+
return <Text color="yellow">保存中...</Text>;
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
if (stage === 'slot_config') {
|
|
619
|
+
const SLOTS = ['main', 'haiku', 'sonnet', 'opus'] as const;
|
|
620
|
+
const SLOT_DESCS: Record<string, string> = {
|
|
621
|
+
main: '主力模型,复杂推理、长上下文、代码生成等高要求任务',
|
|
622
|
+
haiku: '快速轻量,简单问答、自动补全、低延迟响应',
|
|
623
|
+
sonnet: '均衡模型,兼顾质量与速度,适合日常对话',
|
|
624
|
+
opus: '最强模型,深度推理与复杂分析(调用量通常最少)',
|
|
625
|
+
};
|
|
626
|
+
const items = SLOTS.map(s => {
|
|
627
|
+
const entry = slotTable[s];
|
|
628
|
+
const providerName = entry
|
|
629
|
+
? (providers.find(p => p.id === entry.providerId)?.name || entry.providerId)
|
|
630
|
+
: null;
|
|
631
|
+
const status = entry ? `${providerName} / ${entry.modelId}` : '未配置';
|
|
632
|
+
const label = `[${s}] ${status} — ${SLOT_DESCS[s]}`;
|
|
633
|
+
return { label, value: s };
|
|
634
|
+
});
|
|
635
|
+
return (
|
|
636
|
+
<Box flexDirection="column">
|
|
637
|
+
<Text color="cyan">配置槽位(选中槽位后选择模型)</Text>
|
|
638
|
+
{err && <Text color="red">{err}</Text>}
|
|
639
|
+
{opMsg && <Text color="green">{opMsg}</Text>}
|
|
640
|
+
<SelectInput
|
|
641
|
+
items={[...items, { label: '← 返回主菜单', value: 'back' }]}
|
|
642
|
+
onSelect={it => {
|
|
643
|
+
if (it.value === 'back') { setStage('list'); setErr(null); return; }
|
|
644
|
+
setCurrentSlotName(it.value as string);
|
|
645
|
+
setErr(null);
|
|
646
|
+
setSlotLoadingMsg('正在获取模型列表...');
|
|
647
|
+
setStage('slot_loading');
|
|
648
|
+
Promise.all(
|
|
649
|
+
providers.map(p =>
|
|
650
|
+
axios.get(`${base}/api/providers/${encodeURIComponent(p.id)}/models`)
|
|
651
|
+
.then(r => ({ id: p.id, models: (r.data as { models: string[] }).models }))
|
|
652
|
+
.catch(() => ({ id: p.id, models: [] as string[] }))
|
|
653
|
+
)
|
|
654
|
+
).then(results => {
|
|
655
|
+
const map: Record<string, string[]> = {};
|
|
656
|
+
results.forEach(r => { map[r.id] = r.models; });
|
|
657
|
+
setSlotProviderModels(map);
|
|
658
|
+
setStage('slot_select_model');
|
|
659
|
+
});
|
|
660
|
+
}}
|
|
661
|
+
/>
|
|
662
|
+
<Text dimColor>ESC 返回</Text>
|
|
663
|
+
</Box>
|
|
664
|
+
);
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
if (stage === 'slot_loading') {
|
|
668
|
+
return <Text color="yellow">{slotLoadingMsg}</Text>;
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
if (stage === 'slot_select_model') {
|
|
672
|
+
const items: Array<{ label: string; value: string }> = [];
|
|
673
|
+
providers.forEach(p => {
|
|
674
|
+
const models = slotProviderModels[p.id] || [];
|
|
675
|
+
if (models.length === 0) return;
|
|
676
|
+
items.push({ label: `── ${p.name || p.id} ──`, value: `__header__${p.id}` });
|
|
677
|
+
models.forEach(m => items.push({ label: ` ${m}`, value: `${p.id}::${m}` }));
|
|
678
|
+
});
|
|
679
|
+
|
|
680
|
+
if (items.length === 0) {
|
|
681
|
+
return (
|
|
682
|
+
<Box flexDirection="column">
|
|
683
|
+
<Text color="red">没有可用的模型(所有 Provider 连接失败或未返回模型列表)</Text>
|
|
684
|
+
<SelectInput
|
|
685
|
+
items={[{ label: '← 返回', value: 'back' }]}
|
|
686
|
+
onSelect={() => setStage('slot_config')}
|
|
687
|
+
/>
|
|
688
|
+
</Box>
|
|
689
|
+
);
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
return (
|
|
693
|
+
<Box flexDirection="column">
|
|
694
|
+
<Text color="cyan">配置槽位 [{currentSlotName}] — 选择模型</Text>
|
|
695
|
+
{err && <Text color="red">{err}</Text>}
|
|
696
|
+
<SelectInput
|
|
697
|
+
items={items}
|
|
698
|
+
onSelect={it => {
|
|
699
|
+
const val = it.value as string;
|
|
700
|
+
if (val.startsWith('__header__')) return;
|
|
701
|
+
const sepIdx = val.indexOf('::');
|
|
702
|
+
const providerId = val.slice(0, sepIdx);
|
|
703
|
+
const modelId = val.slice(sepIdx + 2);
|
|
704
|
+
axios.put(`${base}/api/providers/slots/${currentSlotName}`, { providerId, modelId })
|
|
705
|
+
.then(() => {
|
|
706
|
+
setSlotTable(prev => ({ ...prev, [currentSlotName]: { providerId, modelId } }));
|
|
707
|
+
setOpMsg(`已配置 [${currentSlotName}] -> ${providers.find(p => p.id === providerId)?.name || providerId} / ${modelId}`);
|
|
708
|
+
setErr(null);
|
|
709
|
+
setStage('slot_config');
|
|
710
|
+
})
|
|
711
|
+
.catch(e => {
|
|
712
|
+
setErr((e as any)?.response?.data?.message || (e as any)?.message || '保存失败');
|
|
713
|
+
setStage('slot_config');
|
|
714
|
+
});
|
|
715
|
+
}}
|
|
716
|
+
/>
|
|
717
|
+
<Text dimColor>↑↓ 选择模型,回车确认,ESC 返回</Text>
|
|
718
|
+
</Box>
|
|
719
|
+
);
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
return null;
|
|
723
|
+
};
|
|
724
|
+
|
|
725
|
+
export default ProviderPanel;
|