bingocode 1.1.200-beta.2 → 1.1.200-beta.21

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.
@@ -1,813 +1,813 @@
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
- import { Panel, Title, Chip, Hint, StateDisplay, ScrollBar, safePadEnd } from '../manager/CliMenuUi.tsx';
7
-
8
- type ProviderField = {
9
- key: string;
10
- label: string;
11
- required?: boolean;
12
- secret?: boolean;
13
- placeholder?: string;
14
- default?: string;
15
- };
16
-
17
- type Provider = {
18
- id: string;
19
- name?: string;
20
- baseUrl?: string;
21
- notes?: string;
22
- isCurrent?: boolean;
23
- models?: { main: string; haiku: string; sonnet: string; opus: string };
24
- };
25
-
26
- type Preset = {
27
- id: string;
28
- label?: string;
29
- name?: string;
30
- desc?: string;
31
- baseUrl?: string;
32
- apiFormat?: string;
33
- needsApiKey?: boolean;
34
- websiteUrl?: string;
35
- fields?: ProviderField[];
36
- };
37
-
38
- type Stage =
39
- | 'list'
40
- | 'add_select_preset'
41
- | 'add_input_fields'
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
- height?: number;
60
- }> = ({ apiUrl, onBack, height = 10 }) => {
61
- const { exit } = useApp();
62
- const [loading, setLoading] = useState(false);
63
- const [err, setErr] = useState<string | null>(null);
64
-
65
- // Scrolling
66
- const [listOffset, setListOffset] = useState(0);
67
-
68
- // Calculated visible counts based on height
69
- const MAX_VISIBLE = Math.max(3, height - 7);
70
- const MAX_VISIBLE_MODELS = Math.max(3, height - 6);
71
-
72
- const [providers, setProviders] = useState<Provider[]>([]);
73
- const [currentId, setCurrentId] = useState<string | null>(null);
74
-
75
- const [presets, setPresets] = useState<Preset[]>([]);
76
-
77
- const [stage, setStage] = useState<Stage>('list');
78
-
79
- // 新增流程(动态字段)
80
- const [selectedPresetId, setSelectedPresetId] = useState<string | null>(null);
81
- const [addFields, setAddFields] = useState<ProviderField[]>([]);
82
- const [addFieldValues, setAddFieldValues] = useState<Record<string, string>>({});
83
- const [addFieldIndex, setAddFieldIndex] = useState(0);
84
-
85
- const [opMsg, setOpMsg] = useState<string | null>(null);
86
- const [selectedId, setSelectedId] = useState<string | null>(null);
87
-
88
- // 编辑所需状态
89
- const [editId, setEditId] = useState<string | null>(null);
90
- const [editName, setEditName] = useState('');
91
- const [editKey, setEditKey] = useState('');
92
-
93
- // 槽位配置状态
94
- type SlotEntry = { providerId: string; modelId: string; label?: string | null } | null;
95
- const [slotTable, setSlotTable] = useState<Record<string, SlotEntry>>({});
96
- const [slotProviderModels, setSlotProviderModels] = useState<Record<string, string[]>>({});
97
- const [currentSlotName, setCurrentSlotName] = useState<string>('main');
98
- const [slotLoadingMsg, setSlotLoadingMsg] = useState<string>('');
99
-
100
-
101
-
102
- const base = apiUrl.replace(/\/+$/, '');
103
-
104
- const parseListResp = (data: any): { list: Provider[]; currentId: string | null } => {
105
- if (Array.isArray(data)) {
106
- const cur = data.find((p: any) => p.isCurrent)?.id ?? null;
107
- return { list: data, currentId: cur };
108
- }
109
- const list = data?.providers || data?.list || data?.items || [];
110
- const cur =
111
- data?.currentId ??
112
- list.find((p: any) => p.isCurrent)?.id ??
113
- null;
114
- return { list, currentId: cur };
115
- };
116
-
117
- const loadProviders = useCallback(async (opts?: { keepError?: boolean }) => {
118
- setLoading(true);
119
- if (!opts?.keepError) setErr(null);
120
- try {
121
- const res = await axios.get(`${base}/api/providers`);
122
- const { list, currentId } = parseListResp(res.data);
123
- setProviders(list || []);
124
- setCurrentId(currentId);
125
- } catch (e: any) {
126
- setErr(e?.message || 'Failed to fetch provider list');
127
- } finally {
128
- setLoading(false);
129
- }
130
- }, [base]);
131
-
132
- const loadPresets = useCallback(async () => {
133
- try {
134
- const res = await axios.get(`${base}/api/providers/presets`);
135
- const data = Array.isArray(res.data) ? res.data : (res.data?.presets || res.data?.list || []);
136
- setPresets(data || []);
137
- } catch (e) {
138
- setPresets([]);
139
- }
140
- }, [base]);
141
-
142
- useEffect(() => {
143
- loadProviders();
144
- loadPresets();
145
- }, [loadProviders, loadPresets]);
146
-
147
- // Key processing for Page Up/Down and Arrow keys in scrolling lists
148
- useEffect(() => {
149
- const handler = (buf: Buffer) => {
150
- const s = buf.toString();
151
- if (stage === 'add_select_preset' || stage === 'slot_select_model') {
152
- if (s === 'j' || s === '\u001b[B') setListOffset(prev => prev + 1); // j or Down
153
- if (s === 'k' || s === '\u001b[A') setListOffset(prev => Math.max(0, prev - 1)); // k or Up
154
- }
155
- };
156
- process.stdin.on('data', handler);
157
- return () => process.stdin.off('data', handler);
158
- }, [stage]);
159
-
160
- // ESC 处理:子页返回列表;列表再触发 onBack(或退出)
161
- useEffect(() => {
162
- const handler = (buf: Buffer) => {
163
- const key = buf.toString();
164
- if (key === '\u001b') {
165
- if (stage === 'slot_select_model' || stage === 'slot_loading') {
166
- setStage('slot_config');
167
- setErr(null);
168
- } else if (stage === 'slot_config') {
169
- setStage('list');
170
- setErr(null);
171
- } else if (stage !== 'list') {
172
- setStage('list');
173
- setSelectedPresetId(null);
174
- setAddFields([]);
175
- setAddFieldValues({});
176
- setAddFieldIndex(0);
177
- setSelectedId(null);
178
- setOpMsg(null);
179
- setErr(null);
180
- setEditId(null);
181
- setEditName('');
182
- setEditKey('');
183
- setListOffset(0);
184
- } else {
185
- onBack ? onBack() : exit();
186
- }
187
- }
188
- };
189
- process.stdin.on('data', handler);
190
- return () => process.stdin.off('data', handler);
191
- }, [stage, onBack, exit]);
192
-
193
- const currentProvider = useMemo(
194
- () => providers.find(p => (currentId ? p.id === currentId : p.isCurrent)),
195
- [providers, currentId]
196
- );
197
-
198
- // Actions
199
- const doCreate = async (
200
- presetId: string,
201
- name: string,
202
- apiKey: string,
203
- baseUrl?: string,
204
- extra?: Record<string, string>,
205
- ) => {
206
- setStage('creating');
207
- setErr(null); setOpMsg(null);
208
- try {
209
- // 从预设补全 baseUrl(前端填的 baseUrl 优先);models 全部留空,后续通过槽位配置动态选择
210
- const preset = presets.find(p => p.id === presetId);
211
- const resolvedBaseUrl = baseUrl || preset?.baseUrl || '';
212
-
213
- const body: Record<string, unknown> = {
214
- presetId,
215
- name,
216
- apiKey,
217
- baseUrl: resolvedBaseUrl,
218
- models: { main: '', haiku: '', sonnet: '', opus: '' },
219
- ...(preset?.apiFormat && { apiFormat: preset.apiFormat }),
220
- };
221
- if (extra && Object.keys(extra).length > 0) body.extra = extra;
222
- await axios.post(`${base}/api/providers`, body);
223
- setOpMsg(`Success -> ${name}`);
224
- await loadProviders();
225
- setStage('list');
226
- } catch (e: any) {
227
- setErr(e?.response?.data?.message || e?.message || 'Create failed');
228
- // Go back to the last field so user sees error instead of re-triggering submit
229
- setAddFieldIndex(Math.max(0, addFields.length - 1));
230
- setStage('add_input_fields');
231
- }
232
- };
233
-
234
- const doTest = async (id: string) => {
235
- setStage('testing');
236
- setErr(null); setOpMsg('Testing...');
237
- try {
238
- const res = await axios.post(`${base}/api/providers/${encodeURIComponent(id)}/test`);
239
- const result = res?.data?.result;
240
- const conn = result?.connectivity;
241
- if (conn?.success) {
242
- setOpMsg(`Connectivity OK -> ${id} (${conn.latencyMs}ms)`);
243
- setErr(null);
244
- } else {
245
- setErr(`Connectivity error: ${conn?.error || 'Unknown error'}`);
246
- setOpMsg(null);
247
- }
248
- } catch (e: any) {
249
- setErr(e?.response?.data?.message || e?.message || `Test failed -> ${id}`);
250
- setOpMsg(null);
251
- } finally {
252
- if (stage !== 'list') setStage('list');
253
- await loadProviders({ keepError: true });
254
- }
255
- };
256
-
257
- const doEdit = async (id: string, name: string, apiKey: string) => {
258
- setStage('editing');
259
- setErr(null); setOpMsg(null);
260
- try {
261
- const updates: Record<string, string> = {};
262
- if (name.trim()) updates.name = name.trim();
263
- if (apiKey.trim()) updates.apiKey = apiKey.trim();
264
- await axios.put(`${base}/api/providers/${encodeURIComponent(id)}`, updates);
265
- setOpMsg(`Updated Provider -> ${name.trim() || id}`);
266
- await loadProviders();
267
- setStage('list');
268
- } catch (e: any) {
269
- setErr(e?.response?.data?.message || e?.message || 'Edit failed');
270
- setStage('edit_input_key');
271
- }
272
- };
273
-
274
- const doRemove = async (id: string) => {
275
- setStage('removing');
276
- setErr(null); setOpMsg(null);
277
- try {
278
- await axios.delete(`${base}/api/providers/${encodeURIComponent(id)}`);
279
- setOpMsg(`Deleted Provider -> ${id}`);
280
- await loadProviders();
281
- setStage('list');
282
- } catch (e: any) {
283
- setErr(e?.response?.data?.message || e?.message || 'Delete failed');
284
- setStage('list');
285
- }
286
- };
287
-
288
- const MAX_LIST = 5;
289
-
290
- // Render function for main list
291
- const renderList = () => {
292
- const visibleProviders = providers.slice(0, MAX_LIST);
293
- const overflow = providers.length - MAX_LIST;
294
- return (
295
- <Box flexDirection="column" flexGrow={1}>
296
- <Title color="cyan">Provider List</Title>
297
- {!providers.length && !loading && <StateDisplay type="empty" message="No providers found" />}
298
- <Box flexDirection="column" marginBottom={1}>
299
- {visibleProviders.map(p => {
300
- const isCur = currentProvider && (currentProvider.id === p.id);
301
- return (
302
- <Box key={p.id}>
303
- <Text color={isCur ? 'green' : undefined} bold={isCur}>
304
- {isCur ? '● ' : ' '}{p.name || '-'}
305
- {isCur ? <Text dimColor> (Current)</Text> : ''}
306
- </Text>
307
- </Box>
308
- );
309
- })}
310
- {overflow > 0 && <Text dimColor> ...and {overflow} more</Text>}
311
- </Box>
312
-
313
- {loading && <StateDisplay type="loading" message="Loading..." />}
314
-
315
- <Box flexDirection="column" flexGrow={1}>
316
- <SelectInput
317
- items={[
318
- { label: 'Add Provider', value: 'add' },
319
- { label: 'Edit Provider (Name/Key)', value: 'edit' },
320
- { label: 'Configure Slots', value: 'slots' },
321
- { label: 'Connectivity Test', value: 'test' },
322
- { label: 'Delete Provider', value: 'delete' },
323
- { label: 'Refresh', value: 'refresh' },
324
- ]}
325
- onSelect={item => {
326
- switch (item.value) {
327
- case 'add':
328
- setSelectedPresetId(null);
329
- setAddFields([]);
330
- setAddFieldValues({});
331
- setAddFieldIndex(0);
332
- setListOffset(0);
333
- setStage('add_select_preset');
334
- break;
335
- case 'edit':
336
- setEditId(null);
337
- setEditName('');
338
- setEditKey('');
339
- setListOffset(0);
340
- setStage('edit_select');
341
- break;
342
- case 'slots':
343
- axios.get(`${base}/api/providers/slots`)
344
- .then(r => setSlotTable(r.data as Record<string, SlotEntry>))
345
- .catch(() => {});
346
- setStage('slot_config');
347
- setListOffset(0);
348
- break;
349
- case 'test':
350
- setStage('test_select');
351
- setListOffset(0);
352
- break;
353
- case 'delete':
354
- setStage('delete_select');
355
- setListOffset(0);
356
- break;
357
- case 'refresh':
358
- loadProviders();
359
- break;
360
- }
361
- }}
362
- />
363
- {err && <StateDisplay type="error" message={err} />}
364
- {opMsg && <Box marginTop={1}><Text color="green">{opMsg}</Text></Box>}
365
- </Box>
366
- <Hint>ESC: Back · ↑↓/Enter: Select Action</Hint>
367
- </Box>
368
- );
369
- };
370
-
371
- if (stage === 'list') return renderList();
372
-
373
- if (stage === 'add_select_preset') {
374
- const items = (presets || []).map(pr => ({
375
- label: pr.websiteUrl
376
- ? `${pr.label || pr.name || pr.id} ${pr.websiteUrl}`
377
- : `${pr.label || pr.name || pr.id}`,
378
- value: pr.id
379
- }));
380
-
381
- // Add Custom option only if not already in presets
382
- const finalItems = [
383
- ...(presets.some(p => p.id === 'custom') ? [] : [{ label: 'Custom (OpenAI Compatible)', value: 'custom' }]),
384
- ...items
385
- ];
386
-
387
- if (!items.length) {
388
- return (
389
- <Box flexDirection="column" flexGrow={1}>
390
- <Title color="cyan">Select Preset</Title>
391
- <SelectInput
392
- items={finalItems}
393
- onSelect={it => {
394
- if (it.value === 'custom') {
395
- const fields: ProviderField[] = [
396
- { key: 'name', label: 'Provider Nickname', required: true, placeholder: 'My Custom API' },
397
- { key: 'baseUrl', label: 'Base URL', required: true, placeholder: 'https://api.example.com/v1' },
398
- { key: 'apiKey', label: 'API Key', required: true, secret: true },
399
- ];
400
- setSelectedPresetId('custom');
401
- setAddFields(fields);
402
- setAddFieldValues({});
403
- setAddFieldIndex(0);
404
- setListOffset(0);
405
- setStage('add_input_fields');
406
- } else {
407
- setStage('list');
408
- }
409
- }}
410
- />
411
- {!items.length && <Hint dimColor>No presets found from server, only Custom available.</Hint>}
412
- </Box>
413
- );
414
- }
415
-
416
- // const MAX_VISIBLE = 8;
417
- const start = Math.min(listOffset, Math.max(0, finalItems.length - MAX_VISIBLE));
418
- const sliced = finalItems.slice(start, start + MAX_VISIBLE);
419
-
420
- return (
421
- <Box flexDirection="column" flexGrow={1}>
422
- <Title color="cyan">Select Preset</Title>
423
- <Box flexDirection="row" flexGrow={1}>
424
- <Box flexDirection="column" flexGrow={1}>
425
- <SelectInput
426
- items={sliced}
427
- onSelect={it => {
428
- if (it.value === 'custom') {
429
- const fields: ProviderField[] = [
430
- { key: 'name', label: 'Provider Nickname', required: true, placeholder: 'My Custom API' },
431
- { key: 'baseUrl', label: 'Base URL', required: true, placeholder: 'https://api.example.com/v1' },
432
- { key: 'apiKey', label: 'API Key', required: true, secret: true },
433
- ];
434
- setSelectedPresetId('custom');
435
- setAddFields(fields);
436
- setAddFieldValues({});
437
- setAddFieldIndex(0);
438
- setListOffset(0);
439
- setStage('add_input_fields');
440
- } else {
441
- const preset = presets.find(p => p.id === (it.value as string));
442
- const fields: ProviderField[] =
443
- preset?.fields && preset.fields.length > 0
444
- ? preset.fields
445
- : [
446
- { key: 'name', label: 'Provider Nickname', required: true },
447
- { key: 'apiKey', label: 'API Key', required: true, secret: true },
448
- ];
449
- setSelectedPresetId(it.value as string);
450
- setAddFields(fields);
451
- setAddFieldValues({});
452
- setAddFieldIndex(0);
453
- setListOffset(0);
454
- setStage('add_input_fields');
455
- }
456
- }}
457
- />
458
- </Box>
459
- <ScrollBar total={finalItems.length} offset={start} height={MAX_VISIBLE} />
460
- </Box>
461
- <Hint>↑↓: Select · j Next Page · k Prev Page · ESC: Back</Hint>
462
- </Box>
463
- );
464
- }
465
-
466
- if (stage === 'add_input_fields') {
467
- const field = addFields[addFieldIndex];
468
- if (!field) {
469
- return <StateDisplay type="loading" message="Creating..." />;
470
- }
471
-
472
- const currentVal = addFieldValues[field.key] ?? field.default ?? '';
473
-
474
- const handleSubmit = (submittedVal: string) => {
475
- const val = submittedVal;
476
- if (field.required && !val.trim()) return;
477
-
478
- const merged = { ...addFieldValues, [field.key]: val };
479
-
480
- const nextIndex = addFieldIndex + 1;
481
- if (nextIndex < addFields.length) {
482
- setAddFieldValues(merged);
483
- setAddFieldIndex(nextIndex);
484
- } else {
485
- const name = merged['name'] || '';
486
- const apiKey = merged['apiKey'] || '';
487
- const baseUrl = merged['baseUrl'] || '';
488
- const extra: Record<string, string> = {};
489
- for (const [k, v] of Object.entries(merged)) {
490
- if (!['name', 'apiKey', 'baseUrl'].includes(k) && v) extra[k] = v;
491
- }
492
- setAddFieldValues(merged);
493
- void doCreate(selectedPresetId!, name, apiKey, baseUrl || undefined, extra);
494
- }
495
- };
496
-
497
- return (
498
- <Box flexDirection="column" flexGrow={1}>
499
- <Title color="cyan">
500
- Add Provider — Field {addFieldIndex + 1}/{addFields.length}
501
- </Title>
502
- <Box marginBottom={1} flexDirection="row">
503
- <Box width={20}>
504
- <Text>
505
- {field.label}{field.required ? <Text color="red"> *</Text> : ''}
506
- </Text>
507
- </Box>
508
- <Box flexGrow={1}>
509
- {field.placeholder ? <Text dimColor>({field.placeholder})</Text> : <Text />}
510
- </Box>
511
- </Box>
512
- <TextInput
513
- value={currentVal}
514
- onChange={v => setAddFieldValues(prev => ({ ...prev, [field.key]: v }))}
515
- // @ts-ignore
516
- mask={field.secret ? '*' : undefined}
517
- onSubmit={handleSubmit}
518
- />
519
- {err && <StateDisplay type="error" message={err} />}
520
- <Hint>Enter: Continue · ESC: Back to List</Hint>
521
- </Box>
522
- );
523
- }
524
-
525
- if (stage === 'creating') {
526
- return <StateDisplay type="loading" message="Creating..." />;
527
- }
528
-
529
- if (stage === 'test_select') {
530
- const items = providers.map(p => ({
531
- label: `${p.name || p.id}`,
532
- value: p.id
533
- }));
534
- return (
535
- <Box flexDirection="column" flexGrow={1}>
536
- <Title color="cyan">Select Provider to Test</Title>
537
- <SelectInput
538
- items={items}
539
- onSelect={it => doTest(it.value as string)}
540
- />
541
- {err && <StateDisplay type="error" message={err} />}
542
- <Hint>ESC: Back</Hint>
543
- </Box>
544
- );
545
- }
546
-
547
- if (stage === 'testing') {
548
- return <StateDisplay type="loading" message="Testing..." />;
549
- }
550
-
551
- if (stage === 'delete_select') {
552
- const items = providers.map(p => ({
553
- label: `${p.name || p.id}`,
554
- value: p.id
555
- }));
556
- return (
557
- <Box flexDirection="column" flexGrow={1}>
558
- <Title color="red">Select Provider to Delete</Title>
559
- <SelectInput
560
- items={items}
561
- onSelect={it => {
562
- setSelectedId(it.value as string);
563
- setStage('delete_confirm');
564
- }}
565
- />
566
- <Hint>ESC: Back</Hint>
567
- </Box>
568
- );
569
- }
570
-
571
- if (stage === 'delete_confirm') {
572
- if (!selectedId) { setStage('list'); return null; }
573
- return (
574
- <Box flexDirection="column" flexGrow={1}>
575
- <Title color="red">Confirm Delete: {selectedId}?</Title>
576
- <SelectInput
577
- items={[
578
- { label: 'Yes, Delete', value: 'yes' },
579
- { label: 'No, Back', value: 'no' }
580
- ]}
581
- onSelect={it => {
582
- if (it.value === 'no') {
583
- setStage('list');
584
- } else {
585
- void doRemove(selectedId);
586
- }
587
- }}
588
- />
589
- {err && <StateDisplay type="error" message={err} />}
590
- <Hint>ESC: Back</Hint>
591
- </Box>
592
- );
593
- }
594
-
595
- if (stage === 'removing') {
596
- return <StateDisplay type="loading" message="Deleting..." />;
597
- }
598
-
599
- if (stage === 'edit_select') {
600
- const items = providers.map(p => ({
601
- label: `${p.name || p.id}${(currentId === p.id || p.isCurrent) ? ' ← Current' : ''}`,
602
- value: p.id,
603
- }));
604
- if (!items.length) {
605
- return (
606
- <Box flexDirection="column" flexGrow={1}>
607
- <StateDisplay type="empty" message="No providers available to edit." />
608
- <SelectInput items={[{ label: '← Back', value: 'back' }]} onSelect={() => setStage('list')} />
609
- </Box>
610
- );
611
- }
612
- return (
613
- <Box flexDirection="column" flexGrow={1}>
614
- <Title color="cyan">Select Provider to Edit</Title>
615
- <SelectInput
616
- items={items}
617
- onSelect={it => {
618
- const p = providers.find(p => p.id === it.value);
619
- setEditId(it.value as string);
620
- setEditName(p?.name || '');
621
- setEditKey('');
622
- setStage('edit_input_name');
623
- }}
624
- />
625
- <Hint>ESC: Back</Hint>
626
- </Box>
627
- );
628
- }
629
-
630
- if (stage === 'edit_input_name') {
631
- return (
632
- <Box flexDirection="column" flexGrow={1}>
633
- <Title color="cyan">Edit Name</Title>
634
- <Text>Current: <Text color="cyan">{editName}</Text> (Enter to keep):</Text>
635
- <TextInput
636
- value={editName}
637
- onChange={setEditName}
638
- onSubmit={() => setStage('edit_input_key')}
639
- />
640
- <Hint>Enter: Continue · ESC: Back</Hint>
641
- </Box>
642
- );
643
- }
644
-
645
- if (stage === 'edit_input_key') {
646
- return (
647
- <Box flexDirection="column" flexGrow={1}>
648
- <Title color="cyan">Edit API Key</Title>
649
- <Text>Enter new API Key (Leave empty to keep current):</Text>
650
- <TextInput
651
- value={editKey}
652
- onChange={setEditKey}
653
- // @ts-ignore
654
- mask="*"
655
- onSubmit={() => {
656
- if (!editId) { setStage('list'); return; }
657
- doEdit(editId, editName, editKey);
658
- }}
659
- />
660
- {err && <StateDisplay type="error" message={err} />}
661
- <Hint>Enter: Save · ESC: Back</Hint>
662
- </Box>
663
- );
664
- }
665
-
666
- if (stage === 'editing') {
667
- return <StateDisplay type="loading" message="Saving..." />;
668
- }
669
-
670
- if (stage === 'slot_config') {
671
- const SLOTS = ['main', 'haiku', 'sonnet', 'opus'] as const;
672
- const SLOT_DESCS: Record<string, string> = {
673
- main: 'Main model for complex reasoning and long context.',
674
- haiku: 'Fast & light for simple Q&A and low latency.',
675
- sonnet: 'Balanced quality & speed for daily tasks.',
676
- opus: 'Strongest reasoning for deep analysis.',
677
- };
678
- const items = SLOTS.map(s => {
679
- const entry = slotTable[s];
680
- const providerName = entry
681
- ? (providers.find(p => p.id === entry.providerId)?.name || entry.providerId)
682
- : null;
683
- const modelDisplayName = entry?.label || entry?.modelId || 'Unconfigured';
684
- const status = entry ? `${providerName} / ${modelDisplayName}` : 'Unconfigured';
685
- const label = `[${s}] ${safePadEnd(status, 30)} — ${SLOT_DESCS[s]}`;
686
- return { label, value: s };
687
- });
688
- return (
689
- <Box flexDirection="column" flexGrow={1}>
690
- <Title color="cyan">Configure Model Slots</Title>
691
- {err && <StateDisplay type="error" message={err} />}
692
- {opMsg && <Box marginBottom={1}><Text color="green">{opMsg}</Text></Box>}
693
- <SelectInput
694
- items={[...items, { label: '← Back to Menu', value: 'back' }]}
695
- onSelect={it => {
696
- if (it.value === 'back') { setStage('list'); setErr(null); return; }
697
- const slotName = it.value as string;
698
- setCurrentSlotName(slotName);
699
- setErr(null);
700
- setSlotLoadingMsg(`Fetching model list...`);
701
- setStage('slot_loading');
702
- Promise.all(
703
- providers.map(p =>
704
- axios.get(`${base}/api/providers/${encodeURIComponent(p.id)}/models`)
705
- .then(r => {
706
- const data = r.data;
707
- const models = Array.isArray(data) ? data : (data?.models || []);
708
- return { id: p.id, models: (models as string[]) || [] };
709
- })
710
- .catch(() => ({ id: p.id, models: [] as string[] }))
711
- )
712
- ).then(results => {
713
- const map: Record<string, string[]> = {};
714
- results.forEach(r => { map[r.id] = r.models; });
715
- setSlotProviderModels(map);
716
- const hasAny = results.some(r => r.models.length > 0);
717
- if (!hasAny) {
718
- setErr('No models returned from any provider. Check API keys.');
719
- setStage('slot_config');
720
- } else {
721
- setListOffset(0);
722
- setStage('slot_select_model');
723
- }
724
- });
725
- }}
726
- />
727
- <Hint>ESC: Back</Hint>
728
- </Box>
729
- );
730
- }
731
-
732
- if (stage === 'slot_loading') {
733
- return (
734
- <Box flexDirection="column" flexGrow={1}>
735
- <StateDisplay type="loading" message={slotLoadingMsg || 'Fetching models...'} />
736
- <Hint>ESC: Cancel</Hint>
737
- </Box>
738
- );
739
- }
740
-
741
- if (stage === 'slot_select_model') {
742
- const items: Array<{ label: string; value: string }> = [];
743
- providers.forEach(p => {
744
- const models = slotProviderModels[p.id] || [];
745
- if (models.length === 0) return;
746
- items.push({ label: `── ${p.name || p.id} ──`, value: `__header__${p.id}` });
747
- models.forEach(m => items.push({ label: ` ${m}`, value: `${p.id}::${m}` }));
748
- });
749
-
750
- if (items.length === 0) {
751
- return (
752
- <Box flexDirection="column" flexGrow={1}>
753
- <StateDisplay type="error" message="No available models found." />
754
- <SelectInput
755
- items={[{ label: '← Back', value: 'back' }]}
756
- onSelect={() => setStage('slot_config')}
757
- />
758
- </Box>
759
- );
760
- }
761
-
762
- // const MAX_VISIBLE_MODELS = 8;
763
- const start = Math.min(listOffset, Math.max(0, items.length - MAX_VISIBLE_MODELS));
764
- const sliced = items.slice(start, start + MAX_VISIBLE_MODELS);
765
-
766
- return (
767
- <Box flexDirection="column" flexGrow={1}>
768
- <Title color="cyan">Configure Slot [{currentSlotName}] — Select Model</Title>
769
- {err && <StateDisplay type="error" message={err} />}
770
-
771
- <Box flexDirection="row" flexGrow={1}>
772
- <Box flexDirection="column" flexGrow={1}>
773
- <SelectInput
774
- items={sliced}
775
- onSelect={it => {
776
- const val = it.value as string;
777
- if (val.startsWith('__header__')) return;
778
- const sepIdx = val.indexOf('::');
779
- const providerId = val.slice(0, sepIdx);
780
- const modelId = val.slice(sepIdx + 2);
781
- axios.put(`${base}/api/providers/slots/${currentSlotName}`, {
782
- providerId,
783
- modelId,
784
- label: null,
785
- })
786
- .then(() => {
787
- setSlotTable(prev => ({
788
- ...prev,
789
- [currentSlotName]: { providerId, modelId, label: null }
790
- }));
791
- setOpMsg(`Configured [${currentSlotName}] -> ${modelId}`);
792
- setErr(null);
793
- setListOffset(0);
794
- setStage('slot_config');
795
- })
796
- .catch(e => {
797
- setErr((e as any)?.response?.data?.message || (e as any)?.message || 'Save failed');
798
- setStage('slot_config');
799
- });
800
- }}
801
- />
802
- </Box>
803
- <ScrollBar total={items.length} offset={start} height={MAX_VISIBLE_MODELS} />
804
- </Box>
805
- <Hint>↑↓: Select · j Next Page · k Prev Page · ESC: Back</Hint>
806
- </Box>
807
- );
808
- }
809
-
810
- return null;
811
- };
812
-
813
- 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
+ import { Panel, Title, Chip, Hint, StateDisplay, ScrollBar, safePadEnd } from '../manager/CliMenuUi.tsx';
7
+
8
+ type ProviderField = {
9
+ key: string;
10
+ label: string;
11
+ required?: boolean;
12
+ secret?: boolean;
13
+ placeholder?: string;
14
+ default?: string;
15
+ };
16
+
17
+ type Provider = {
18
+ id: string;
19
+ name?: string;
20
+ baseUrl?: string;
21
+ notes?: string;
22
+ isCurrent?: boolean;
23
+ models?: { main: string; haiku: string; sonnet: string; opus: string };
24
+ };
25
+
26
+ type Preset = {
27
+ id: string;
28
+ label?: string;
29
+ name?: string;
30
+ desc?: string;
31
+ baseUrl?: string;
32
+ apiFormat?: string;
33
+ needsApiKey?: boolean;
34
+ websiteUrl?: string;
35
+ fields?: ProviderField[];
36
+ };
37
+
38
+ type Stage =
39
+ | 'list'
40
+ | 'add_select_preset'
41
+ | 'add_input_fields'
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
+ height?: number;
60
+ }> = ({ apiUrl, onBack, height = 10 }) => {
61
+ const { exit } = useApp();
62
+ const [loading, setLoading] = useState(false);
63
+ const [err, setErr] = useState<string | null>(null);
64
+
65
+ // Scrolling
66
+ const [listOffset, setListOffset] = useState(0);
67
+
68
+ // Calculated visible counts based on height
69
+ const MAX_VISIBLE = Math.max(3, height - 7);
70
+ const MAX_VISIBLE_MODELS = Math.max(3, height - 6);
71
+
72
+ const [providers, setProviders] = useState<Provider[]>([]);
73
+ const [currentId, setCurrentId] = useState<string | null>(null);
74
+
75
+ const [presets, setPresets] = useState<Preset[]>([]);
76
+
77
+ const [stage, setStage] = useState<Stage>('list');
78
+
79
+ // 新增流程(动态字段)
80
+ const [selectedPresetId, setSelectedPresetId] = useState<string | null>(null);
81
+ const [addFields, setAddFields] = useState<ProviderField[]>([]);
82
+ const [addFieldValues, setAddFieldValues] = useState<Record<string, string>>({});
83
+ const [addFieldIndex, setAddFieldIndex] = useState(0);
84
+
85
+ const [opMsg, setOpMsg] = useState<string | null>(null);
86
+ const [selectedId, setSelectedId] = useState<string | null>(null);
87
+
88
+ // 编辑所需状态
89
+ const [editId, setEditId] = useState<string | null>(null);
90
+ const [editName, setEditName] = useState('');
91
+ const [editKey, setEditKey] = useState('');
92
+
93
+ // 槽位配置状态
94
+ type SlotEntry = { providerId: string; modelId: string; label?: string | null } | null;
95
+ const [slotTable, setSlotTable] = useState<Record<string, SlotEntry>>({});
96
+ const [slotProviderModels, setSlotProviderModels] = useState<Record<string, string[]>>({});
97
+ const [currentSlotName, setCurrentSlotName] = useState<string>('main');
98
+ const [slotLoadingMsg, setSlotLoadingMsg] = useState<string>('');
99
+
100
+
101
+
102
+ const base = apiUrl.replace(/\/+$/, '');
103
+
104
+ const parseListResp = (data: any): { list: Provider[]; currentId: string | null } => {
105
+ if (Array.isArray(data)) {
106
+ const cur = data.find((p: any) => p.isCurrent)?.id ?? null;
107
+ return { list: data, currentId: cur };
108
+ }
109
+ const list = data?.providers || data?.list || data?.items || [];
110
+ const cur =
111
+ data?.currentId ??
112
+ list.find((p: any) => p.isCurrent)?.id ??
113
+ null;
114
+ return { list, currentId: cur };
115
+ };
116
+
117
+ const loadProviders = useCallback(async (opts?: { keepError?: boolean }) => {
118
+ setLoading(true);
119
+ if (!opts?.keepError) setErr(null);
120
+ try {
121
+ const res = await axios.get(`${base}/api/providers`);
122
+ const { list, currentId } = parseListResp(res.data);
123
+ setProviders(list || []);
124
+ setCurrentId(currentId);
125
+ } catch (e: any) {
126
+ setErr(e?.message || 'Failed to fetch provider list');
127
+ } finally {
128
+ setLoading(false);
129
+ }
130
+ }, [base]);
131
+
132
+ const loadPresets = useCallback(async () => {
133
+ try {
134
+ const res = await axios.get(`${base}/api/providers/presets`);
135
+ const data = Array.isArray(res.data) ? res.data : (res.data?.presets || res.data?.list || []);
136
+ setPresets(data || []);
137
+ } catch (e) {
138
+ setPresets([]);
139
+ }
140
+ }, [base]);
141
+
142
+ useEffect(() => {
143
+ loadProviders();
144
+ loadPresets();
145
+ }, [loadProviders, loadPresets]);
146
+
147
+ // Key processing for Page Up/Down and Arrow keys in scrolling lists
148
+ useEffect(() => {
149
+ const handler = (buf: Buffer) => {
150
+ const s = buf.toString();
151
+ if (stage === 'add_select_preset' || stage === 'slot_select_model') {
152
+ if (s === 'j' || s === '\u001b[B') setListOffset(prev => prev + 1); // j or Down
153
+ if (s === 'k' || s === '\u001b[A') setListOffset(prev => Math.max(0, prev - 1)); // k or Up
154
+ }
155
+ };
156
+ process.stdin.on('data', handler);
157
+ return () => process.stdin.off('data', handler);
158
+ }, [stage]);
159
+
160
+ // ESC 处理:子页返回列表;列表再触发 onBack(或退出)
161
+ useEffect(() => {
162
+ const handler = (buf: Buffer) => {
163
+ const key = buf.toString();
164
+ if (key === '\u001b') {
165
+ if (stage === 'slot_select_model' || stage === 'slot_loading') {
166
+ setStage('slot_config');
167
+ setErr(null);
168
+ } else if (stage === 'slot_config') {
169
+ setStage('list');
170
+ setErr(null);
171
+ } else if (stage !== 'list') {
172
+ setStage('list');
173
+ setSelectedPresetId(null);
174
+ setAddFields([]);
175
+ setAddFieldValues({});
176
+ setAddFieldIndex(0);
177
+ setSelectedId(null);
178
+ setOpMsg(null);
179
+ setErr(null);
180
+ setEditId(null);
181
+ setEditName('');
182
+ setEditKey('');
183
+ setListOffset(0);
184
+ } else {
185
+ onBack ? onBack() : exit();
186
+ }
187
+ }
188
+ };
189
+ process.stdin.on('data', handler);
190
+ return () => process.stdin.off('data', handler);
191
+ }, [stage, onBack, exit]);
192
+
193
+ const currentProvider = useMemo(
194
+ () => providers.find(p => (currentId ? p.id === currentId : p.isCurrent)),
195
+ [providers, currentId]
196
+ );
197
+
198
+ // Actions
199
+ const doCreate = async (
200
+ presetId: string,
201
+ name: string,
202
+ apiKey: string,
203
+ baseUrl?: string,
204
+ extra?: Record<string, string>,
205
+ ) => {
206
+ setStage('creating');
207
+ setErr(null); setOpMsg(null);
208
+ try {
209
+ // 从预设补全 baseUrl(前端填的 baseUrl 优先);models 全部留空,后续通过槽位配置动态选择
210
+ const preset = presets.find(p => p.id === presetId);
211
+ const resolvedBaseUrl = baseUrl || preset?.baseUrl || '';
212
+
213
+ const body: Record<string, unknown> = {
214
+ presetId,
215
+ name,
216
+ apiKey,
217
+ baseUrl: resolvedBaseUrl,
218
+ models: { main: '', haiku: '', sonnet: '', opus: '' },
219
+ ...(preset?.apiFormat && { apiFormat: preset.apiFormat }),
220
+ };
221
+ if (extra && Object.keys(extra).length > 0) body.extra = extra;
222
+ await axios.post(`${base}/api/providers`, body);
223
+ setOpMsg(`Success -> ${name}`);
224
+ await loadProviders();
225
+ setStage('list');
226
+ } catch (e: any) {
227
+ setErr(e?.response?.data?.message || e?.message || 'Create failed');
228
+ // Go back to the last field so user sees error instead of re-triggering submit
229
+ setAddFieldIndex(Math.max(0, addFields.length - 1));
230
+ setStage('add_input_fields');
231
+ }
232
+ };
233
+
234
+ const doTest = async (id: string) => {
235
+ setStage('testing');
236
+ setErr(null); setOpMsg('Testing...');
237
+ try {
238
+ const res = await axios.post(`${base}/api/providers/${encodeURIComponent(id)}/test`);
239
+ const result = res?.data?.result;
240
+ const conn = result?.connectivity;
241
+ if (conn?.success) {
242
+ setOpMsg(`Connectivity OK -> ${id} (${conn.latencyMs}ms)`);
243
+ setErr(null);
244
+ } else {
245
+ setErr(`Connectivity error: ${conn?.error || 'Unknown error'}`);
246
+ setOpMsg(null);
247
+ }
248
+ } catch (e: any) {
249
+ setErr(e?.response?.data?.message || e?.message || `Test failed -> ${id}`);
250
+ setOpMsg(null);
251
+ } finally {
252
+ if (stage !== 'list') setStage('list');
253
+ await loadProviders({ keepError: true });
254
+ }
255
+ };
256
+
257
+ const doEdit = async (id: string, name: string, apiKey: string) => {
258
+ setStage('editing');
259
+ setErr(null); setOpMsg(null);
260
+ try {
261
+ const updates: Record<string, string> = {};
262
+ if (name.trim()) updates.name = name.trim();
263
+ if (apiKey.trim()) updates.apiKey = apiKey.trim();
264
+ await axios.put(`${base}/api/providers/${encodeURIComponent(id)}`, updates);
265
+ setOpMsg(`Updated Provider -> ${name.trim() || id}`);
266
+ await loadProviders();
267
+ setStage('list');
268
+ } catch (e: any) {
269
+ setErr(e?.response?.data?.message || e?.message || 'Edit failed');
270
+ setStage('edit_input_key');
271
+ }
272
+ };
273
+
274
+ const doRemove = async (id: string) => {
275
+ setStage('removing');
276
+ setErr(null); setOpMsg(null);
277
+ try {
278
+ await axios.delete(`${base}/api/providers/${encodeURIComponent(id)}`);
279
+ setOpMsg(`Deleted Provider -> ${id}`);
280
+ await loadProviders();
281
+ setStage('list');
282
+ } catch (e: any) {
283
+ setErr(e?.response?.data?.message || e?.message || 'Delete failed');
284
+ setStage('list');
285
+ }
286
+ };
287
+
288
+ const MAX_LIST = 5;
289
+
290
+ // Render function for main list
291
+ const renderList = () => {
292
+ const visibleProviders = providers.slice(0, MAX_LIST);
293
+ const overflow = providers.length - MAX_LIST;
294
+ return (
295
+ <Box flexDirection="column" flexGrow={1}>
296
+ <Title color="cyan">Provider List</Title>
297
+ {!providers.length && !loading && <StateDisplay type="empty" message="No providers found" />}
298
+ <Box flexDirection="column" marginBottom={1}>
299
+ {visibleProviders.map(p => {
300
+ const isCur = currentProvider && (currentProvider.id === p.id);
301
+ return (
302
+ <Box key={p.id}>
303
+ <Text color={isCur ? 'green' : undefined} bold={isCur}>
304
+ {isCur ? '● ' : ' '}{p.name || '-'}
305
+ {isCur ? <Text dimColor> (Current)</Text> : ''}
306
+ </Text>
307
+ </Box>
308
+ );
309
+ })}
310
+ {overflow > 0 && <Text dimColor> ...and {overflow} more</Text>}
311
+ </Box>
312
+
313
+ {loading && <StateDisplay type="loading" message="Loading..." />}
314
+
315
+ <Box flexDirection="column" flexGrow={1}>
316
+ <SelectInput
317
+ items={[
318
+ { label: 'Add Provider', value: 'add' },
319
+ { label: 'Edit Provider (Name/Key)', value: 'edit' },
320
+ { label: 'Configure Slots', value: 'slots' },
321
+ { label: 'Connectivity Test', value: 'test' },
322
+ { label: 'Delete Provider', value: 'delete' },
323
+ { label: 'Refresh', value: 'refresh' },
324
+ ]}
325
+ onSelect={item => {
326
+ switch (item.value) {
327
+ case 'add':
328
+ setSelectedPresetId(null);
329
+ setAddFields([]);
330
+ setAddFieldValues({});
331
+ setAddFieldIndex(0);
332
+ setListOffset(0);
333
+ setStage('add_select_preset');
334
+ break;
335
+ case 'edit':
336
+ setEditId(null);
337
+ setEditName('');
338
+ setEditKey('');
339
+ setListOffset(0);
340
+ setStage('edit_select');
341
+ break;
342
+ case 'slots':
343
+ axios.get(`${base}/api/providers/slots`)
344
+ .then(r => setSlotTable(r.data as Record<string, SlotEntry>))
345
+ .catch(() => {});
346
+ setStage('slot_config');
347
+ setListOffset(0);
348
+ break;
349
+ case 'test':
350
+ setStage('test_select');
351
+ setListOffset(0);
352
+ break;
353
+ case 'delete':
354
+ setStage('delete_select');
355
+ setListOffset(0);
356
+ break;
357
+ case 'refresh':
358
+ loadProviders();
359
+ break;
360
+ }
361
+ }}
362
+ />
363
+ {err && <StateDisplay type="error" message={err} />}
364
+ {opMsg && <Box marginTop={1}><Text color="green">{opMsg}</Text></Box>}
365
+ </Box>
366
+ <Hint>ESC: Back · ↑↓/Enter: Select Action</Hint>
367
+ </Box>
368
+ );
369
+ };
370
+
371
+ if (stage === 'list') return renderList();
372
+
373
+ if (stage === 'add_select_preset') {
374
+ const items = (presets || []).map(pr => ({
375
+ label: pr.websiteUrl
376
+ ? `${pr.label || pr.name || pr.id} ${pr.websiteUrl}`
377
+ : `${pr.label || pr.name || pr.id}`,
378
+ value: pr.id
379
+ }));
380
+
381
+ // Add Custom option only if not already in presets
382
+ const finalItems = [
383
+ ...(presets.some(p => p.id === 'custom') ? [] : [{ label: 'Custom (OpenAI Compatible)', value: 'custom' }]),
384
+ ...items
385
+ ];
386
+
387
+ if (!items.length) {
388
+ return (
389
+ <Box flexDirection="column" flexGrow={1}>
390
+ <Title color="cyan">Select Preset</Title>
391
+ <SelectInput
392
+ items={finalItems}
393
+ onSelect={it => {
394
+ if (it.value === 'custom') {
395
+ const fields: ProviderField[] = [
396
+ { key: 'name', label: 'Provider Nickname', required: true, placeholder: 'My Custom API' },
397
+ { key: 'baseUrl', label: 'Base URL', required: true, placeholder: 'https://api.example.com/v1' },
398
+ { key: 'apiKey', label: 'API Key', required: true, secret: true },
399
+ ];
400
+ setSelectedPresetId('custom');
401
+ setAddFields(fields);
402
+ setAddFieldValues({});
403
+ setAddFieldIndex(0);
404
+ setListOffset(0);
405
+ setStage('add_input_fields');
406
+ } else {
407
+ setStage('list');
408
+ }
409
+ }}
410
+ />
411
+ {!items.length && <Hint dimColor>No presets found from server, only Custom available.</Hint>}
412
+ </Box>
413
+ );
414
+ }
415
+
416
+ // const MAX_VISIBLE = 8;
417
+ const start = Math.min(listOffset, Math.max(0, finalItems.length - MAX_VISIBLE));
418
+ const sliced = finalItems.slice(start, start + MAX_VISIBLE);
419
+
420
+ return (
421
+ <Box flexDirection="column" flexGrow={1}>
422
+ <Title color="cyan">Select Preset</Title>
423
+ <Box flexDirection="row" flexGrow={1}>
424
+ <Box flexDirection="column" flexGrow={1}>
425
+ <SelectInput
426
+ items={sliced}
427
+ onSelect={it => {
428
+ if (it.value === 'custom') {
429
+ const fields: ProviderField[] = [
430
+ { key: 'name', label: 'Provider Nickname', required: true, placeholder: 'My Custom API' },
431
+ { key: 'baseUrl', label: 'Base URL', required: true, placeholder: 'https://api.example.com/v1' },
432
+ { key: 'apiKey', label: 'API Key', required: true, secret: true },
433
+ ];
434
+ setSelectedPresetId('custom');
435
+ setAddFields(fields);
436
+ setAddFieldValues({});
437
+ setAddFieldIndex(0);
438
+ setListOffset(0);
439
+ setStage('add_input_fields');
440
+ } else {
441
+ const preset = presets.find(p => p.id === (it.value as string));
442
+ const fields: ProviderField[] =
443
+ preset?.fields && preset.fields.length > 0
444
+ ? preset.fields
445
+ : [
446
+ { key: 'name', label: 'Provider Nickname', required: true },
447
+ { key: 'apiKey', label: 'API Key', required: true, secret: true },
448
+ ];
449
+ setSelectedPresetId(it.value as string);
450
+ setAddFields(fields);
451
+ setAddFieldValues({});
452
+ setAddFieldIndex(0);
453
+ setListOffset(0);
454
+ setStage('add_input_fields');
455
+ }
456
+ }}
457
+ />
458
+ </Box>
459
+ <ScrollBar total={finalItems.length} offset={start} height={MAX_VISIBLE} />
460
+ </Box>
461
+ <Hint>↑↓: Select · j Next Page · k Prev Page · ESC: Back</Hint>
462
+ </Box>
463
+ );
464
+ }
465
+
466
+ if (stage === 'add_input_fields') {
467
+ const field = addFields[addFieldIndex];
468
+ if (!field) {
469
+ return <StateDisplay type="loading" message="Creating..." />;
470
+ }
471
+
472
+ const currentVal = addFieldValues[field.key] ?? field.default ?? '';
473
+
474
+ const handleSubmit = (submittedVal: string) => {
475
+ const val = submittedVal;
476
+ if (field.required && !val.trim()) return;
477
+
478
+ const merged = { ...addFieldValues, [field.key]: val };
479
+
480
+ const nextIndex = addFieldIndex + 1;
481
+ if (nextIndex < addFields.length) {
482
+ setAddFieldValues(merged);
483
+ setAddFieldIndex(nextIndex);
484
+ } else {
485
+ const name = merged['name'] || '';
486
+ const apiKey = merged['apiKey'] || '';
487
+ const baseUrl = merged['baseUrl'] || '';
488
+ const extra: Record<string, string> = {};
489
+ for (const [k, v] of Object.entries(merged)) {
490
+ if (!['name', 'apiKey', 'baseUrl'].includes(k) && v) extra[k] = v;
491
+ }
492
+ setAddFieldValues(merged);
493
+ void doCreate(selectedPresetId!, name, apiKey, baseUrl || undefined, extra);
494
+ }
495
+ };
496
+
497
+ return (
498
+ <Box flexDirection="column" flexGrow={1}>
499
+ <Title color="cyan">
500
+ Add Provider — Field {addFieldIndex + 1}/{addFields.length}
501
+ </Title>
502
+ <Box marginBottom={1} flexDirection="row">
503
+ <Box width={20}>
504
+ <Text>
505
+ {field.label}{field.required ? <Text color="red"> *</Text> : ''}
506
+ </Text>
507
+ </Box>
508
+ <Box flexGrow={1}>
509
+ {field.placeholder ? <Text dimColor>({field.placeholder})</Text> : <Text />}
510
+ </Box>
511
+ </Box>
512
+ <TextInput
513
+ value={currentVal}
514
+ onChange={v => setAddFieldValues(prev => ({ ...prev, [field.key]: v }))}
515
+ // @ts-ignore
516
+ mask={field.secret ? '*' : undefined}
517
+ onSubmit={handleSubmit}
518
+ />
519
+ {err && <StateDisplay type="error" message={err} />}
520
+ <Hint>Enter: Continue · ESC: Back to List</Hint>
521
+ </Box>
522
+ );
523
+ }
524
+
525
+ if (stage === 'creating') {
526
+ return <StateDisplay type="loading" message="Creating..." />;
527
+ }
528
+
529
+ if (stage === 'test_select') {
530
+ const items = providers.map(p => ({
531
+ label: `${p.name || p.id}`,
532
+ value: p.id
533
+ }));
534
+ return (
535
+ <Box flexDirection="column" flexGrow={1}>
536
+ <Title color="cyan">Select Provider to Test</Title>
537
+ <SelectInput
538
+ items={items}
539
+ onSelect={it => doTest(it.value as string)}
540
+ />
541
+ {err && <StateDisplay type="error" message={err} />}
542
+ <Hint>ESC: Back</Hint>
543
+ </Box>
544
+ );
545
+ }
546
+
547
+ if (stage === 'testing') {
548
+ return <StateDisplay type="loading" message="Testing..." />;
549
+ }
550
+
551
+ if (stage === 'delete_select') {
552
+ const items = providers.map(p => ({
553
+ label: `${p.name || p.id}`,
554
+ value: p.id
555
+ }));
556
+ return (
557
+ <Box flexDirection="column" flexGrow={1}>
558
+ <Title color="red">Select Provider to Delete</Title>
559
+ <SelectInput
560
+ items={items}
561
+ onSelect={it => {
562
+ setSelectedId(it.value as string);
563
+ setStage('delete_confirm');
564
+ }}
565
+ />
566
+ <Hint>ESC: Back</Hint>
567
+ </Box>
568
+ );
569
+ }
570
+
571
+ if (stage === 'delete_confirm') {
572
+ if (!selectedId) { setStage('list'); return null; }
573
+ return (
574
+ <Box flexDirection="column" flexGrow={1}>
575
+ <Title color="red">Confirm Delete: {selectedId}?</Title>
576
+ <SelectInput
577
+ items={[
578
+ { label: 'Yes, Delete', value: 'yes' },
579
+ { label: 'No, Back', value: 'no' }
580
+ ]}
581
+ onSelect={it => {
582
+ if (it.value === 'no') {
583
+ setStage('list');
584
+ } else {
585
+ void doRemove(selectedId);
586
+ }
587
+ }}
588
+ />
589
+ {err && <StateDisplay type="error" message={err} />}
590
+ <Hint>ESC: Back</Hint>
591
+ </Box>
592
+ );
593
+ }
594
+
595
+ if (stage === 'removing') {
596
+ return <StateDisplay type="loading" message="Deleting..." />;
597
+ }
598
+
599
+ if (stage === 'edit_select') {
600
+ const items = providers.map(p => ({
601
+ label: `${p.name || p.id}${(currentId === p.id || p.isCurrent) ? ' ← Current' : ''}`,
602
+ value: p.id,
603
+ }));
604
+ if (!items.length) {
605
+ return (
606
+ <Box flexDirection="column" flexGrow={1}>
607
+ <StateDisplay type="empty" message="No providers available to edit." />
608
+ <SelectInput items={[{ label: '← Back', value: 'back' }]} onSelect={() => setStage('list')} />
609
+ </Box>
610
+ );
611
+ }
612
+ return (
613
+ <Box flexDirection="column" flexGrow={1}>
614
+ <Title color="cyan">Select Provider to Edit</Title>
615
+ <SelectInput
616
+ items={items}
617
+ onSelect={it => {
618
+ const p = providers.find(p => p.id === it.value);
619
+ setEditId(it.value as string);
620
+ setEditName(p?.name || '');
621
+ setEditKey('');
622
+ setStage('edit_input_name');
623
+ }}
624
+ />
625
+ <Hint>ESC: Back</Hint>
626
+ </Box>
627
+ );
628
+ }
629
+
630
+ if (stage === 'edit_input_name') {
631
+ return (
632
+ <Box flexDirection="column" flexGrow={1}>
633
+ <Title color="cyan">Edit Name</Title>
634
+ <Text>Current: <Text color="cyan">{editName}</Text> (Enter to keep):</Text>
635
+ <TextInput
636
+ value={editName}
637
+ onChange={setEditName}
638
+ onSubmit={() => setStage('edit_input_key')}
639
+ />
640
+ <Hint>Enter: Continue · ESC: Back</Hint>
641
+ </Box>
642
+ );
643
+ }
644
+
645
+ if (stage === 'edit_input_key') {
646
+ return (
647
+ <Box flexDirection="column" flexGrow={1}>
648
+ <Title color="cyan">Edit API Key</Title>
649
+ <Text>Enter new API Key (Leave empty to keep current):</Text>
650
+ <TextInput
651
+ value={editKey}
652
+ onChange={setEditKey}
653
+ // @ts-ignore
654
+ mask="*"
655
+ onSubmit={() => {
656
+ if (!editId) { setStage('list'); return; }
657
+ doEdit(editId, editName, editKey);
658
+ }}
659
+ />
660
+ {err && <StateDisplay type="error" message={err} />}
661
+ <Hint>Enter: Save · ESC: Back</Hint>
662
+ </Box>
663
+ );
664
+ }
665
+
666
+ if (stage === 'editing') {
667
+ return <StateDisplay type="loading" message="Saving..." />;
668
+ }
669
+
670
+ if (stage === 'slot_config') {
671
+ const SLOTS = ['main', 'haiku', 'sonnet', 'opus'] as const;
672
+ const SLOT_DESCS: Record<string, string> = {
673
+ main: 'Main model for complex reasoning and long context.',
674
+ haiku: 'Fast & light for simple Q&A and low latency.',
675
+ sonnet: 'Balanced quality & speed for daily tasks.',
676
+ opus: 'Strongest reasoning for deep analysis.',
677
+ };
678
+ const items = SLOTS.map(s => {
679
+ const entry = slotTable[s];
680
+ const providerName = entry
681
+ ? (providers.find(p => p.id === entry.providerId)?.name || entry.providerId)
682
+ : null;
683
+ const modelDisplayName = entry?.label || entry?.modelId || 'Unconfigured';
684
+ const status = entry ? `${providerName} / ${modelDisplayName}` : 'Unconfigured';
685
+ const label = `[${s}] ${safePadEnd(status, 30)} — ${SLOT_DESCS[s]}`;
686
+ return { label, value: s };
687
+ });
688
+ return (
689
+ <Box flexDirection="column" flexGrow={1}>
690
+ <Title color="cyan">Configure Model Slots</Title>
691
+ {err && <StateDisplay type="error" message={err} />}
692
+ {opMsg && <Box marginBottom={1}><Text color="green">{opMsg}</Text></Box>}
693
+ <SelectInput
694
+ items={[...items, { label: '← Back to Menu', value: 'back' }]}
695
+ onSelect={it => {
696
+ if (it.value === 'back') { setStage('list'); setErr(null); return; }
697
+ const slotName = it.value as string;
698
+ setCurrentSlotName(slotName);
699
+ setErr(null);
700
+ setSlotLoadingMsg(`Fetching model list...`);
701
+ setStage('slot_loading');
702
+ Promise.all(
703
+ providers.map(p =>
704
+ axios.get(`${base}/api/providers/${encodeURIComponent(p.id)}/models`)
705
+ .then(r => {
706
+ const data = r.data;
707
+ const models = Array.isArray(data) ? data : (data?.models || []);
708
+ return { id: p.id, models: (models as string[]) || [] };
709
+ })
710
+ .catch(() => ({ id: p.id, models: [] as string[] }))
711
+ )
712
+ ).then(results => {
713
+ const map: Record<string, string[]> = {};
714
+ results.forEach(r => { map[r.id] = r.models; });
715
+ setSlotProviderModels(map);
716
+ const hasAny = results.some(r => r.models.length > 0);
717
+ if (!hasAny) {
718
+ setErr('No models returned from any provider. Check API keys.');
719
+ setStage('slot_config');
720
+ } else {
721
+ setListOffset(0);
722
+ setStage('slot_select_model');
723
+ }
724
+ });
725
+ }}
726
+ />
727
+ <Hint>ESC: Back</Hint>
728
+ </Box>
729
+ );
730
+ }
731
+
732
+ if (stage === 'slot_loading') {
733
+ return (
734
+ <Box flexDirection="column" flexGrow={1}>
735
+ <StateDisplay type="loading" message={slotLoadingMsg || 'Fetching models...'} />
736
+ <Hint>ESC: Cancel</Hint>
737
+ </Box>
738
+ );
739
+ }
740
+
741
+ if (stage === 'slot_select_model') {
742
+ const items: Array<{ label: string; value: string }> = [];
743
+ providers.forEach(p => {
744
+ const models = slotProviderModels[p.id] || [];
745
+ if (models.length === 0) return;
746
+ items.push({ label: `── ${p.name || p.id} ──`, value: `__header__${p.id}` });
747
+ models.forEach(m => items.push({ label: ` ${m}`, value: `${p.id}::${m}` }));
748
+ });
749
+
750
+ if (items.length === 0) {
751
+ return (
752
+ <Box flexDirection="column" flexGrow={1}>
753
+ <StateDisplay type="error" message="No available models found." />
754
+ <SelectInput
755
+ items={[{ label: '← Back', value: 'back' }]}
756
+ onSelect={() => setStage('slot_config')}
757
+ />
758
+ </Box>
759
+ );
760
+ }
761
+
762
+ // const MAX_VISIBLE_MODELS = 8;
763
+ const start = Math.min(listOffset, Math.max(0, items.length - MAX_VISIBLE_MODELS));
764
+ const sliced = items.slice(start, start + MAX_VISIBLE_MODELS);
765
+
766
+ return (
767
+ <Box flexDirection="column" flexGrow={1}>
768
+ <Title color="cyan">Configure Slot [{currentSlotName}] — Select Model</Title>
769
+ {err && <StateDisplay type="error" message={err} />}
770
+
771
+ <Box flexDirection="row" flexGrow={1}>
772
+ <Box flexDirection="column" flexGrow={1}>
773
+ <SelectInput
774
+ items={sliced}
775
+ onSelect={it => {
776
+ const val = it.value as string;
777
+ if (val.startsWith('__header__')) return;
778
+ const sepIdx = val.indexOf('::');
779
+ const providerId = val.slice(0, sepIdx);
780
+ const modelId = val.slice(sepIdx + 2);
781
+ axios.put(`${base}/api/providers/slots/${currentSlotName}`, {
782
+ providerId,
783
+ modelId,
784
+ label: null,
785
+ })
786
+ .then(() => {
787
+ setSlotTable(prev => ({
788
+ ...prev,
789
+ [currentSlotName]: { providerId, modelId, label: null }
790
+ }));
791
+ setOpMsg(`Configured [${currentSlotName}] -> ${modelId}`);
792
+ setErr(null);
793
+ setListOffset(0);
794
+ setStage('slot_config');
795
+ })
796
+ .catch(e => {
797
+ setErr((e as any)?.response?.data?.message || (e as any)?.message || 'Save failed');
798
+ setStage('slot_config');
799
+ });
800
+ }}
801
+ />
802
+ </Box>
803
+ <ScrollBar total={items.length} offset={start} height={MAX_VISIBLE_MODELS} />
804
+ </Box>
805
+ <Hint>↑↓: Select · j Next Page · k Prev Page · ESC: Back</Hint>
806
+ </Box>
807
+ );
808
+ }
809
+
810
+ return null;
811
+ };
812
+
813
+ export default ProviderPanel;