pi-model-manager 0.1.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/LICENSE +661 -0
- package/NOTICE +8 -0
- package/README.en.md +206 -0
- package/README.md +210 -0
- package/atomic-write.ts +100 -0
- package/builtin-model-catalog.ts +92 -0
- package/claude-code-compat.ts +56 -0
- package/common.ts +66 -0
- package/compat-settings.ts +25 -0
- package/config-value-reference.ts +51 -0
- package/configuration-persistence.ts +72 -0
- package/header-profile-mutations.ts +46 -0
- package/index.ts +82 -0
- package/local-proxy-service.ts +313 -0
- package/model-mutations.ts +210 -0
- package/models-json-manager.ts +238 -0
- package/models-json-mutations.ts +90 -0
- package/models-json-sync.ts +386 -0
- package/openai-responses-payload.ts +80 -0
- package/openai-service-tier.ts +36 -0
- package/package.json +74 -0
- package/presets/builtin-client-headers.ts +23 -0
- package/presets/client-headers.ts +126 -0
- package/presets/providers.ts +80 -0
- package/presets/thinking.ts +81 -0
- package/provider-registrar.ts +281 -0
- package/request-pipeline.ts +88 -0
- package/rescue.ts +69 -0
- package/runtime-base-url.ts +54 -0
- package/state-cache.ts +55 -0
- package/state-document.ts +569 -0
- package/state-metadata-store.ts +455 -0
- package/state-store.ts +238 -0
- package/tui/dashboard.ts +376 -0
- package/tui/editor-model.ts +339 -0
- package/tui/editor-provider.ts +219 -0
- package/tui/header-profiles-panel.ts +307 -0
- package/tui/model-list-fetch.ts +259 -0
- package/tui/model-picker.ts +266 -0
- package/tui/models-json-panel.ts +269 -0
- package/tui/persistent-menu.ts +349 -0
- package/tui/ui-helpers.ts +289 -0
- package/types.ts +165 -0
|
@@ -0,0 +1,339 @@
|
|
|
1
|
+
// tui/editor-model.ts
|
|
2
|
+
//
|
|
3
|
+
// 模型编辑器:与 editor-provider 同款问答式表单。
|
|
4
|
+
// 关键差异:
|
|
5
|
+
// - 请求头已经收敛到接入级;模型编辑器只编辑模型自身能力
|
|
6
|
+
// - 提供"从上游拉取模型 ID 列表"入口(OpenAI/Anthropic/Google)
|
|
7
|
+
// - 支持视觉用开关式 select,保存时仍映射为 text / text,image
|
|
8
|
+
|
|
9
|
+
import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
|
|
10
|
+
import type { AnthropicThinkingProtocol, BuiltInClientHeaderProfileId, ModelDraft, ReasoningMode, StoredClientHeaderCapture, StoredRequestHeaderProfile } from "../types.ts";
|
|
11
|
+
import { fetchModelIds } from "./model-list-fetch.ts";
|
|
12
|
+
import { pickModelIdFromList } from "./model-picker.ts";
|
|
13
|
+
import { showPersistentFormMenu, showPersistentMenu, padLabel, type HorizontalDirection, type MenuCursor } from "./persistent-menu.ts";
|
|
14
|
+
import {
|
|
15
|
+
describeVisionInput,
|
|
16
|
+
formatApiShort,
|
|
17
|
+
supportsVisionInput,
|
|
18
|
+
VISION_INPUT_CHOICES,
|
|
19
|
+
} from "./ui-helpers.ts";
|
|
20
|
+
|
|
21
|
+
interface FieldRow {
|
|
22
|
+
id: string;
|
|
23
|
+
label: string;
|
|
24
|
+
value: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
function shouldShowOpenAIServiceTier(draft: ModelDraft): boolean {
|
|
29
|
+
return draft.api === "openai-responses";
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function shouldShowAnthropicThinkingProtocol(draft: ModelDraft): boolean {
|
|
33
|
+
return draft.reasoningMode === "enabled" && draft.anthropicThinkingProtocol !== undefined;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
function describeReasoningMode(mode: ReasoningMode): string {
|
|
38
|
+
return mode === "enabled" ? "开启" : "关闭";
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function describeAnthropicThinkingProtocol(protocol: AnthropicThinkingProtocol): string {
|
|
42
|
+
return protocol === "adaptive" ? "开启 · 新版协议" : "关闭 · Legacy";
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function describeOpenAIServiceTier(draft: ModelDraft): string {
|
|
46
|
+
return draft.openAIServiceTier === "priority" ? "开启 · priority" : "关闭";
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function getIntegerFieldLabel(field: "contextWindow" | "maxTokens"): string {
|
|
50
|
+
return field === "contextWindow" ? "上下文窗口" : "最大输出";
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function cycleSwitchState(current: boolean, direction: HorizontalDirection): boolean {
|
|
54
|
+
const states = [false, true];
|
|
55
|
+
const currentIndex = current ? 1 : 0;
|
|
56
|
+
const delta = direction === "right" ? 1 : -1;
|
|
57
|
+
return states[(currentIndex + delta + states.length) % states.length]!;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function applyHorizontalToggle(draft: ModelDraft, fieldId: string, direction: HorizontalDirection): boolean {
|
|
61
|
+
if (fieldId === "visionInput") {
|
|
62
|
+
const enabled = cycleSwitchState(supportsVisionInput(draft.inputKinds), direction);
|
|
63
|
+
draft.inputKinds = enabled ? ["text", "image"] : ["text"];
|
|
64
|
+
return true;
|
|
65
|
+
}
|
|
66
|
+
if (fieldId === "reasoning") {
|
|
67
|
+
const enabled = cycleSwitchState(draft.reasoningMode === "enabled", direction);
|
|
68
|
+
draft.reasoningMode = enabled ? "enabled" : "disabled";
|
|
69
|
+
return true;
|
|
70
|
+
}
|
|
71
|
+
if (fieldId === "anthropicThinkingProtocol") {
|
|
72
|
+
const enabled = cycleSwitchState(draft.anthropicThinkingProtocol === "adaptive", direction);
|
|
73
|
+
draft.anthropicThinkingProtocol = enabled ? "adaptive" : "legacy";
|
|
74
|
+
return true;
|
|
75
|
+
}
|
|
76
|
+
if (fieldId === "openAIServiceTier") {
|
|
77
|
+
const enabled = cycleSwitchState(draft.openAIServiceTier === "priority", direction);
|
|
78
|
+
draft.openAIServiceTier = enabled ? "priority" : undefined;
|
|
79
|
+
return true;
|
|
80
|
+
}
|
|
81
|
+
return false;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function buildRows(draft: ModelDraft): FieldRow[] {
|
|
85
|
+
const rows: FieldRow[] = [
|
|
86
|
+
{ id: "modelId", label: "模型 ID", value: draft.modelId || "<未填写>" },
|
|
87
|
+
{ id: "fetch", label: "重新拉取", value: "上游模型列表" },
|
|
88
|
+
{ id: "modelName", label: "显示名称", value: draft.modelName || "默认 = 模型 ID" },
|
|
89
|
+
{ id: "visionInput", label: "支持视觉", value: describeVisionInput(draft.inputKinds) },
|
|
90
|
+
{ id: "reasoning", label: "Thinking", value: describeReasoningMode(draft.reasoningMode) },
|
|
91
|
+
];
|
|
92
|
+
if (shouldShowAnthropicThinkingProtocol(draft)) {
|
|
93
|
+
rows.push({
|
|
94
|
+
id: "anthropicThinkingProtocol",
|
|
95
|
+
label: "Adaptive",
|
|
96
|
+
value: describeAnthropicThinkingProtocol(draft.anthropicThinkingProtocol!),
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
if (shouldShowOpenAIServiceTier(draft)) {
|
|
100
|
+
rows.push({ id: "openAIServiceTier", label: "Fast mode", value: describeOpenAIServiceTier(draft) });
|
|
101
|
+
}
|
|
102
|
+
rows.push(
|
|
103
|
+
{ id: "contextWindow", label: "上下文窗口", value: String(draft.contextWindow) },
|
|
104
|
+
{ id: "maxTokens", label: "最大输出", value: String(draft.maxTokens) },
|
|
105
|
+
);
|
|
106
|
+
return rows;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function getAdjustableRowIds(draft: ModelDraft): string[] {
|
|
110
|
+
const ids = ["visionInput", "reasoning"];
|
|
111
|
+
if (shouldShowAnthropicThinkingProtocol(draft)) ids.push("anthropicThinkingProtocol");
|
|
112
|
+
if (shouldShowOpenAIServiceTier(draft)) ids.push("openAIServiceTier");
|
|
113
|
+
return ids;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function getSelectedCustomHeaders(
|
|
117
|
+
draft: ModelDraft,
|
|
118
|
+
requestHeaderProfiles: Record<string, StoredRequestHeaderProfile>,
|
|
119
|
+
): Record<string, string> {
|
|
120
|
+
if (draft.clientHeaderProfile !== "custom" || !draft.requestHeaderProfileId) return {};
|
|
121
|
+
return requestHeaderProfiles[draft.requestHeaderProfileId]?.headers ?? {};
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
async function pickModelFromUpstream(
|
|
125
|
+
ctx: ExtensionCommandContext,
|
|
126
|
+
draft: ModelDraft,
|
|
127
|
+
requestHeaderProfiles: Record<string, StoredRequestHeaderProfile>,
|
|
128
|
+
clientHeaderCaptures: Partial<Record<BuiltInClientHeaderProfileId, StoredClientHeaderCapture>>,
|
|
129
|
+
): Promise<void> {
|
|
130
|
+
ctx.ui.notify("正在拉取模型列表(10 秒超时)…", "info");
|
|
131
|
+
const outcome = await fetchModelIds({
|
|
132
|
+
providerId: draft.providerId,
|
|
133
|
+
api: draft.api,
|
|
134
|
+
baseUrl: draft.baseUrl,
|
|
135
|
+
apiKey: draft.apiKey,
|
|
136
|
+
authHeader: draft.authHeader,
|
|
137
|
+
clientHeaderProfile: draft.clientHeaderProfile,
|
|
138
|
+
customClientHeaders: getSelectedCustomHeaders(draft, requestHeaderProfiles),
|
|
139
|
+
httpProxyEnabled: draft.httpProxyEnabled,
|
|
140
|
+
httpProxyUrl: draft.httpProxyUrl,
|
|
141
|
+
clientHeaderCaptures,
|
|
142
|
+
});
|
|
143
|
+
if (outcome.status === "failed") {
|
|
144
|
+
ctx.ui.notify(`拉取失败:${outcome.message}`, "warning");
|
|
145
|
+
const fallback = await ctx.ui.input(`手动输入模型 ID(当前:${draft.modelId || "<空>"})`, draft.modelId);
|
|
146
|
+
if (fallback !== undefined) {
|
|
147
|
+
draft.modelId = fallback.trim() || draft.modelId;
|
|
148
|
+
}
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
if (outcome.modelIds.length === 0) {
|
|
152
|
+
ctx.ui.notify("上游返回空列表", "warning");
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
const picked = await pickModelIdFromList(
|
|
156
|
+
ctx,
|
|
157
|
+
`选择模型 ID(共 ${outcome.modelIds.length} 个)`,
|
|
158
|
+
outcome.modelIds,
|
|
159
|
+
draft.modelId,
|
|
160
|
+
);
|
|
161
|
+
if (picked) {
|
|
162
|
+
draft.modelId = picked;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
async function editIntField(ctx: ExtensionCommandContext, draft: ModelDraft, field: "contextWindow" | "maxTokens"): Promise<void> {
|
|
167
|
+
const label = getIntegerFieldLabel(field);
|
|
168
|
+
const current = String(draft[field]);
|
|
169
|
+
const value = await ctx.ui.input(`${label}(当前:${current},留空保持原值)`, current);
|
|
170
|
+
if (value === undefined) return;
|
|
171
|
+
const trimmed = value.trim();
|
|
172
|
+
if (!trimmed) return;
|
|
173
|
+
const parsed = Number.parseInt(trimmed, 10);
|
|
174
|
+
if (!Number.isFinite(parsed) || parsed <= 0) {
|
|
175
|
+
ctx.ui.notify(`${label} 必须是正整数`, "warning");
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
draft[field] = parsed;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
async function editField(
|
|
182
|
+
ctx: ExtensionCommandContext,
|
|
183
|
+
draft: ModelDraft,
|
|
184
|
+
fieldId: string,
|
|
185
|
+
requestHeaderProfiles: Record<string, StoredRequestHeaderProfile>,
|
|
186
|
+
clientHeaderCaptures: Partial<Record<BuiltInClientHeaderProfileId, StoredClientHeaderCapture>>,
|
|
187
|
+
): Promise<void> {
|
|
188
|
+
if (fieldId === "modelId") {
|
|
189
|
+
const value = await ctx.ui.input(`模型 ID(当前:${draft.modelId || "<空>"},传给上游 API 的模型字段)`, draft.modelId);
|
|
190
|
+
if (value !== undefined) {
|
|
191
|
+
const trimmed = value.trim();
|
|
192
|
+
if (trimmed) {
|
|
193
|
+
draft.modelId = trimmed;
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
198
|
+
if (fieldId === "fetch") {
|
|
199
|
+
await pickModelFromUpstream(ctx, draft, requestHeaderProfiles, clientHeaderCaptures);
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
if (fieldId === "modelName") {
|
|
203
|
+
const value = await ctx.ui.input(`显示名称(当前:${draft.modelName || "<空>"},可空默认用模型 ID;输入空格清空)`, draft.modelName);
|
|
204
|
+
if (value !== undefined) draft.modelName = value.trim();
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
if (fieldId === "visionInput") {
|
|
208
|
+
const current = supportsVisionInput(draft.inputKinds);
|
|
209
|
+
const cursor: MenuCursor = { index: VISION_INPUT_CHOICES.findIndex((choice) => choice.enabled === current) };
|
|
210
|
+
const action = await showPersistentMenu(
|
|
211
|
+
ctx,
|
|
212
|
+
"支持视觉输入",
|
|
213
|
+
"↑↓ 移动 · Enter 选择 · Esc 返回",
|
|
214
|
+
VISION_INPUT_CHOICES.map((choice) => ({
|
|
215
|
+
id: choice.enabled ? "enabled" : "disabled",
|
|
216
|
+
label: choice.enabled === current ? `${choice.label} ← 当前` : choice.label,
|
|
217
|
+
})),
|
|
218
|
+
cursor,
|
|
219
|
+
);
|
|
220
|
+
if (action.type === "cancel") return;
|
|
221
|
+
const choice = VISION_INPUT_CHOICES.find((candidate) => (candidate.enabled ? "enabled" : "disabled") === action.id);
|
|
222
|
+
if (choice) draft.inputKinds = [...choice.kinds];
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
225
|
+
if (fieldId === "reasoning") {
|
|
226
|
+
const choices: Array<{ mode: ReasoningMode; label: string }> = [
|
|
227
|
+
{ mode: "disabled", label: "关闭 — 不发送模型推理参数" },
|
|
228
|
+
{ mode: "enabled", label: "开启 — 启用模型推理参数" },
|
|
229
|
+
];
|
|
230
|
+
const cursor: MenuCursor = { index: choices.findIndex((choice) => choice.mode === draft.reasoningMode) };
|
|
231
|
+
const action = await showPersistentMenu(
|
|
232
|
+
ctx,
|
|
233
|
+
"Thinking",
|
|
234
|
+
"↑↓ 移动 · Enter 选择 · Esc 返回",
|
|
235
|
+
choices.map((choice) => ({
|
|
236
|
+
id: choice.mode,
|
|
237
|
+
label: choice.mode === draft.reasoningMode ? `${choice.label} ← 当前` : choice.label,
|
|
238
|
+
})),
|
|
239
|
+
cursor,
|
|
240
|
+
);
|
|
241
|
+
if (action.type === "cancel") return;
|
|
242
|
+
const choice = choices.find((candidate) => candidate.mode === action.id);
|
|
243
|
+
if (choice) draft.reasoningMode = choice.mode;
|
|
244
|
+
return;
|
|
245
|
+
}
|
|
246
|
+
if (fieldId === "anthropicThinkingProtocol") {
|
|
247
|
+
const choices: Array<{ protocol: AnthropicThinkingProtocol; label: string }> = [
|
|
248
|
+
{ protocol: "adaptive", label: "开启:新版模型,发送 thinking.type=adaptive 和 output_config.effort" },
|
|
249
|
+
{ protocol: "legacy", label: "关闭:旧版接口,发送 thinking.type=enabled 和 budget_tokens" },
|
|
250
|
+
];
|
|
251
|
+
const current = draft.anthropicThinkingProtocol ?? "legacy";
|
|
252
|
+
const cursor: MenuCursor = { index: choices.findIndex((choice) => choice.protocol === current) };
|
|
253
|
+
const action = await showPersistentMenu(
|
|
254
|
+
ctx,
|
|
255
|
+
"Adaptive",
|
|
256
|
+
"↑↓ 移动 · Enter 选择 · Esc 返回",
|
|
257
|
+
choices.map((choice) => ({
|
|
258
|
+
id: choice.protocol,
|
|
259
|
+
label: choice.protocol === current ? `${choice.label} ← 当前` : choice.label,
|
|
260
|
+
})),
|
|
261
|
+
cursor,
|
|
262
|
+
);
|
|
263
|
+
if (action.type === "cancel") return;
|
|
264
|
+
const choice = choices.find((candidate) => candidate.protocol === action.id);
|
|
265
|
+
if (choice) draft.anthropicThinkingProtocol = choice.protocol;
|
|
266
|
+
return;
|
|
267
|
+
}
|
|
268
|
+
if (fieldId === "openAIServiceTier") {
|
|
269
|
+
const choices: Array<{ id: "disabled" | "priority"; tier: ModelDraft["openAIServiceTier"]; label: string }> = [
|
|
270
|
+
{ id: "disabled", tier: undefined, label: "关闭 — 不发送 service_tier(默认)" },
|
|
271
|
+
{ id: "priority", tier: "priority", label: "开启 — service_tier=priority,可能消耗 Fast / priority 额度" },
|
|
272
|
+
];
|
|
273
|
+
const current = draft.openAIServiceTier === "priority" ? "priority" : "disabled";
|
|
274
|
+
const cursor: MenuCursor = { index: choices.findIndex((choice) => choice.id === current) };
|
|
275
|
+
const action = await showPersistentMenu(
|
|
276
|
+
ctx,
|
|
277
|
+
"Fast mode",
|
|
278
|
+
"↑↓ 移动 · Enter 选择 · Esc 返回",
|
|
279
|
+
choices.map((choice) => ({
|
|
280
|
+
id: choice.id,
|
|
281
|
+
label: choice.id === current ? `${choice.label} ← 当前` : choice.label,
|
|
282
|
+
})),
|
|
283
|
+
cursor,
|
|
284
|
+
);
|
|
285
|
+
if (action.type === "cancel") return;
|
|
286
|
+
const choice = choices.find((candidate) => candidate.id === action.id);
|
|
287
|
+
if (choice) draft.openAIServiceTier = choice.tier;
|
|
288
|
+
return;
|
|
289
|
+
}
|
|
290
|
+
if (fieldId === "contextWindow" || fieldId === "maxTokens") {
|
|
291
|
+
await editIntField(ctx, draft, fieldId);
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
export type ModelEditOutcome =
|
|
296
|
+
| { action: "save"; draft: ModelDraft }
|
|
297
|
+
| { action: "cancel" };
|
|
298
|
+
|
|
299
|
+
export async function editModel(
|
|
300
|
+
ctx: ExtensionCommandContext,
|
|
301
|
+
draft: ModelDraft,
|
|
302
|
+
titlePrefix: string,
|
|
303
|
+
requestHeaderProfiles: Record<string, StoredRequestHeaderProfile> = {},
|
|
304
|
+
clientHeaderCaptures: Partial<Record<BuiltInClientHeaderProfileId, StoredClientHeaderCapture>> = {},
|
|
305
|
+
): Promise<ModelEditOutcome> {
|
|
306
|
+
if (!draft.modelId.trim()) {
|
|
307
|
+
await pickModelFromUpstream(ctx, draft, requestHeaderProfiles, clientHeaderCaptures);
|
|
308
|
+
}
|
|
309
|
+
const cursor: MenuCursor = { index: 0 };
|
|
310
|
+
while (true) {
|
|
311
|
+
const rows = buildRows(draft);
|
|
312
|
+
const menuRows = rows.map((row) => ({
|
|
313
|
+
id: row.id,
|
|
314
|
+
label: `${padLabel(row.label, 16)}${row.value}`,
|
|
315
|
+
}));
|
|
316
|
+
const action = await showPersistentFormMenu(
|
|
317
|
+
ctx,
|
|
318
|
+
titlePrefix,
|
|
319
|
+
"",
|
|
320
|
+
menuRows,
|
|
321
|
+
cursor,
|
|
322
|
+
{
|
|
323
|
+
adjustableRowIds: getAdjustableRowIds(draft),
|
|
324
|
+
summaryLines: [
|
|
325
|
+
`接入 ${draft.providerId} · API ${formatApiShort(draft.api)}`,
|
|
326
|
+
"Ctrl+S 保存并启用模型;不切换当前会话模型",
|
|
327
|
+
],
|
|
328
|
+
footer: "↑↓ 选择 ←→ 切换选项 Enter 编辑 Ctrl+S 保存并启用 Esc 返回",
|
|
329
|
+
},
|
|
330
|
+
);
|
|
331
|
+
if (action.type === "cancel") return { action: "cancel" };
|
|
332
|
+
if (action.type === "save") return { action: "save", draft };
|
|
333
|
+
if (action.type === "adjust") {
|
|
334
|
+
applyHorizontalToggle(draft, action.id, action.direction);
|
|
335
|
+
continue;
|
|
336
|
+
}
|
|
337
|
+
await editField(ctx, draft, action.id, requestHeaderProfiles, clientHeaderCaptures);
|
|
338
|
+
}
|
|
339
|
+
}
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
// tui/editor-provider.ts
|
|
2
|
+
//
|
|
3
|
+
// 接入编辑器:问答式表单(custom menu + input/select 拼装)。
|
|
4
|
+
// 用户可以反复编辑字段;Ctrl+S 保存,Esc 返回。
|
|
5
|
+
|
|
6
|
+
import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
|
|
7
|
+
import { hasStringRecordEntries } from "../common.ts";
|
|
8
|
+
import { findPresetForApi } from "../presets/providers.ts";
|
|
9
|
+
import { DEFAULT_PROVIDER_HTTP_PROXY_URL } from "../types.ts";
|
|
10
|
+
import { showPersistentFormMenu, padLabel, type HorizontalDirection, type MenuCursor } from "./persistent-menu.ts";
|
|
11
|
+
import {
|
|
12
|
+
API_CHOICES,
|
|
13
|
+
BUILT_IN_PROFILE_CHOICES,
|
|
14
|
+
describeProfile,
|
|
15
|
+
formatApiShort,
|
|
16
|
+
maskSecret,
|
|
17
|
+
} from "./ui-helpers.ts";
|
|
18
|
+
import type { ClientHeaderProfileId, ProviderDraft, StoredRequestHeaderProfile } from "../types.ts";
|
|
19
|
+
|
|
20
|
+
interface FieldRow {
|
|
21
|
+
id: string;
|
|
22
|
+
label: string;
|
|
23
|
+
value: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function buildRows(
|
|
27
|
+
draft: ProviderDraft,
|
|
28
|
+
requestHeaderProfiles: Record<string, StoredRequestHeaderProfile>,
|
|
29
|
+
): FieldRow[] {
|
|
30
|
+
const inlineCustomProfile = draft.clientHeaderProfile === "custom"
|
|
31
|
+
&& !draft.requestHeaderProfileId
|
|
32
|
+
&& hasStringRecordEntries(draft.customClientHeaders);
|
|
33
|
+
const profileDisplay = inlineCustomProfile
|
|
34
|
+
? `内联自定义请求头(${Object.keys(draft.customClientHeaders).length}项)`
|
|
35
|
+
: describeProfile(draft.clientHeaderProfile, draft.api, draft.requestHeaderProfileId, requestHeaderProfiles);
|
|
36
|
+
return [
|
|
37
|
+
{ id: "api", label: "API 协议", value: draft.api },
|
|
38
|
+
{ id: "providerId", label: "接入 ID(必填)", value: draft.providerId || "<必填>" },
|
|
39
|
+
{ id: "providerName", label: "名称", value: draft.providerName || "<空>" },
|
|
40
|
+
{ id: "baseUrl", label: "Base URL", value: draft.baseUrl },
|
|
41
|
+
{ id: "httpProxyEnabled", label: "本机代理", value: draft.httpProxyEnabled ? "开启" : "关闭" },
|
|
42
|
+
{ id: "httpProxyUrl", label: "代理地址", value: draft.httpProxyEnabled ? (draft.httpProxyUrl || DEFAULT_PROVIDER_HTTP_PROXY_URL) : "关闭时不使用" },
|
|
43
|
+
{ id: "apiKey", label: "API key", value: maskSecret(draft.apiKey) },
|
|
44
|
+
{ id: "authHeader", label: "认证头", value: draft.authHeader ? "Bearer" : "默认" },
|
|
45
|
+
{ id: "clientHeaderProfile", label: "请求头", value: profileDisplay },
|
|
46
|
+
];
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async function selectChoice<T extends { id: string; label: string }>(
|
|
50
|
+
ctx: ExtensionCommandContext,
|
|
51
|
+
title: string,
|
|
52
|
+
choices: T[],
|
|
53
|
+
currentId: string,
|
|
54
|
+
): Promise<T | undefined> {
|
|
55
|
+
const labels = choices.map((c) => c.id === currentId ? `${c.label} ← 当前` : c.label);
|
|
56
|
+
const picked = await ctx.ui.select(title, labels);
|
|
57
|
+
if (!picked) return undefined;
|
|
58
|
+
const index = labels.indexOf(picked);
|
|
59
|
+
return index >= 0 ? choices[index] : undefined;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function cycleSwitchState(current: boolean, direction: HorizontalDirection): boolean {
|
|
63
|
+
const states = [false, true];
|
|
64
|
+
const currentIndex = current ? 1 : 0;
|
|
65
|
+
const delta = direction === "right" ? 1 : -1;
|
|
66
|
+
return states[(currentIndex + delta + states.length) % states.length]!;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function applyHorizontalToggle(draft: ProviderDraft, fieldId: string, direction: HorizontalDirection): boolean {
|
|
70
|
+
if (fieldId !== "httpProxyEnabled") return false;
|
|
71
|
+
draft.httpProxyEnabled = cycleSwitchState(draft.httpProxyEnabled, direction);
|
|
72
|
+
if (draft.httpProxyEnabled && !draft.httpProxyUrl.trim()) draft.httpProxyUrl = DEFAULT_PROVIDER_HTTP_PROXY_URL;
|
|
73
|
+
return true;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
async function editClientHeaderProfile(
|
|
77
|
+
ctx: ExtensionCommandContext,
|
|
78
|
+
draft: ProviderDraft,
|
|
79
|
+
requestHeaderProfiles: Record<string, StoredRequestHeaderProfile>,
|
|
80
|
+
): Promise<void> {
|
|
81
|
+
const choices = [
|
|
82
|
+
...BUILT_IN_PROFILE_CHOICES.map((choice) => ({
|
|
83
|
+
id: choice.id,
|
|
84
|
+
label: draft.clientHeaderProfile === choice.id ? `${choice.label} ← 当前` : choice.label,
|
|
85
|
+
})),
|
|
86
|
+
...Object.entries(requestHeaderProfiles)
|
|
87
|
+
.sort(([a], [b]) => a.localeCompare(b))
|
|
88
|
+
.map(([profileId, profile]) => ({
|
|
89
|
+
id: `custom:${profileId}`,
|
|
90
|
+
label: draft.clientHeaderProfile === "custom" && draft.requestHeaderProfileId === profileId
|
|
91
|
+
? `自定义:${profileId} — ${profile.name} ← 当前`
|
|
92
|
+
: `自定义:${profileId} — ${profile.name}`,
|
|
93
|
+
})),
|
|
94
|
+
];
|
|
95
|
+
const picked = await ctx.ui.select("请求头", choices.map((choice) => choice.label));
|
|
96
|
+
if (!picked) return;
|
|
97
|
+
const choice = choices.find((candidate) => candidate.label === picked);
|
|
98
|
+
if (!choice) return;
|
|
99
|
+
if (choice.id.startsWith("custom:")) {
|
|
100
|
+
draft.clientHeaderProfile = "custom";
|
|
101
|
+
draft.requestHeaderProfileId = choice.id.slice("custom:".length);
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
draft.clientHeaderProfile = choice.id as ClientHeaderProfileId;
|
|
105
|
+
delete draft.requestHeaderProfileId;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
async function editField(
|
|
109
|
+
ctx: ExtensionCommandContext,
|
|
110
|
+
draft: ProviderDraft,
|
|
111
|
+
fieldId: string,
|
|
112
|
+
requestHeaderProfiles: Record<string, StoredRequestHeaderProfile>,
|
|
113
|
+
): Promise<void> {
|
|
114
|
+
if (fieldId === "api") {
|
|
115
|
+
const choice = await selectChoice(ctx, "选择 API 协议", API_CHOICES, draft.api);
|
|
116
|
+
if (choice) {
|
|
117
|
+
draft.api = choice.id;
|
|
118
|
+
const preset = findPresetForApi(draft.api);
|
|
119
|
+
if (!draft.baseUrl.trim()) draft.baseUrl = preset.baseUrl;
|
|
120
|
+
}
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
if (fieldId === "authHeader") {
|
|
124
|
+
const choice = await ctx.ui.select("认证头(API key 放在哪里)", [
|
|
125
|
+
"默认 — 交给协议 SDK / 接入默认行为",
|
|
126
|
+
"Bearer — 强制 Authorization: Bearer <apiKey>",
|
|
127
|
+
]);
|
|
128
|
+
if (choice) draft.authHeader = choice.startsWith("Bearer");
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
if (fieldId === "clientHeaderProfile") {
|
|
132
|
+
await editClientHeaderProfile(ctx, draft, requestHeaderProfiles);
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
if (fieldId === "httpProxyEnabled") {
|
|
136
|
+
const choice = await ctx.ui.select("本机代理(仅当前接入点)", [
|
|
137
|
+
"关闭 — 请求直连上游",
|
|
138
|
+
`开启 — 通过 ${draft.httpProxyUrl || DEFAULT_PROVIDER_HTTP_PROXY_URL}`,
|
|
139
|
+
]);
|
|
140
|
+
if (!choice) return;
|
|
141
|
+
draft.httpProxyEnabled = choice.startsWith("开启");
|
|
142
|
+
if (draft.httpProxyEnabled && !draft.httpProxyUrl.trim()) draft.httpProxyUrl = DEFAULT_PROVIDER_HTTP_PROXY_URL;
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
if (fieldId === "providerId") {
|
|
146
|
+
const value = await ctx.ui.input(
|
|
147
|
+
`接入 ID(必填,最多 48 个字符;仅字母、数字、点、下划线和连字符;当前:${draft.providerId || "<空>"})`,
|
|
148
|
+
draft.providerId,
|
|
149
|
+
);
|
|
150
|
+
if (value !== undefined) draft.providerId = value.trim();
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
if (fieldId === "providerName") {
|
|
154
|
+
const value = await ctx.ui.input(`名称(显示用,可留空;当前:${draft.providerName || "<空>"})`, draft.providerName);
|
|
155
|
+
if (value !== undefined) draft.providerName = value.trim();
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
if (fieldId === "apiKey") {
|
|
159
|
+
const currentLabel = draft.apiKey ? maskSecret(draft.apiKey) : "<空>";
|
|
160
|
+
const value = await ctx.ui.input(`API key(可选;明文 / $ENV_VAR / !command;当前:${currentLabel},留空清除)`, "");
|
|
161
|
+
if (value === undefined) return;
|
|
162
|
+
draft.apiKey = value.trim();
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
const prompt = ({
|
|
166
|
+
baseUrl: "Base URL(http/https)",
|
|
167
|
+
httpProxyUrl: "代理地址(http://host:port 或 https://host:port)",
|
|
168
|
+
apiKey: "API key(可选;明文 / $ENV_VAR / !command)",
|
|
169
|
+
} as Record<string, string>)[fieldId];
|
|
170
|
+
const current = (draft as any)[fieldId] as string;
|
|
171
|
+
const value = await ctx.ui.input(`${prompt ?? fieldId}(当前:${current || "<空>"},留空保持原值)`, current);
|
|
172
|
+
if (value === undefined) return;
|
|
173
|
+
const trimmed = value.trim();
|
|
174
|
+
if (!trimmed) return;
|
|
175
|
+
(draft as any)[fieldId] = trimmed;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
export type ProviderEditOutcome =
|
|
179
|
+
| { action: "save"; draft: ProviderDraft }
|
|
180
|
+
| { action: "cancel" };
|
|
181
|
+
|
|
182
|
+
export async function editProvider(
|
|
183
|
+
ctx: ExtensionCommandContext,
|
|
184
|
+
draft: ProviderDraft,
|
|
185
|
+
titlePrefix: string,
|
|
186
|
+
requestHeaderProfiles: Record<string, StoredRequestHeaderProfile> = {},
|
|
187
|
+
): Promise<ProviderEditOutcome> {
|
|
188
|
+
const cursor: MenuCursor = { index: 0 };
|
|
189
|
+
while (true) {
|
|
190
|
+
const rows = buildRows(draft, requestHeaderProfiles);
|
|
191
|
+
const menuRows = rows.map((r) => ({
|
|
192
|
+
id: r.id,
|
|
193
|
+
label: `${padLabel(r.label, 16)}${r.value}`,
|
|
194
|
+
}));
|
|
195
|
+
const action = await showPersistentFormMenu(
|
|
196
|
+
ctx,
|
|
197
|
+
titlePrefix,
|
|
198
|
+
"",
|
|
199
|
+
menuRows,
|
|
200
|
+
cursor,
|
|
201
|
+
{
|
|
202
|
+
adjustableRowIds: ["httpProxyEnabled"],
|
|
203
|
+
summaryLines: [
|
|
204
|
+
`API ${formatApiShort(draft.api)} · 请求头 ${describeProfile(draft.clientHeaderProfile, draft.api, draft.requestHeaderProfileId, requestHeaderProfiles)}`,
|
|
205
|
+
"接入 ID 必填,且不能与已有或 pi 内置接入重复",
|
|
206
|
+
"Ctrl+S 保存并同步 models.json;不切换当前会话模型",
|
|
207
|
+
],
|
|
208
|
+
footer: "↑↓ 选择 ←→ 切换选项 Enter 编辑 Ctrl+S 保存并同步 Esc 返回",
|
|
209
|
+
},
|
|
210
|
+
);
|
|
211
|
+
if (action.type === "cancel") return { action: "cancel" };
|
|
212
|
+
if (action.type === "save") return { action: "save", draft };
|
|
213
|
+
if (action.type === "adjust") {
|
|
214
|
+
applyHorizontalToggle(draft, action.id, action.direction);
|
|
215
|
+
continue;
|
|
216
|
+
}
|
|
217
|
+
await editField(ctx, draft, action.id, requestHeaderProfiles);
|
|
218
|
+
}
|
|
219
|
+
}
|