min-agent 0.5.0 → 0.5.1

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.
@@ -0,0 +1,534 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useEffect, useRef, useState } from "react";
3
+ import { Box, Text, render, useInput } from "ink";
4
+ import InkSpinner from "ink-spinner";
5
+ import path from "path";
6
+ import { fetchModelsLive, getActiveProvider, getConfigDir, loadConfig, normalizeOllamaBaseURL, saveConfig, } from "../../config.js";
7
+ import { theme } from "../../tui/theme.js";
8
+ import { detectLocalOllama } from "./detect.js";
9
+ import { applyAddProvider, applyRemoveProvider, applySwitchProvider, applyUpdateProvider, filterChoices, maskApiKey, suggestName, validateProviderName, } from "./provider-form.js";
10
+ const OPENAI_URL = "https://api.openai.com/v1";
11
+ const OLLAMA_URL = "http://localhost:11434/v1";
12
+ export async function renderSetupWizard(mode) {
13
+ return new Promise((resolve) => {
14
+ const instance = render(_jsx(SetupApp, { mode: mode, onDone: (result) => {
15
+ instance.unmount();
16
+ resolve(result);
17
+ } }), { exitOnCtrlC: false });
18
+ });
19
+ }
20
+ function SetupApp({ mode, onDone }) {
21
+ const [screen, setScreen] = useState(() => mode === "session-gate" ? { id: "gate" } : mode === "hub" ? { id: "hub" } : { id: "type" });
22
+ const [draft, setDraft] = useState(null);
23
+ const [detected, setDetected] = useState({ found: false });
24
+ const [error, setError] = useState("");
25
+ const [tick, setTick] = useState(0);
26
+ const reload = () => setTick((n) => n + 1);
27
+ void tick;
28
+ const config = loadConfig();
29
+ const abortFirstRun = () => onDone("aborted");
30
+ const finishHub = () => onDone("saved");
31
+ return (_jsxs(Box, { flexDirection: "column", paddingX: 1, paddingY: 1, children: [_jsx(Text, { bold: true, color: theme.accent, children: "\u914D\u7F6E\u670D\u52A1\u5546" }), screen.id === "gate" && (_jsx(ChoiceList, { items: [
32
+ { label: "开始配置", value: "start" },
33
+ { label: "退出", value: "exit" },
34
+ ], onSelect: (value) => {
35
+ if (value === "exit")
36
+ onDone("aborted");
37
+ else
38
+ setScreen({ id: "type" });
39
+ }, onCancel: () => onDone("aborted") })), screen.id === "hub" && (_jsx(HubScreen, { config: config, onAdd: () => {
40
+ setDraft({
41
+ fromHub: true,
42
+ type: "openai-compatible",
43
+ baseURL: OPENAI_URL,
44
+ apiKey: "",
45
+ defaultModel: "",
46
+ name: "",
47
+ });
48
+ setError("");
49
+ setScreen({ id: "type" });
50
+ }, onPick: (action) => {
51
+ const providers = config.providers ?? [];
52
+ if (action === "switch" && providers.length <= 1) {
53
+ setScreen({ id: "message", text: "当前只有一个服务商,无需切换。", back: "hub" });
54
+ return;
55
+ }
56
+ setScreen({ id: "pick", action });
57
+ }, onDone: finishHub })), screen.id === "pick" && (_jsx(ProviderPick, { config: config, onCancel: () => setScreen({ id: "hub" }), onSelect: (provider) => {
58
+ const name = provider.name ?? "";
59
+ if (screen.action === "switch") {
60
+ const next = applySwitchProvider(config, name);
61
+ if (isFormError(next)) {
62
+ setScreen({ id: "message", text: next.error, back: "hub" });
63
+ return;
64
+ }
65
+ saveConfig(next);
66
+ logSaved();
67
+ reload();
68
+ setScreen({ id: "hub" });
69
+ return;
70
+ }
71
+ if (screen.action === "delete") {
72
+ setScreen({ id: "delete-confirm", name });
73
+ return;
74
+ }
75
+ if (screen.action === "test") {
76
+ void (async () => {
77
+ const live = await fetchModelsLive(provider.baseURL, provider.apiKey);
78
+ if (live.ok && live.models.length > 0) {
79
+ setScreen({
80
+ id: "message",
81
+ text: `可访问,${live.models.length} 个模型`,
82
+ back: "hub",
83
+ });
84
+ return;
85
+ }
86
+ const status = live.status != null ? `HTTP ${live.status}。` : "";
87
+ const hint = provider.type === "ollama" ? "请确认 Ollama 已启动。" : "请检查 API 地址和密钥。";
88
+ setScreen({
89
+ id: "message",
90
+ text: redactSecrets(`无法连接到该服务商。${status}${hint}`),
91
+ back: "hub",
92
+ });
93
+ })();
94
+ return;
95
+ }
96
+ setDraft(draftFromProvider(provider, true));
97
+ setError("");
98
+ setScreen({ id: "type" });
99
+ } })), screen.id === "delete-confirm" && (_jsx(ChoiceList, { title: `确定删除服务商 "${screen.name}"?`, items: [
100
+ { label: "删除", value: "yes" },
101
+ { label: "取消", value: "no" },
102
+ ], onSelect: (value) => {
103
+ if (value !== "yes") {
104
+ setScreen({ id: "hub" });
105
+ return;
106
+ }
107
+ const next = applyRemoveProvider(loadConfig(), screen.name);
108
+ if (isFormError(next)) {
109
+ setScreen({ id: "message", text: next.error, back: "hub" });
110
+ return;
111
+ }
112
+ saveConfig(next);
113
+ logSaved();
114
+ reload();
115
+ setScreen({ id: "hub" });
116
+ }, onCancel: () => setScreen({ id: "hub" }) })), screen.id === "message" && (_jsx(MessageScreen, { text: screen.text, onClose: () => {
117
+ if (screen.back === "exit-saved")
118
+ finishHub();
119
+ else
120
+ setScreen({ id: "hub" });
121
+ } })), screen.id === "type" && (_jsx(TypeScreen, { detected: detected, onDetected: setDetected, initial: draft?.type, onCancel: () => {
122
+ if (draft?.fromHub || mode === "hub")
123
+ setScreen({ id: "hub" });
124
+ else
125
+ abortFirstRun();
126
+ }, onSelect: (type) => {
127
+ const next = startDraft(type, draft, detected);
128
+ setDraft(next);
129
+ setError("");
130
+ if (type === "openai")
131
+ setScreen({ id: "key" });
132
+ else
133
+ setScreen({ id: "url" });
134
+ } })), screen.id === "url" && draft && (_jsx(FieldScreen, { label: draft.type === "ollama" ? "Ollama 地址" : "API 地址", value: draft.baseURL, error: error, onChange: (baseURL) => setDraft({ ...draft, baseURL }), onCancel: () => {
135
+ setError("");
136
+ setScreen({ id: "type" });
137
+ }, onSubmit: () => {
138
+ const trimmed = draft.baseURL.trim();
139
+ if (!trimmed) {
140
+ setError("API 地址不能为空。");
141
+ return;
142
+ }
143
+ const baseURL = draft.type === "ollama" ? normalizeOllamaBaseURL(trimmed) : trimmed.replace(/\/$/, "");
144
+ setDraft({ ...draft, baseURL });
145
+ setError("");
146
+ if (draft.type === "ollama")
147
+ setScreen({ id: "fetch" });
148
+ else
149
+ setScreen({ id: "key" });
150
+ } })), screen.id === "key" && draft && (_jsx(FieldScreen, { label: "API \u5BC6\u94A5", value: draft.apiKey, mask: true, placeholder: draft.existingKey ? `已保存 (${maskApiKey(draft.existingKey)}),回车保留` : undefined, error: error, onChange: (apiKey) => setDraft({ ...draft, apiKey }), onCancel: () => {
151
+ setError("");
152
+ if (draft.type === "openai")
153
+ setScreen({ id: "type" });
154
+ else
155
+ setScreen({ id: "url" });
156
+ }, onSubmit: () => {
157
+ if (!draft.apiKey.trim() && draft.existingKey) {
158
+ setDraft({ ...draft, apiKey: draft.existingKey });
159
+ setError("");
160
+ setScreen({ id: "fetch" });
161
+ return;
162
+ }
163
+ if (!draft.apiKey.trim()) {
164
+ setError("API 密钥不能为空。");
165
+ return;
166
+ }
167
+ setError("");
168
+ setScreen({ id: "fetch" });
169
+ } })), screen.id === "fetch" && draft && (_jsx(FetchScreen, { draft: draft, onBack: () => setScreen(draft.type === "ollama" ? { id: "url" } : { id: "key" }), onList: (models) => {
170
+ setDraft({ ...draft, models });
171
+ setError("");
172
+ setScreen({ id: "models", models });
173
+ }, onEmpty: () => {
174
+ setError("");
175
+ setScreen({ id: "model-manual" });
176
+ } })), screen.id === "models" && draft && (_jsx(ModelListScreen, { models: screen.models, current: draft.defaultModel, onCancel: () => setScreen(draft.type === "openai" ? { id: "key" } : { id: "url" }), onSelect: (defaultModel) => {
177
+ setDraft({ ...draft, defaultModel });
178
+ setError("");
179
+ setScreen({ id: "name" });
180
+ } })), screen.id === "model-manual" && draft && (_jsx(FieldScreen, { label: "\u65E0\u6CD5\u83B7\u53D6\u6A21\u578B\u5217\u8868\uFF0C\u8BF7\u624B\u52A8\u8F93\u5165\u9ED8\u8BA4\u6A21\u578B", value: draft.defaultModel, error: error, onChange: (defaultModel) => setDraft({ ...draft, defaultModel }), onCancel: () => setScreen(draft.type === "openai" ? { id: "key" } : { id: "url" }), onSubmit: () => {
181
+ if (!draft.defaultModel.trim()) {
182
+ setError("默认模型不能为空。");
183
+ return;
184
+ }
185
+ setError("");
186
+ if (draft.type === "openai-compatible")
187
+ setScreen({ id: "context" });
188
+ else
189
+ setScreen({ id: "name" });
190
+ } })), screen.id === "context" && draft && (_jsx(FieldScreen, { label: "\u4E0A\u4E0B\u6587\u7A97\u53E3\uFF08token\uFF0C\u56DE\u8F66\u8DF3\u8FC7\uFF09", value: draft.contextWindow != null ? String(draft.contextWindow) : "", error: error, onChange: (raw) => {
191
+ const trimmed = raw.trim();
192
+ if (!trimmed) {
193
+ setDraft({ ...draft, contextWindow: undefined });
194
+ return;
195
+ }
196
+ const n = Number.parseInt(trimmed, 10);
197
+ setDraft({ ...draft, contextWindow: Number.isFinite(n) ? n : undefined });
198
+ }, onCancel: () => setScreen({ id: "model-manual" }), onSubmit: (raw) => {
199
+ const trimmed = raw.trim();
200
+ if (!trimmed) {
201
+ setDraft({ ...draft, contextWindow: undefined });
202
+ setError("");
203
+ setScreen({ id: "name" });
204
+ return;
205
+ }
206
+ const n = Number.parseInt(trimmed, 10);
207
+ if (!Number.isFinite(n) || n <= 0) {
208
+ setError("上下文窗口必须是大于 0 的整数。");
209
+ return;
210
+ }
211
+ setDraft({ ...draft, contextWindow: n });
212
+ setError("");
213
+ setScreen({ id: "name" });
214
+ } })), screen.id === "name" && draft && (_jsx(FieldScreen, { label: "\u540D\u79F0", value: draft.name, error: error, onChange: (name) => setDraft({ ...draft, name }), onCancel: () => {
215
+ if (draft.models && draft.models.length > 0)
216
+ setScreen({ id: "models", models: draft.models });
217
+ else
218
+ setScreen({ id: "model-manual" });
219
+ }, onSubmit: () => {
220
+ const nameError = validateProviderName(draft.name);
221
+ if (nameError) {
222
+ setError(nameError);
223
+ return;
224
+ }
225
+ setError("");
226
+ setScreen({ id: "summary" });
227
+ } })), screen.id === "summary" && draft && (_jsx(SummaryScreen, { draft: draft, error: error, onCancel: () => setScreen({ id: "name" }), onConfirm: () => {
228
+ const result = commitDraft(draft);
229
+ if (isFormError(result)) {
230
+ setError(result.error);
231
+ setScreen({ id: "name" });
232
+ return;
233
+ }
234
+ saveConfig(result);
235
+ logSaved();
236
+ if (draft.fromHub || mode === "hub") {
237
+ reload();
238
+ setDraft(null);
239
+ setScreen({ id: "hub" });
240
+ return;
241
+ }
242
+ onDone("saved");
243
+ } }))] }));
244
+ }
245
+ function HubScreen({ config, onAdd, onPick, onDone, }) {
246
+ const providers = config.providers ?? [];
247
+ const items = [
248
+ { label: "添加", value: "add" },
249
+ { label: "设为当前", value: "switch" },
250
+ { label: "修改", value: "edit" },
251
+ { label: "删除", value: "delete" },
252
+ { label: "测试连接", value: "test" },
253
+ { label: "完成", value: "done" },
254
+ ];
255
+ return (_jsxs(Box, { flexDirection: "column", children: [providers.map((p) => (_jsxs(Text, { color: p.name === config.activeProvider ? theme.accent : theme.muted, children: [p.name === config.activeProvider ? "* " : " ", p.name ?? "(未命名)", " ", p.baseURL, " ", p.defaultModel ?? "未设置模型"] }, p.name ?? p.baseURL))), _jsx(ChoiceList, { items: items, onSelect: (value) => {
256
+ if (value === "add")
257
+ onAdd();
258
+ else if (value === "done")
259
+ onDone();
260
+ else
261
+ onPick(value);
262
+ }, onCancel: onDone })] }));
263
+ }
264
+ function ProviderPick({ config, onSelect, onCancel, }) {
265
+ const providers = config.providers ?? [];
266
+ const active = getActiveProvider(config)?.name;
267
+ return (_jsx(ChoiceList, { items: providers.map((p) => ({
268
+ label: `${p.name ?? "(未命名)"}${p.name === active ? " *" : ""} ${p.baseURL}`,
269
+ value: p.name ?? "",
270
+ })), initial: Math.max(0, providers.findIndex((p) => p.name === active)), onSelect: (name) => {
271
+ const provider = providers.find((p) => p.name === name);
272
+ if (provider)
273
+ onSelect(provider);
274
+ }, onCancel: onCancel }));
275
+ }
276
+ function TypeScreen({ detected, onDetected, initial, onSelect, onCancel, }) {
277
+ useEffect(() => {
278
+ let cancelled = false;
279
+ void detectLocalOllama().then((result) => {
280
+ if (!cancelled)
281
+ onDetected(result);
282
+ });
283
+ return () => {
284
+ cancelled = true;
285
+ };
286
+ }, [onDetected]);
287
+ const items = [
288
+ { label: "OpenAI 兼容接口", hint: "OpenRouter、DeepSeek、Groq 等", value: "openai-compatible" },
289
+ { label: "OpenAI", hint: "官方接口", value: "openai" },
290
+ {
291
+ label: "Ollama(本地)",
292
+ hint: detected.found ? "已检测到" : "本机模型",
293
+ value: "ollama",
294
+ },
295
+ ];
296
+ const initialIndex = initial === "openai" ? 1 : initial === "ollama" ? 2 : detected.found && !initial ? 2 : 0;
297
+ return (_jsx(ChoiceList, { items: items, initial: initialIndex, onSelect: (value) => onSelect(value), onCancel: onCancel }, `${initial ?? "auto"}-${detected.found ? "detected" : "plain"}`));
298
+ }
299
+ function FetchScreen({ draft, onList, onEmpty, onBack, }) {
300
+ const onListRef = useRef(onList);
301
+ const onEmptyRef = useRef(onEmpty);
302
+ onListRef.current = onList;
303
+ onEmptyRef.current = onEmpty;
304
+ useInput((_input, key) => {
305
+ if (key.escape)
306
+ onBack();
307
+ });
308
+ useEffect(() => {
309
+ let cancelled = false;
310
+ void fetchModelsLive(draft.baseURL, draft.apiKey || "ollama").then((live) => {
311
+ if (cancelled)
312
+ return;
313
+ if (live.ok && live.models.length > 0)
314
+ onListRef.current(live.models);
315
+ else
316
+ onEmptyRef.current();
317
+ });
318
+ return () => {
319
+ cancelled = true;
320
+ };
321
+ }, [draft.apiKey, draft.baseURL]);
322
+ return (_jsxs(Box, { children: [_jsx(Text, { color: theme.accent, children: _jsx(InkSpinner, { type: "dots" }) }), _jsx(Text, { color: theme.muted, children: " \u6B63\u5728\u83B7\u53D6\u6A21\u578B\u5217\u8868\u2026" })] }));
323
+ }
324
+ function ModelListScreen({ models, current, onSelect, onCancel, }) {
325
+ const [query, setQuery] = useState("");
326
+ const [index, setIndex] = useState(0);
327
+ const filtered = filterChoices(query, models);
328
+ const q = query.trim();
329
+ useArmEnter((input, key, isEnter) => {
330
+ if (key.escape) {
331
+ onCancel();
332
+ return;
333
+ }
334
+ if (key.downArrow && filtered.length > 0) {
335
+ setIndex((i) => (i + 1) % filtered.length);
336
+ return;
337
+ }
338
+ if (key.upArrow && filtered.length > 0) {
339
+ setIndex((i) => (i - 1 + filtered.length) % filtered.length);
340
+ return;
341
+ }
342
+ if (isEnter) {
343
+ if (filtered.length > 0)
344
+ onSelect(filtered[Math.min(index, filtered.length - 1)]);
345
+ else if (q)
346
+ onSelect(q);
347
+ return;
348
+ }
349
+ if (key.backspace || key.delete) {
350
+ setQuery((v) => Array.from(v).slice(0, -1).join(""));
351
+ setIndex(0);
352
+ return;
353
+ }
354
+ if (key.ctrl || key.meta || key.tab)
355
+ return;
356
+ const clean = input.replace(/[\r\n]+$/, "");
357
+ if (clean) {
358
+ setQuery((v) => v + clean);
359
+ setIndex(0);
360
+ }
361
+ });
362
+ const shownIndex = Math.min(index, Math.max(0, filtered.length - 1));
363
+ const windowSize = 12;
364
+ const windowStart = Math.min(shownIndex, Math.max(0, filtered.length - windowSize));
365
+ const shown = filtered.slice(windowStart, windowStart + windowSize);
366
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { color: theme.muted, children: "\u8F93\u5165\u53EF\u7B5B\u9009" }), _jsx(Text, { color: query ? theme.accent : theme.muted, children: query || " " }), shown.map((model, i) => {
367
+ const selected = windowStart + i === shownIndex;
368
+ return (_jsxs(Text, { color: selected ? theme.accent : undefined, bold: selected, children: [selected ? "▸ " : " ", model, model === current ? " ← 当前" : ""] }, model));
369
+ }), filtered.length === 0 && q !== "" && _jsxs(Text, { color: theme.accent, children: ["\u25B8 \u4F7F\u7528\u81EA\u5B9A\u4E49\u6A21\u578B: ", q] }), _jsx(Text, { color: theme.muted, children: "\u2191\u2193 \u9009\u62E9 \u00B7 Enter \u786E\u8BA4 \u00B7 Esc \u8FD4\u56DE" })] }));
370
+ }
371
+ function FieldScreen({ label, value, mask, placeholder, error, onChange, onSubmit, onCancel, }) {
372
+ useArmEnter((input, key, isEnter) => {
373
+ if (key.escape) {
374
+ onCancel();
375
+ return;
376
+ }
377
+ if (isEnter) {
378
+ onSubmit(value);
379
+ return;
380
+ }
381
+ if (key.backspace || key.delete) {
382
+ onChange(Array.from(value).slice(0, -1).join(""));
383
+ return;
384
+ }
385
+ if (key.ctrl || key.meta || key.tab)
386
+ return;
387
+ const clean = input.replace(/[\r\n]+$/, "");
388
+ if (clean)
389
+ onChange(value + clean);
390
+ });
391
+ const shown = mask ? "*".repeat(Array.from(value).length) : value;
392
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { children: label }), _jsx(Text, { color: theme.accent, children: shown || placeholder || " " }), error ? _jsx(Text, { color: theme.error, children: error }) : null, _jsx(Text, { color: theme.muted, children: "Enter \u786E\u8BA4 \u00B7 Esc \u8FD4\u56DE" })] }));
393
+ }
394
+ function SummaryScreen({ draft, error, onConfirm, onCancel, }) {
395
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { children: ["\u7C7B\u578B ", typeLabel(draft.type)] }), _jsxs(Text, { children: ["\u5730\u5740 ", draft.baseURL] }), _jsxs(Text, { children: ["\u5BC6\u94A5 ", maskApiKey(draft.apiKey)] }), _jsxs(Text, { children: ["\u6A21\u578B ", draft.defaultModel] }), _jsxs(Text, { children: ["\u540D\u79F0 ", draft.name.trim()] }), error ? _jsx(Text, { color: theme.error, children: error }) : null, _jsx(ChoiceList, { items: [
396
+ { label: "保存", value: "save" },
397
+ { label: "返回", value: "back" },
398
+ ], onSelect: (value) => {
399
+ if (value === "save")
400
+ onConfirm();
401
+ else
402
+ onCancel();
403
+ }, onCancel: onCancel })] }));
404
+ }
405
+ function MessageScreen({ text, onClose }) {
406
+ useArmEnter((_input, key, isEnter) => {
407
+ if (key.escape || isEnter)
408
+ onClose();
409
+ });
410
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { children: text }), _jsx(Text, { color: theme.muted, children: "Enter / Esc \u7EE7\u7EED" })] }));
411
+ }
412
+ function ChoiceList({ title, items, initial = 0, onSelect, onCancel, }) {
413
+ const [index, setIndex] = useState(Math.min(Math.max(0, initial), Math.max(0, items.length - 1)));
414
+ useArmEnter((_input, key, isEnter) => {
415
+ if (key.escape) {
416
+ onCancel();
417
+ return;
418
+ }
419
+ if (items.length === 0)
420
+ return;
421
+ if (key.downArrow) {
422
+ setIndex((i) => (i + 1) % items.length);
423
+ return;
424
+ }
425
+ if (key.upArrow) {
426
+ setIndex((i) => (i - 1 + items.length) % items.length);
427
+ return;
428
+ }
429
+ if (isEnter)
430
+ onSelect(items[index].value);
431
+ });
432
+ return (_jsxs(Box, { flexDirection: "column", children: [title ? _jsx(Text, { children: title }) : null, items.map((item, i) => (_jsxs(Text, { color: i === index ? theme.accent : undefined, bold: i === index, children: [i === index ? "▸ " : " ", item.label, item.hint ? _jsxs(Text, { color: theme.muted, children: [" ", item.hint] }) : null] }, item.value))), _jsx(Text, { color: theme.muted, children: "\u2191\u2193 \u9009\u62E9 \u00B7 Enter \u786E\u8BA4 \u00B7 Esc \u8FD4\u56DE" })] }));
433
+ }
434
+ function useArmEnter(handler) {
435
+ const armed = useRef(false);
436
+ const handlerRef = useRef(handler);
437
+ handlerRef.current = handler;
438
+ useEffect(() => {
439
+ const timer = setTimeout(() => {
440
+ armed.current = true;
441
+ }, 0);
442
+ return () => clearTimeout(timer);
443
+ }, []);
444
+ useInput((input, key) => {
445
+ const isEnter = key.return || /[\r\n]+$/.test(input);
446
+ if (isEnter && !armed.current)
447
+ return;
448
+ handlerRef.current(input, key, isEnter);
449
+ });
450
+ }
451
+ function startDraft(type, previous, detected) {
452
+ const fromHub = previous?.fromHub === true || previous?.editingName != null;
453
+ const editingName = previous?.editingName;
454
+ if (type === "openai") {
455
+ return {
456
+ fromHub,
457
+ editingName,
458
+ type,
459
+ baseURL: OPENAI_URL,
460
+ apiKey: "",
461
+ existingKey: previous?.type === "openai" ? (previous.existingKey ?? previous.apiKey) : previous?.existingKey,
462
+ defaultModel: previous?.type === "openai" ? previous.defaultModel : "",
463
+ name: previous?.name ?? "",
464
+ };
465
+ }
466
+ if (type === "ollama") {
467
+ return {
468
+ fromHub,
469
+ editingName,
470
+ type,
471
+ baseURL: detected.baseURL || previous?.baseURL || OLLAMA_URL,
472
+ apiKey: "ollama",
473
+ defaultModel: previous?.type === "ollama" ? previous.defaultModel : "llama3",
474
+ name: previous?.name ?? "",
475
+ };
476
+ }
477
+ return {
478
+ fromHub,
479
+ editingName,
480
+ type,
481
+ baseURL: previous?.type === "openai-compatible" ? previous.baseURL : OPENAI_URL,
482
+ apiKey: "",
483
+ existingKey: previous?.existingKey,
484
+ defaultModel: previous?.defaultModel ?? "",
485
+ name: previous?.name ?? "",
486
+ };
487
+ }
488
+ function draftFromProvider(provider, fromHub) {
489
+ const type = provider.type ?? "openai-compatible";
490
+ return {
491
+ fromHub,
492
+ editingName: provider.name,
493
+ type,
494
+ baseURL: provider.baseURL,
495
+ apiKey: "",
496
+ existingKey: provider.apiKey,
497
+ defaultModel: provider.defaultModel ?? "",
498
+ contextWindow: provider.contextWindow,
499
+ name: provider.name ?? "",
500
+ };
501
+ }
502
+ function commitDraft(draft) {
503
+ const config = loadConfig();
504
+ const existing = (config.providers ?? []).map((p) => p.name).filter((name) => Boolean(name));
505
+ const name = draft.name.trim() ||
506
+ suggestName(draft.type, draft.baseURL, existing.filter((item) => item !== draft.editingName));
507
+ const payload = {
508
+ name,
509
+ type: draft.type,
510
+ baseURL: draft.baseURL,
511
+ apiKey: draft.apiKey || draft.existingKey || (draft.type === "ollama" ? "ollama" : ""),
512
+ defaultModel: draft.defaultModel.trim(),
513
+ ...(draft.contextWindow != null ? { contextWindow: draft.contextWindow } : {}),
514
+ };
515
+ if (draft.editingName)
516
+ return applyUpdateProvider(config, draft.editingName, payload);
517
+ return applyAddProvider(config, payload, { overwrite: false });
518
+ }
519
+ function typeLabel(type) {
520
+ if (type === "openai")
521
+ return "OpenAI";
522
+ if (type === "ollama")
523
+ return "Ollama";
524
+ return "OpenAI 兼容接口";
525
+ }
526
+ function logSaved() {
527
+ console.log(`已保存到 ${path.join(getConfigDir(), "config.json")}`);
528
+ }
529
+ function redactSecrets(text) {
530
+ return text.replace(/Bearer\s+\S+/gi, "Bearer ****").replace(/\bsk-[A-Za-z0-9_-]+/g, "sk-****");
531
+ }
532
+ function isFormError(result) {
533
+ return Object.keys(result).length === 1 && "error" in result;
534
+ }