@fanchaozz/provider-manager 0.2.2 → 1.0.0
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/README.md +257 -244
- package/README_EN.md +12 -0
- package/components.ts +192 -40
- package/forms.ts +700 -542
- package/package.json +47 -47
- package/sync.ts +3 -1
- package/ui.ts +20 -10
package/forms.ts
CHANGED
|
@@ -1,542 +1,700 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* forms.ts — TUI 表单流程
|
|
3
|
-
*
|
|
4
|
-
* 每个 flow 是一串 ctx.ui.input / ctx.ui.select / ctx.ui.confirm 调用,
|
|
5
|
-
* 最后写盘 models.json + 通知用户。
|
|
6
|
-
*
|
|
7
|
-
* pi 实际 API(位置 string 参数,不是 object):
|
|
8
|
-
* input(title, placeholder?, opts?) -> Promise<string | undefined>
|
|
9
|
-
* select(title, options: string[], opts?) -> Promise<string | undefined>
|
|
10
|
-
* confirm(title, message, opts?) -> Promise<boolean>
|
|
11
|
-
*
|
|
12
|
-
* 与 LLM 工具(tools.ts)不共用——LLM 工具走自己的参数 schema。
|
|
13
|
-
*/
|
|
14
|
-
|
|
15
|
-
import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
|
|
16
|
-
import { getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
17
|
-
import { readModelsJson, writeModelsJson, backupExists, restoreBackup, type ModelsJson, type ProviderConfig, type ModelConfig, ALLOWED_APIS } from "./store.ts";
|
|
18
|
-
import { fetchListing, inferModel, diffModels } from "./sync.ts";
|
|
19
|
-
import { ModelChecklist, FormEditor, type FormField } from "./components.ts";
|
|
20
|
-
|
|
21
|
-
// ============================================================================
|
|
22
|
-
// 共用 prompt helpers
|
|
23
|
-
// ============================================================================
|
|
24
|
-
|
|
25
|
-
// select 的 options 必须是 string[],不能是 {label,value}。直接把 value 字符串化。
|
|
26
|
-
const API_OPTIONS: string[] = [
|
|
27
|
-
...ALLOWED_APIS,
|
|
28
|
-
"(none / 由 model 字段指定)",
|
|
29
|
-
];
|
|
30
|
-
|
|
31
|
-
/** 新 model 的默认配置。调 /providers model <pid> add 或 dashboard n 走 addModelFlow 时
|
|
32
|
-
* 会问 "Use defaults?",回答 yes → 套这里的所有值;回答 no → 逐个问。 */
|
|
33
|
-
export const DEFAULT_MODEL_CONFIG: {
|
|
34
|
-
reasoning: boolean;
|
|
35
|
-
input: ("text" | "image")[];
|
|
36
|
-
contextWindow: number;
|
|
37
|
-
maxTokens: number;
|
|
38
|
-
thinkingLevelMap: ModelConfig["thinkingLevelMap"];
|
|
39
|
-
compat: { supportsDeveloperRole: boolean };
|
|
40
|
-
} = {
|
|
41
|
-
reasoning: true,
|
|
42
|
-
input: ["text", "image"],
|
|
43
|
-
contextWindow: 128000,
|
|
44
|
-
maxTokens: 16384,
|
|
45
|
-
thinkingLevelMap: {
|
|
46
|
-
off: null,
|
|
47
|
-
minimal: null,
|
|
48
|
-
low: null,
|
|
49
|
-
medium: "medium", // 默认只勾 medium
|
|
50
|
-
high: null,
|
|
51
|
-
xhigh: null,
|
|
52
|
-
max: null,
|
|
53
|
-
},
|
|
54
|
-
// Zhipu GLM 等 OpenAI-compat 网关拒收 role:"developer"(会返 422)。默认 false → pi 用 system role。
|
|
55
|
-
compat: { supportsDeveloperRole: false },
|
|
56
|
-
};
|
|
57
|
-
|
|
58
|
-
// ============================================================================
|
|
59
|
-
// user-level override: ~/.pi/agent/provider-manager.json#defaultModel
|
|
60
|
-
// ============================================================================
|
|
61
|
-
|
|
62
|
-
import { readFileSync, writeFileSync, mkdirSync } from "node:fs";
|
|
63
|
-
import { existsSync } from "node:fs";
|
|
64
|
-
import { dirname, join } from "node:path";
|
|
65
|
-
|
|
66
|
-
export function getDefaultModelConfigPath(): string {
|
|
67
|
-
const ov = (globalThis as any)[Symbol.for("pi-provider-manager:default-model-path-override")] as string | undefined;
|
|
68
|
-
if (ov) return ov;
|
|
69
|
-
return join(getAgentDir(), "provider-manager.json");
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
/**
|
|
73
|
-
* If ~/.pi/agent/provider-manager.json does not exist, write current code DEFAULT_MODEL_CONFIG to it.
|
|
74
|
-
* Existing file is left untouched. Returns path written or null.
|
|
75
|
-
* 同步执行:index.ts 启动后立即调用;`pi -p` 模式进程会立即退出,async 会被中断。
|
|
76
|
-
*/
|
|
77
|
-
export function ensureDefaultConfigFile(): string | null {
|
|
78
|
-
try {
|
|
79
|
-
const p = getDefaultModelConfigPath();
|
|
80
|
-
if (existsSync(p)) return null;
|
|
81
|
-
mkdirSync(dirname(p), { recursive: true });
|
|
82
|
-
writeFileSync(p, JSON.stringify({
|
|
83
|
-
_defaultModel: "New model defaults used by /providers model <pid> add and dashboard n. When asked Use default config? answering yes applies these; no = per-field prompts. Edit then save -> next add picks up changes.",
|
|
84
|
-
defaultModel: DEFAULT_MODEL_CONFIG,
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
return
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
)
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
}
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
const
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
):
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
ctx.ui.notify(`
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
}
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
const
|
|
392
|
-
|
|
393
|
-
const
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
};
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
)
|
|
421
|
-
|
|
422
|
-
const
|
|
423
|
-
|
|
424
|
-
if (!
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
}
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
}
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
});
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
const
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
1
|
+
/**
|
|
2
|
+
* forms.ts — TUI 表单流程
|
|
3
|
+
*
|
|
4
|
+
* 每个 flow 是一串 ctx.ui.input / ctx.ui.select / ctx.ui.confirm 调用,
|
|
5
|
+
* 最后写盘 models.json + 通知用户。
|
|
6
|
+
*
|
|
7
|
+
* pi 实际 API(位置 string 参数,不是 object):
|
|
8
|
+
* input(title, placeholder?, opts?) -> Promise<string | undefined>
|
|
9
|
+
* select(title, options: string[], opts?) -> Promise<string | undefined>
|
|
10
|
+
* confirm(title, message, opts?) -> Promise<boolean>
|
|
11
|
+
*
|
|
12
|
+
* 与 LLM 工具(tools.ts)不共用——LLM 工具走自己的参数 schema。
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
|
|
16
|
+
import { getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
17
|
+
import { readModelsJson, writeModelsJson, backupExists, restoreBackup, type ModelsJson, type ProviderConfig, type ModelConfig, ALLOWED_APIS } from "./store.ts";
|
|
18
|
+
import { fetchListing, inferModel, diffModels } from "./sync.ts";
|
|
19
|
+
import { ModelChecklist, FormEditor, type FormField } from "./components.ts";
|
|
20
|
+
|
|
21
|
+
// ============================================================================
|
|
22
|
+
// 共用 prompt helpers
|
|
23
|
+
// ============================================================================
|
|
24
|
+
|
|
25
|
+
// select 的 options 必须是 string[],不能是 {label,value}。直接把 value 字符串化。
|
|
26
|
+
const API_OPTIONS: string[] = [
|
|
27
|
+
...ALLOWED_APIS,
|
|
28
|
+
"(none / 由 model 字段指定)",
|
|
29
|
+
];
|
|
30
|
+
|
|
31
|
+
/** 新 model 的默认配置。调 /providers model <pid> add 或 dashboard n 走 addModelFlow 时
|
|
32
|
+
* 会问 "Use defaults?",回答 yes → 套这里的所有值;回答 no → 逐个问。 */
|
|
33
|
+
export const DEFAULT_MODEL_CONFIG: {
|
|
34
|
+
reasoning: boolean;
|
|
35
|
+
input: ("text" | "image")[];
|
|
36
|
+
contextWindow: number;
|
|
37
|
+
maxTokens: number;
|
|
38
|
+
thinkingLevelMap: ModelConfig["thinkingLevelMap"];
|
|
39
|
+
compat: { supportsDeveloperRole: boolean };
|
|
40
|
+
} = {
|
|
41
|
+
reasoning: true,
|
|
42
|
+
input: ["text", "image"],
|
|
43
|
+
contextWindow: 128000,
|
|
44
|
+
maxTokens: 16384,
|
|
45
|
+
thinkingLevelMap: {
|
|
46
|
+
off: null,
|
|
47
|
+
minimal: null,
|
|
48
|
+
low: null,
|
|
49
|
+
medium: "medium", // 默认只勾 medium
|
|
50
|
+
high: null,
|
|
51
|
+
xhigh: null,
|
|
52
|
+
max: null,
|
|
53
|
+
},
|
|
54
|
+
// Zhipu GLM 等 OpenAI-compat 网关拒收 role:"developer"(会返 422)。默认 false → pi 用 system role。
|
|
55
|
+
compat: { supportsDeveloperRole: false },
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
// ============================================================================
|
|
59
|
+
// user-level override: ~/.pi/agent/provider-manager.json#defaultModel
|
|
60
|
+
// ============================================================================
|
|
61
|
+
|
|
62
|
+
import { readFileSync, writeFileSync, mkdirSync } from "node:fs";
|
|
63
|
+
import { existsSync } from "node:fs";
|
|
64
|
+
import { dirname, join } from "node:path";
|
|
65
|
+
|
|
66
|
+
export function getDefaultModelConfigPath(): string {
|
|
67
|
+
const ov = (globalThis as any)[Symbol.for("pi-provider-manager:default-model-path-override")] as string | undefined;
|
|
68
|
+
if (ov) return ov;
|
|
69
|
+
return join(getAgentDir(), "provider-manager.json");
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* If ~/.pi/agent/provider-manager.json does not exist, write current code DEFAULT_MODEL_CONFIG to it.
|
|
74
|
+
* Existing file is left untouched. Returns path written or null.
|
|
75
|
+
* 同步执行:index.ts 启动后立即调用;`pi -p` 模式进程会立即退出,async 会被中断。
|
|
76
|
+
*/
|
|
77
|
+
export function ensureDefaultConfigFile(): string | null {
|
|
78
|
+
try {
|
|
79
|
+
const p = getDefaultModelConfigPath();
|
|
80
|
+
if (existsSync(p)) return null;
|
|
81
|
+
mkdirSync(dirname(p), { recursive: true });
|
|
82
|
+
writeFileSync(p, JSON.stringify({
|
|
83
|
+
_defaultModel: "New model defaults used by /providers model <pid> add and dashboard n. When asked Use default config? answering yes applies these; no = per-field prompts. Edit then save -> next add picks up changes.",
|
|
84
|
+
defaultModel: DEFAULT_MODEL_CONFIG,
|
|
85
|
+
_syncViewportSize: "Sync checklist viewport height (rows). Affects how many models are visible at once during /providers sync. Default = 8. Range 5-200. To override: add \"syncViewportSize\": 30 here.",
|
|
86
|
+
}, null, 2) + "\n", { mode: 0o600 });
|
|
87
|
+
return p;
|
|
88
|
+
} catch (err) {
|
|
89
|
+
console.error(`[provider-manager] ensureDefaultConfigFile failed:`, err);
|
|
90
|
+
return null;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* 读 ~/.pi/agent/provider-manager.json 的 syncViewportSize 字段。
|
|
96
|
+
* 用于 sync checklist 视口大小(一次性能看到的 model 行数)。
|
|
97
|
+
* 不存在 / 非法 / 越界(< 5 或 > 200)→ 返回 null,由调用方走默认。
|
|
98
|
+
*/
|
|
99
|
+
export function getSyncViewportSize(): number | null {
|
|
100
|
+
try {
|
|
101
|
+
const p = getDefaultModelConfigPath();
|
|
102
|
+
if (!existsSync(p)) return null;
|
|
103
|
+
const raw = readFileSync(p, "utf8");
|
|
104
|
+
const parsed = JSON.parse(raw);
|
|
105
|
+
const v = parsed?.syncViewportSize;
|
|
106
|
+
if (typeof v !== "number" || !Number.isFinite(v)) return null;
|
|
107
|
+
const n = Math.floor(v);
|
|
108
|
+
// 越界保护:5-200 之间。太小装不下任何项,太大可能让 framework 不裁剪(体验差)
|
|
109
|
+
if (n < 5 || n > 200) return null;
|
|
110
|
+
return n;
|
|
111
|
+
} catch {
|
|
112
|
+
return null;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function isValidDefaultModelConfig(v: any): v is typeof DEFAULT_MODEL_CONFIG {
|
|
117
|
+
const compatOk = !v.compat
|
|
118
|
+
|| (typeof v.compat === "object" && !Array.isArray(v.compat) && (
|
|
119
|
+
v.compat.supportsDeveloperRole === undefined
|
|
120
|
+
|| typeof v.compat.supportsDeveloperRole === "boolean"
|
|
121
|
+
));
|
|
122
|
+
return (
|
|
123
|
+
v && typeof v === "object" &&
|
|
124
|
+
typeof v.reasoning === "boolean" &&
|
|
125
|
+
Array.isArray(v.input) && v.input.every((x: any) => x === "text" || x === "image") && v.input.length > 0 &&
|
|
126
|
+
typeof v.contextWindow === "number" && v.contextWindow > 0 && Number.isFinite(v.contextWindow) &&
|
|
127
|
+
typeof v.maxTokens === "number" && v.maxTokens > 0 && Number.isFinite(v.maxTokens) &&
|
|
128
|
+
v.thinkingLevelMap && typeof v.thinkingLevelMap === "object" && !Array.isArray(v.thinkingLevelMap) &&
|
|
129
|
+
compatOk
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* 加载 default model config:先读 ~/.pi/agent/provider-manager.json 的 defaultModel 字段,
|
|
135
|
+
* 不存在或非法 → 走代码 DEFAULT_MODEL_CONFIG+同步异常不崩+
|
|
136
|
+
*/
|
|
137
|
+
export function loadDefaultModelConfig(): typeof DEFAULT_MODEL_CONFIG {
|
|
138
|
+
try {
|
|
139
|
+
const p = getDefaultModelConfigPath();
|
|
140
|
+
if (!existsSync(p)) return DEFAULT_MODEL_CONFIG;
|
|
141
|
+
const raw = readFileSync(p, "utf8");
|
|
142
|
+
const parsed = JSON.parse(raw);
|
|
143
|
+
const cfg = parsed?.defaultModel;
|
|
144
|
+
if (isValidDefaultModelConfig(cfg)) {
|
|
145
|
+
// 补全缺失的 compat(老 config 没有这个字段时默认为 false)
|
|
146
|
+
if (!cfg.compat) cfg.compat = { supportsDeveloperRole: false };
|
|
147
|
+
return cfg;
|
|
148
|
+
}
|
|
149
|
+
} catch {
|
|
150
|
+
// 回退到代码默认
|
|
151
|
+
}
|
|
152
|
+
return DEFAULT_MODEL_CONFIG;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
async function askInput(
|
|
156
|
+
ctx: ExtensionCommandContext,
|
|
157
|
+
opts: { message: string; placeholder?: string; secret?: boolean; defaultValue?: string; validate?: (s: string) => string | null },
|
|
158
|
+
): Promise<string | undefined> {
|
|
159
|
+
// title 拼到 message 里(pi 只能传一个 string)
|
|
160
|
+
let title = opts.message;
|
|
161
|
+
if (opts.secret) title += " (input hidden)";
|
|
162
|
+
const result = await ctx.ui.input(title, opts.placeholder);
|
|
163
|
+
if (result === undefined) return undefined; // Esc 取消
|
|
164
|
+
const trimmed = result.trim();
|
|
165
|
+
// 空输入 + 有 defaultValue → 保留原值(empty = keep)
|
|
166
|
+
if (trimmed === "" && opts.defaultValue !== undefined) {
|
|
167
|
+
const v = opts.defaultValue;
|
|
168
|
+
if (opts.validate) {
|
|
169
|
+
const err = opts.validate(v);
|
|
170
|
+
if (err) { ctx.ui.notify(err, "error"); return undefined; }
|
|
171
|
+
}
|
|
172
|
+
return v;
|
|
173
|
+
}
|
|
174
|
+
if (opts.validate) {
|
|
175
|
+
const err = opts.validate(trimmed);
|
|
176
|
+
if (err) { ctx.ui.notify(err, "error"); return undefined; }
|
|
177
|
+
}
|
|
178
|
+
return trimmed;
|
|
179
|
+
}
|
|
180
|
+
async function askSelect(
|
|
181
|
+
ctx: ExtensionCommandContext,
|
|
182
|
+
opts: { message: string; options: string[]; defaultValue?: string },
|
|
183
|
+
): Promise<string | undefined> {
|
|
184
|
+
const result = await ctx.ui.select(opts.message, opts.options);
|
|
185
|
+
if (result === undefined) return undefined;
|
|
186
|
+
return result;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
async function askConfirm(ctx: ExtensionCommandContext, title: string, message: string): Promise<boolean | undefined> {
|
|
190
|
+
return ctx.ui.confirm(title, message);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** 包 FormEditor 进 ctx.ui.custom dialog。返回 { saved, values } 或 { saved: false, values: initial }。 */
|
|
194
|
+
async function runFormEditor<T extends Record<string, unknown>>(
|
|
195
|
+
ctx: ExtensionCommandContext,
|
|
196
|
+
title: string,
|
|
197
|
+
fields: FormField[],
|
|
198
|
+
initial: T,
|
|
199
|
+
): Promise<{ saved: boolean; values: T }> {
|
|
200
|
+
const result = await ctx.ui.custom<{ saved: boolean; values: T } | undefined>((_tui, theme, _kb, done) => {
|
|
201
|
+
return new FormEditor({
|
|
202
|
+
title,
|
|
203
|
+
fields,
|
|
204
|
+
initial,
|
|
205
|
+
theme,
|
|
206
|
+
onSave: (values: T) => done({ saved: true, values }),
|
|
207
|
+
onCancel: () => done(undefined),
|
|
208
|
+
});
|
|
209
|
+
}).catch((err) => {
|
|
210
|
+
// 框架抛错(不是用户取消 Esc)要让用户知道
|
|
211
|
+
console.error(`[provider-manager] form editor error:`, err);
|
|
212
|
+
ctx.ui.notify(`Form editor error: ${err instanceof Error ? err.message : err}`, "error");
|
|
213
|
+
return undefined;
|
|
214
|
+
});
|
|
215
|
+
return result ?? { saved: false, values: initial };
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// ============================================================================
|
|
219
|
+
// Provider CRUD
|
|
220
|
+
// ============================================================================
|
|
221
|
+
|
|
222
|
+
export async function addProviderFlow(ctx: ExtensionCommandContext, onDone: () => void): Promise<void> {
|
|
223
|
+
if (ctx.mode !== "tui") {
|
|
224
|
+
ctx.ui.notify("add provider 需要 TUI 模式。打开 /providers 后按 n", "warning");
|
|
225
|
+
onDone?.();
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// 与 editProviderFlow 同形:一次性表单采集所有字段(含 id)
|
|
230
|
+
// 错误(id 重复 / id 非法)不走 notify 弹窗 — 留在表单里提示,点 s 后再调
|
|
231
|
+
const json = await readModelsJson();
|
|
232
|
+
const fields: FormField[] = [
|
|
233
|
+
{ key: "id", label: "id", type: "text", hint: "[a-z0-9_-]+", validate: (s) => {
|
|
234
|
+
if (!s) return "id required";
|
|
235
|
+
if (!/^[a-z0-9_-]+$/i.test(s as string)) return "id must match [a-z0-9_-]+";
|
|
236
|
+
if (json.providers[s as string]) return `provider "${s}" already exists`;
|
|
237
|
+
return null;
|
|
238
|
+
} },
|
|
239
|
+
{ key: "name", label: "Display name", type: "text", hint: "(empty = unset)" },
|
|
240
|
+
{ key: "baseUrl", label: "baseUrl", type: "text" },
|
|
241
|
+
{ key: "apiKey", label: "apiKey", type: "secret" },
|
|
242
|
+
{ key: "api", label: "api", type: "select", options: API_OPTIONS, hint: "(empty = unset)" },
|
|
243
|
+
{ key: "authHeader", label: "authHeader", type: "select", options: ["no", "yes"] },
|
|
244
|
+
{ key: "proxy", label: "proxy", type: "text", hint: "(empty = unset; http://host:port)" },
|
|
245
|
+
];
|
|
246
|
+
const initial: Record<string, unknown> = {
|
|
247
|
+
id: "",
|
|
248
|
+
name: "",
|
|
249
|
+
baseUrl: "",
|
|
250
|
+
apiKey: "",
|
|
251
|
+
api: "",
|
|
252
|
+
authHeader: "no",
|
|
253
|
+
proxy: "",
|
|
254
|
+
};
|
|
255
|
+
const result = await runFormEditor(ctx, `Add provider`, fields, initial);
|
|
256
|
+
if (!result.saved) { onDone?.(); return; }
|
|
257
|
+
const v = result.values;
|
|
258
|
+
const id = (v.id as string).trim();
|
|
259
|
+
// 二次校验:表单后还会再查一次(同进程可能别的并发写)
|
|
260
|
+
if (json.providers[id]) {
|
|
261
|
+
ctx.ui.notify(`Provider "${id}" already exists. Use /providers remove ${id} first.`, "error");
|
|
262
|
+
onDone?.();
|
|
263
|
+
return;
|
|
264
|
+
}
|
|
265
|
+
const newProv: ProviderConfig = {
|
|
266
|
+
...((v.name as string) ? { name: v.name as string } : {}),
|
|
267
|
+
baseUrl: ((v.baseUrl as string) || "") || undefined,
|
|
268
|
+
apiKey: ((v.apiKey as string) || "") || undefined,
|
|
269
|
+
api: ((v.api as string) || "") || undefined,
|
|
270
|
+
authHeader: v.authHeader === "yes",
|
|
271
|
+
proxy: ((v.proxy as string) || "") || undefined,
|
|
272
|
+
models: [],
|
|
273
|
+
};
|
|
274
|
+
try {
|
|
275
|
+
const fresh = await readModelsJson();
|
|
276
|
+
await writeModelsJson({ ...fresh, providers: { ...fresh.providers, [id]: newProv } });
|
|
277
|
+
ctx.ui.notify(`✓ Provider "${id}" added. Use 'y' to sync models.`, "success");
|
|
278
|
+
} catch (err) {
|
|
279
|
+
ctx.ui.notify(`Write failed: ${err instanceof Error ? err.message : err}`, "error");
|
|
280
|
+
}
|
|
281
|
+
onDone?.();
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/** @deprecated Models are added/removed via sync; this flow is for cases where sync
|
|
285
|
+
* cannot reach the upstream (offline / private deploy / unsupported listing). Uses
|
|
286
|
+
* loadDefaultModelConfig() as the field template. The "compat.supportsDeveloperRole:false"
|
|
287
|
+
* default is preserved here too (Zhipu GLM 等 OpenAI-compat 网关需 false 避免 422)。 */
|
|
288
|
+
export async function addModelFlow(
|
|
289
|
+
ctx: ExtensionCommandContext,
|
|
290
|
+
providerId: string,
|
|
291
|
+
onDone?: () => void,
|
|
292
|
+
): Promise<void> {
|
|
293
|
+
if (ctx.mode !== "tui") {
|
|
294
|
+
ctx.ui.notify("add model 需要 TUI 模式。打开 /providers 选中 provider 后按 n", "warning");
|
|
295
|
+
onDone?.();
|
|
296
|
+
return;
|
|
297
|
+
}
|
|
298
|
+
const json = await readModelsJson();
|
|
299
|
+
const prov = json.providers[providerId];
|
|
300
|
+
if (!prov) {
|
|
301
|
+
ctx.ui.notify(`Provider "${providerId}" 不存在。`, "error");
|
|
302
|
+
onDone?.();
|
|
303
|
+
return;
|
|
304
|
+
}
|
|
305
|
+
// 先问 "Use default config?" — yes → loadDefaultModelConfig() 作初值;no → 逐项提问(0/空)
|
|
306
|
+
const useDefault = await ctx.ui.confirm(
|
|
307
|
+
"Use default model config?",
|
|
308
|
+
`Apply ~/.pi/agent/provider-manager.json#defaultModel template (reasoning / input / contextWindow / maxTokens / thinkingLevelMap / compat.supportsDeveloperRole)? yes = template values; no = per-field prompts.`,
|
|
309
|
+
);
|
|
310
|
+
if (useDefault === undefined) { onDone?.(); return; } // Esc 取消
|
|
311
|
+
const defaults = loadDefaultModelConfig();
|
|
312
|
+
|
|
313
|
+
// 字段集:id 始终问、name 问、其余问 + 给默认值
|
|
314
|
+
const fields: FormField[] = [
|
|
315
|
+
{ key: "id", label: "id", type: "text", hint: "[a-z0-9_-]+", validate: (s) => {
|
|
316
|
+
if (!s) return "id required";
|
|
317
|
+
if (!/^[a-z0-9_.-]+$/i.test(s as string)) return "id must match [a-z0-9_.-]+";
|
|
318
|
+
if ((prov.models ?? []).some((m) => m.id === s)) return `model "${s}" already exists in provider "${providerId}"`;
|
|
319
|
+
return null;
|
|
320
|
+
} },
|
|
321
|
+
{ key: "name", label: "Display name", type: "text", hint: "(empty = unset)" },
|
|
322
|
+
];
|
|
323
|
+
if (!useDefault) {
|
|
324
|
+
// 逐项提示。给一个起点初值(上一轮默认值/代码默认)
|
|
325
|
+
fields.push(
|
|
326
|
+
{ key: "reasoning", label: "reasoning", type: "select", options: ["no", "yes"], hint: "Zhipu GLM 等推理=否" },
|
|
327
|
+
{ key: "input", label: "input", type: "multiselect", options: ["text", "image"] },
|
|
328
|
+
{ key: "contextWindow", label: "contextWindow", type: "number", validate: (v) => typeof v === "number" && v >= 0 ? null : "must be non-negative" },
|
|
329
|
+
{ key: "maxTokens", label: "maxTokens", type: "number", validate: (v) => typeof v === "number" && v >= 0 ? null : "must be non-negative" },
|
|
330
|
+
{ key: "thinkingLevelMap", label: "thinkingLevelMap", type: "levelmap", hint: "(empty = remove)" },
|
|
331
|
+
// Zhipu GLM 等需 no (用 system role)
|
|
332
|
+
{ key: "supportsDeveloperRole", label: "supportsDeveloperRole (compat)", type: "select", options: ["no", "yes"], hint: "Zhipu GLM 等需 no" },
|
|
333
|
+
);
|
|
334
|
+
}
|
|
335
|
+
const initial: Record<string, unknown> = useDefault
|
|
336
|
+
? { id: "", name: "" }
|
|
337
|
+
: {
|
|
338
|
+
id: "",
|
|
339
|
+
name: "",
|
|
340
|
+
reasoning: defaults.reasoning ? "yes" : "no",
|
|
341
|
+
input: defaults.input,
|
|
342
|
+
contextWindow: defaults.contextWindow,
|
|
343
|
+
maxTokens: defaults.maxTokens,
|
|
344
|
+
thinkingLevelMap: defaults.thinkingLevelMap,
|
|
345
|
+
supportsDeveloperRole: defaults.compat?.supportsDeveloperRole === true ? "yes" : "no",
|
|
346
|
+
};
|
|
347
|
+
const result = await runFormEditor(ctx, `Add model to "${providerId}"`, fields, initial);
|
|
348
|
+
if (!result.saved) { onDone?.(); return; }
|
|
349
|
+
const v = result.values;
|
|
350
|
+
const id = (v.id as string).trim();
|
|
351
|
+
// 二次校验:表单后还会再查一次(同进程可能别的并发写)
|
|
352
|
+
if ((prov.models ?? []).some((m) => m.id === id)) {
|
|
353
|
+
ctx.ui.notify(`Model "${id}" already exists in "${providerId}".`, "error");
|
|
354
|
+
onDone?.();
|
|
355
|
+
return;
|
|
356
|
+
}
|
|
357
|
+
const newModel: ModelConfig = useDefault
|
|
358
|
+
? buildModelFromTemplate(id, v.name as string, defaults)
|
|
359
|
+
: buildModelFromFields(id, v);
|
|
360
|
+
const newModels = [...(prov.models ?? []), newModel];
|
|
361
|
+
const newProv: ProviderConfig = { ...prov, models: newModels };
|
|
362
|
+
try {
|
|
363
|
+
const fresh = await readModelsJson();
|
|
364
|
+
await writeModelsJson({ ...fresh, providers: { ...fresh.providers, [providerId]: newProv } });
|
|
365
|
+
ctx.ui.notify(`✓ Model "${id}" added to "${providerId}".`, "success");
|
|
366
|
+
} catch (err) {
|
|
367
|
+
ctx.ui.notify(`Write failed: ${err instanceof Error ? err.message : err}`, "error");
|
|
368
|
+
}
|
|
369
|
+
onDone?.();
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
/** 从 template(useDefault=yes)拼 ModelConfig。所有字段都从 defaults 拷贝。 */
|
|
373
|
+
function buildModelFromTemplate(id: string, nameRaw: string, defaults: typeof DEFAULT_MODEL_CONFIG): ModelConfig {
|
|
374
|
+
return {
|
|
375
|
+
id,
|
|
376
|
+
name: nameRaw || undefined,
|
|
377
|
+
reasoning: defaults.reasoning,
|
|
378
|
+
input: [...defaults.input],
|
|
379
|
+
contextWindow: defaults.contextWindow,
|
|
380
|
+
maxTokens: defaults.maxTokens,
|
|
381
|
+
thinkingLevelMap: { ...defaults.thinkingLevelMap },
|
|
382
|
+
// compat 拷贝不丢:Zhipu GLM 等需 supportsDeveloperRole=false 防 422
|
|
383
|
+
compat: defaults.compat ? { ...defaults.compat } : { supportsDeveloperRole: false },
|
|
384
|
+
};
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
/** 从表单(useDefault=no)拼 ModelConfig。逐项使用用户输入,缺失项用 defaults 补。 */
|
|
388
|
+
function buildModelFromFields(id: string, v: Record<string, unknown>): ModelConfig {
|
|
389
|
+
const defaults = loadDefaultModelConfig();
|
|
390
|
+
const name = ((v.name as string) || "") || undefined;
|
|
391
|
+
const reasoning = v.reasoning === "yes";
|
|
392
|
+
const input = Array.isArray(v.input) ? v.input as ("text" | "image")[] : [...defaults.input];
|
|
393
|
+
const contextWindow = (typeof v.contextWindow === "number" && v.contextWindow > 0)
|
|
394
|
+
? v.contextWindow
|
|
395
|
+
: defaults.contextWindow;
|
|
396
|
+
const maxTokens = (typeof v.maxTokens === "number" && v.maxTokens > 0)
|
|
397
|
+
? v.maxTokens
|
|
398
|
+
: defaults.maxTokens;
|
|
399
|
+
const thinkingLevelMap = v.thinkingLevelMap && typeof v.thinkingLevelMap === "object"
|
|
400
|
+
? v.thinkingLevelMap as ModelConfig["thinkingLevelMap"]
|
|
401
|
+
: { ...defaults.thinkingLevelMap };
|
|
402
|
+
// compat:保留老 compat(如果用户输入给了)+ 补 supportsDeveloperRole。0/空 → false
|
|
403
|
+
const compat: Record<string, unknown> = { ...(defaults.compat ?? {}) };
|
|
404
|
+
compat.supportsDeveloperRole = v.supportsDeveloperRole === "yes";
|
|
405
|
+
return {
|
|
406
|
+
id,
|
|
407
|
+
name,
|
|
408
|
+
reasoning,
|
|
409
|
+
input,
|
|
410
|
+
contextWindow,
|
|
411
|
+
maxTokens,
|
|
412
|
+
thinkingLevelMap,
|
|
413
|
+
compat,
|
|
414
|
+
};
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
export async function editProviderFlow(
|
|
418
|
+
ctx: ExtensionCommandContext,
|
|
419
|
+
providerId: string,
|
|
420
|
+
onDone?: () => void,
|
|
421
|
+
): Promise<void> {
|
|
422
|
+
const json = await readModelsJson();
|
|
423
|
+
const cur = json.providers[providerId];
|
|
424
|
+
if (!cur) {
|
|
425
|
+
ctx.ui.notify(`Provider "${providerId}" does not exist.`, "error");
|
|
426
|
+
onDone?.();
|
|
427
|
+
return;
|
|
428
|
+
}
|
|
429
|
+
if (ctx.mode !== "tui") {
|
|
430
|
+
ctx.ui.notify("edit 需要 TUI 模式。打开 /providers 选中 provider 后按 e", "warning");
|
|
431
|
+
onDone?.();
|
|
432
|
+
return;
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
const fields: FormField[] = [
|
|
436
|
+
{ key: "name", label: "Display name", type: "text", hint: "(empty = unset)" },
|
|
437
|
+
{ key: "baseUrl", label: "baseUrl", type: "text" },
|
|
438
|
+
{ key: "apiKey", label: "apiKey", type: "secret" },
|
|
439
|
+
{ key: "api", label: "api", type: "select", options: API_OPTIONS, hint: "1-N 选" },
|
|
440
|
+
{ key: "authHeader", label: "authHeader", type: "select", options: ["no", "yes"] },
|
|
441
|
+
{ key: "proxy", label: "proxy", type: "text", hint: "(empty = unset; http://host:port)" },
|
|
442
|
+
];
|
|
443
|
+
const initial: Record<string, unknown> = {
|
|
444
|
+
name: cur.name ?? "",
|
|
445
|
+
baseUrl: cur.baseUrl ?? "",
|
|
446
|
+
apiKey: cur.apiKey ?? "",
|
|
447
|
+
api: cur.api ?? "",
|
|
448
|
+
authHeader: cur.authHeader ? "yes" : "no",
|
|
449
|
+
proxy: cur.proxy ?? "",
|
|
450
|
+
};
|
|
451
|
+
const result = await runFormEditor(ctx, `Edit provider "${providerId}"`, fields, initial);
|
|
452
|
+
if (!result.saved) { onDone?.(); return; }
|
|
453
|
+
const v = result.values;
|
|
454
|
+
const next: ProviderConfig = {
|
|
455
|
+
...cur,
|
|
456
|
+
name: ((v.name as string) || "") || undefined,
|
|
457
|
+
baseUrl: ((v.baseUrl as string) || "") || undefined,
|
|
458
|
+
apiKey: ((v.apiKey as string) || "") || undefined,
|
|
459
|
+
api: ((v.api as string) || "") || undefined,
|
|
460
|
+
authHeader: v.authHeader === "yes",
|
|
461
|
+
proxy: ((v.proxy as string) || "") || undefined,
|
|
462
|
+
};
|
|
463
|
+
try {
|
|
464
|
+
await writeModelsJson({ ...json, providers: { ...json.providers, [providerId]: next } });
|
|
465
|
+
ctx.ui.notify(`✓ Provider "${providerId}" updated.`, "success");
|
|
466
|
+
} catch (err) {
|
|
467
|
+
ctx.ui.notify(`Write failed: ${err instanceof Error ? err.message : err}`, "error");
|
|
468
|
+
}
|
|
469
|
+
onDone?.();
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
export async function deleteProviderFlow(
|
|
473
|
+
ctx: ExtensionCommandContext,
|
|
474
|
+
providerId: string,
|
|
475
|
+
onDone?: () => void,
|
|
476
|
+
): Promise<void> {
|
|
477
|
+
const json = await readModelsJson();
|
|
478
|
+
if (!json.providers[providerId]) {
|
|
479
|
+
ctx.ui.notify(`Provider "${providerId}" does not exist.`, "error");
|
|
480
|
+
onDone?.();
|
|
481
|
+
return;
|
|
482
|
+
}
|
|
483
|
+
const ok = await askConfirm(
|
|
484
|
+
ctx,
|
|
485
|
+
`Delete provider "${providerId}"?`,
|
|
486
|
+
`This removes ${json.providers[providerId].models?.length ?? 0} model(s). Can be restored from .bak via /providers reset.`,
|
|
487
|
+
);
|
|
488
|
+
if (ok === undefined || !ok) {
|
|
489
|
+
onDone?.();
|
|
490
|
+
return;
|
|
491
|
+
}
|
|
492
|
+
const { [providerId]: _, ...rest } = json.providers;
|
|
493
|
+
try {
|
|
494
|
+
await writeModelsJson({ providers: rest });
|
|
495
|
+
ctx.ui.notify(`✓ Provider "${providerId}" deleted (restore with /providers reset).`, "success");
|
|
496
|
+
} catch (err) {
|
|
497
|
+
ctx.ui.notify(`Write failed: ${err instanceof Error ? err.message : err}`, "error");
|
|
498
|
+
}
|
|
499
|
+
onDone?.();
|
|
500
|
+
}
|
|
501
|
+
// ============================================================================
|
|
502
|
+
// Model CRUD (continued)
|
|
503
|
+
// ============================================================================
|
|
504
|
+
|
|
505
|
+
export async function editModelFlow(
|
|
506
|
+
ctx: ExtensionCommandContext,
|
|
507
|
+
providerId: string,
|
|
508
|
+
modelId: string,
|
|
509
|
+
onDone?: () => void,
|
|
510
|
+
): Promise<void> {
|
|
511
|
+
const json = await readModelsJson();
|
|
512
|
+
const prov = json.providers[providerId];
|
|
513
|
+
if (!prov) { ctx.ui.notify(`Provider "${providerId}" does not exist.`, "error"); onDone?.(); return; }
|
|
514
|
+
const cur = (prov.models ?? []).find((m) => m.id === modelId);
|
|
515
|
+
if (!cur) { ctx.ui.notify(`Model "${modelId}" does not exist in "${providerId}".`, "error"); onDone?.(); return; }
|
|
516
|
+
if (ctx.mode !== "tui") { ctx.ui.notify("edit 需要 TUI 模式。打开 /providers 选中 model 后按 e", "warning"); onDone?.(); return; }
|
|
517
|
+
|
|
518
|
+
const fields: FormField[] = [
|
|
519
|
+
{ key: "name", label: "Display name", type: "text", hint: "(empty = unset)" },
|
|
520
|
+
{ key: "reasoning", label: "reasoning", type: "select", options: ["no", "yes"] },
|
|
521
|
+
{ key: "input", label: "input", type: "multiselect", options: ["text", "image"] },
|
|
522
|
+
{ key: "contextWindow", label: "contextWindow", type: "number", validate: (v) => typeof v === "number" && v >= 0 ? null : "must be non-negative" },
|
|
523
|
+
{ key: "maxTokens", label: "maxTokens", type: "number", validate: (v) => typeof v === "number" && v >= 0 ? null : "must be non-negative" },
|
|
524
|
+
{ key: "thinkingLevelMap", label: "thinkingLevelMap", type: "levelmap", hint: "(empty = remove)" },
|
|
525
|
+
// Zhipu GLM 等 OpenAI-compat 网关不接受 role:"developer" (会返 422)。默认 no 用 system role。
|
|
526
|
+
{ key: "supportsDeveloperRole", label: "supportsDeveloperRole (compat)", type: "select", options: ["no", "yes"], hint: "Zhipu GLM 等需 no (用 system role)" },
|
|
527
|
+
];
|
|
528
|
+
const initial: Record<string, unknown> = {
|
|
529
|
+
name: cur.name ?? "",
|
|
530
|
+
reasoning: cur.reasoning === true || cur.reasoning === "yes" ? "yes" : "no",
|
|
531
|
+
input: Array.isArray(cur.input) ? cur.input.filter((x) => x === "text" || x === "image") : [],
|
|
532
|
+
contextWindow: cur.contextWindow ?? 0,
|
|
533
|
+
maxTokens: cur.maxTokens ?? 0,
|
|
534
|
+
thinkingLevelMap: cur.thinkingLevelMap ?? null,
|
|
535
|
+
supportsDeveloperRole: (cur.compat as any)?.supportsDeveloperRole === true ? "yes" : "no",
|
|
536
|
+
};
|
|
537
|
+
const result = await runFormEditor(ctx, `Edit model "${providerId}/${modelId}"`, fields, initial);
|
|
538
|
+
if (!result.saved) { onDone?.(); return; }
|
|
539
|
+
const v = result.values;
|
|
540
|
+
const next: ModelConfig = {
|
|
541
|
+
...cur,
|
|
542
|
+
name: ((v.name as string) || "") || undefined,
|
|
543
|
+
reasoning: v.reasoning === "yes",
|
|
544
|
+
input: Array.isArray(v.input) ? v.input : (v.input === "image" ? ["text", "image"] : ["text"]),
|
|
545
|
+
contextWindow: (v.contextWindow as number) || undefined,
|
|
546
|
+
maxTokens: (v.maxTokens as number) || undefined,
|
|
547
|
+
thinkingLevelMap: (v.thinkingLevelMap as Record<string, unknown> | null) ?? undefined,
|
|
548
|
+
compat: { ...(cur.compat ?? {}), supportsDeveloperRole: v.supportsDeveloperRole === "yes" },
|
|
549
|
+
};
|
|
550
|
+
const newModels = (prov.models ?? []).map((m) => (m.id === modelId ? next : m));
|
|
551
|
+
const newProv: ProviderConfig = { ...prov, models: newModels };
|
|
552
|
+
try {
|
|
553
|
+
await writeModelsJson({ ...json, providers: { ...json.providers, [providerId]: newProv } });
|
|
554
|
+
ctx.ui.notify(`✓ Model "${modelId}" updated.`, "success");
|
|
555
|
+
} catch (err) {
|
|
556
|
+
ctx.ui.notify(`Write failed: ${err instanceof Error ? err.message : err}`, "error");
|
|
557
|
+
}
|
|
558
|
+
onDone?.();
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
export async function deleteModelFlow(
|
|
562
|
+
ctx: ExtensionCommandContext,
|
|
563
|
+
providerId: string,
|
|
564
|
+
modelId: string,
|
|
565
|
+
onDone?: () => void,
|
|
566
|
+
): Promise<void> {
|
|
567
|
+
const json = await readModelsJson();
|
|
568
|
+
const prov = json.providers[providerId];
|
|
569
|
+
if (!prov) { ctx.ui.notify(`Provider "${providerId}" does not exist.`, "error"); onDone?.(); return; }
|
|
570
|
+
if (!(prov.models ?? []).some((m) => m.id === modelId)) { ctx.ui.notify(`Model "${modelId}" not in "${providerId}".`, "error"); onDone?.(); return; }
|
|
571
|
+
const ok = await askConfirm(ctx, `Delete model "${modelId}"?`, "This removes it from models.json. Can be restored from .bak via /providers reset.");
|
|
572
|
+
if (ok === undefined || !ok) { onDone?.(); return; }
|
|
573
|
+
const newModels = (prov.models ?? []).filter((m) => m.id !== modelId);
|
|
574
|
+
const newProv: ProviderConfig = { ...prov, models: newModels };
|
|
575
|
+
try {
|
|
576
|
+
await writeModelsJson({ ...json, providers: { ...json.providers, [providerId]: newProv } });
|
|
577
|
+
ctx.ui.notify(`✓ Model "${modelId}" deleted (restore with /providers reset).`, "success");
|
|
578
|
+
} catch (err) {
|
|
579
|
+
ctx.ui.notify(`Write failed: ${err instanceof Error ? err.message : err}`, "error");
|
|
580
|
+
}
|
|
581
|
+
onDone?.();
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
// ============================================================================
|
|
585
|
+
// Backup restore / sync
|
|
586
|
+
// ============================================================================
|
|
587
|
+
|
|
588
|
+
export async function restoreFromBackupFlow(ctx: ExtensionCommandContext, onDone: () => void): Promise<void> {
|
|
589
|
+
if (!backupExists()) { ctx.ui.notify("No backup found. Nothing to restore.", "warning"); onDone?.(); return; }
|
|
590
|
+
const ok = await askConfirm(ctx, "Restore from .bak?", "Current models.json will be overwritten with .bak content. .bak itself is preserved.");
|
|
591
|
+
if (ok === undefined || !ok) { onDone?.(); return; }
|
|
592
|
+
const restored = await restoreBackup();
|
|
593
|
+
if (restored) ctx.ui.notify("✓ Restored from .bak.", "success");
|
|
594
|
+
else ctx.ui.notify("Restore failed.", "error");
|
|
595
|
+
onDone?.();
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
export type SyncOpts = { sourceProviderId?: string; onDone?: () => void };
|
|
599
|
+
|
|
600
|
+
export async function syncFlow(ctx: ExtensionCommandContext, opts: SyncOpts = {}): Promise<void> {
|
|
601
|
+
const json = await readModelsJson();
|
|
602
|
+
const ids = Object.keys(json.providers);
|
|
603
|
+
if (ids.length === 0) { ctx.ui.notify("models.json is empty. Add a provider first.", "warning"); opts.onDone?.(); return; }
|
|
604
|
+
let sourceId = opts.sourceProviderId;
|
|
605
|
+
// 预先检查:没有任何 provider 有 baseUrl → 直接报错
|
|
606
|
+
const allNoBaseUrl = ids.length > 0 && ids.every((id) => !json.providers[id]?.baseUrl);
|
|
607
|
+
if (allNoBaseUrl) {
|
|
608
|
+
ctx.ui.notify("没有 baseUrl。先去 /providers 改一下 baseUrl 再 sync。", "error");
|
|
609
|
+
opts.onDone?.();
|
|
610
|
+
return;
|
|
611
|
+
}
|
|
612
|
+
if (!sourceId) {
|
|
613
|
+
const picked = await askSelect(ctx, { message: "Sync from which provider?", options: ids });
|
|
614
|
+
if (picked === undefined) { opts.onDone?.(); return; }
|
|
615
|
+
sourceId = picked;
|
|
616
|
+
}
|
|
617
|
+
const prov = json.providers[sourceId];
|
|
618
|
+
if (!prov || !prov.baseUrl) { ctx.ui.notify(`Provider "${sourceId}" 没有 baseUrl,先去 /providers 改一下 baseUrl 再 sync。`, "error"); opts.onDone?.(); return; }
|
|
619
|
+
const apiKey = prov.apiKey ?? "";
|
|
620
|
+
const apiKind: "openai-compat" | "google" = prov.api === "google-generative-ai" ? "google" : "openai-compat";
|
|
621
|
+
ctx.ui.notify(`Fetching models from ${prov.baseUrl}...`, "info");
|
|
622
|
+
let result;
|
|
623
|
+
try {
|
|
624
|
+
result = await fetchListing({ baseUrl: prov.baseUrl, apiKey, apiKind, proxy: prov.proxy, signal: ctx.signal, timeoutMs: 10000 });
|
|
625
|
+
} catch (err) {
|
|
626
|
+
ctx.ui.notify(`Fetch failed: ${err instanceof Error ? err.message : err}`, "error");
|
|
627
|
+
opts.onDone?.();
|
|
628
|
+
return;
|
|
629
|
+
}
|
|
630
|
+
if (result.warnings.length) ctx.ui.notify(result.warnings.join("; "), "warning");
|
|
631
|
+
if (result.models.length === 0 && (prov.models ?? []).length === 0) { ctx.ui.notify("No models found. Check baseUrl / api key.", "warning"); opts.onDone?.(); return; }
|
|
632
|
+
const existing = (prov.models ?? []).map((m) => ({ id: m.id }));
|
|
633
|
+
// 关键修复:传 loadDefaultModelConfig() 作 defaults,使 toAdd 使用用户级 default(不是代码内置默认)
|
|
634
|
+
const userDefaults = loadDefaultModelConfig();
|
|
635
|
+
const { toAdd } = diffModels(result.models, existing, { defaults: userDefaults });
|
|
636
|
+
// wire pi done directly to checklist onConfirm/onCancel (otherwise dialog never closes)
|
|
637
|
+
// checklist shows ALL models in this provider:
|
|
638
|
+
// - existing: label " (existing)", default checked (uncheck = remove)
|
|
639
|
+
// - toAdd (remote new): default unchecked (check = add)
|
|
640
|
+
const items = [
|
|
641
|
+
...(prov.models ?? []).map((m) => ({ id: m.id, label: `${m.id} (existing)`, hint: "uncheck to remove" })),
|
|
642
|
+
...toAdd.map((m) => ({ id: m.id, label: m.id, hint: `reasoning=${m.reasoning} input=${m.input.join(",")} ctx=${m.contextWindow}` })),
|
|
643
|
+
];
|
|
644
|
+
const selectedIds = await ctx.ui.custom<Set<string> | string[]>((_t, theme, _kb, done) => {
|
|
645
|
+
// 视口高度:用户配置 > 默认 8。越界 / 非法 → getSyncViewportSize 返 null,走默认。
|
|
646
|
+
const configured = getSyncViewportSize();
|
|
647
|
+
const maxRows = configured ?? 8;
|
|
648
|
+
const checklist = new ModelChecklist({
|
|
649
|
+
title: `Sync "${sourceId}": ${toAdd.length} new, ${(prov.models ?? []).length} existing`,
|
|
650
|
+
items,
|
|
651
|
+
preSelect: (it) => it.id ? (prov.models ?? []).some((m) => m.id === it.id) : true,
|
|
652
|
+
theme, // 构造时传 theme,pi 框架会注入
|
|
653
|
+
maxRows,
|
|
654
|
+
onConfirm: (sel) => done(new Set(sel)),
|
|
655
|
+
onCancel: () => done(undefined),
|
|
656
|
+
});
|
|
657
|
+
return checklist;
|
|
658
|
+
}).catch((err) => {
|
|
659
|
+
// 框架抛错(不是用户取消)要让用户知道
|
|
660
|
+
console.error(`[provider-manager] sync checklist error:`, err);
|
|
661
|
+
ctx.ui.notify(`Sync checklist error: ${err instanceof Error ? err.message : err}`, "error");
|
|
662
|
+
return undefined;
|
|
663
|
+
});
|
|
664
|
+
if (selectedIds === undefined || selectedIds === null) { ctx.ui.notify("Sync cancelled.", "info"); opts.onDone?.(); return; }
|
|
665
|
+
const pickedIds = selectedIds instanceof Set ? selectedIds : new Set(selectedIds as string[]);
|
|
666
|
+
// merge: checked = keep/add; unchecked = remove
|
|
667
|
+
const existingIds = (prov.models ?? []).map((m) => m.id);
|
|
668
|
+
const allIds = new Set([...existingIds, ...toAdd.map((m) => m.id)]);
|
|
669
|
+
const finalModels: ModelConfig[] = [];
|
|
670
|
+
for (const id of allIds) {
|
|
671
|
+
if (!pickedIds.has(id)) continue;
|
|
672
|
+
const fromRemote = toAdd.find((m) => m.id === id);
|
|
673
|
+
if (fromRemote) finalModels.push(fromRemote);
|
|
674
|
+
else {
|
|
675
|
+
const fromLocal = (prov.models ?? []).find((m) => m.id === id);
|
|
676
|
+
if (fromLocal) finalModels.push(fromLocal);
|
|
677
|
+
}
|
|
678
|
+
}
|
|
679
|
+
if (finalModels.length === 0) {
|
|
680
|
+
// Enter = 提交选择。不 short-circuit:uncheck 全部 + Enter 也要写空列表。
|
|
681
|
+
// 只有 Esc / checklist onCancel 会走 onDone 不写盘。
|
|
682
|
+
const newProv: ProviderConfig = { ...prov, models: [] };
|
|
683
|
+
try {
|
|
684
|
+
await writeModelsJson({ ...json, providers: { ...json.providers, [sourceId!]: newProv } });
|
|
685
|
+
ctx.ui.notify(`Cleared all models from "${sourceId}". Press Ctrl+L to pick a model.`, "info");
|
|
686
|
+
} catch (err) {
|
|
687
|
+
ctx.ui.notify(`Write failed: ${err instanceof Error ? err.message : err}`, "error");
|
|
688
|
+
}
|
|
689
|
+
opts.onDone?.();
|
|
690
|
+
return;
|
|
691
|
+
}
|
|
692
|
+
const newProv: ProviderConfig = { ...prov, models: finalModels };
|
|
693
|
+
try {
|
|
694
|
+
await writeModelsJson({ ...json, providers: { ...json.providers, [sourceId!]: newProv } });
|
|
695
|
+
ctx.ui.notify(`Synced "${sourceId}": ${finalModels.length} model(s) kept. Press Ctrl+L to pick model.`, "success");
|
|
696
|
+
} catch (err) {
|
|
697
|
+
ctx.ui.notify(`Write failed: ${err instanceof Error ? err.message : err}`, "error");
|
|
698
|
+
}
|
|
699
|
+
opts.onDone?.();
|
|
700
|
+
}
|