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,349 @@
|
|
|
1
|
+
// tui/persistent-menu.ts
|
|
2
|
+
//
|
|
3
|
+
// 共享的"光标记忆"菜单组件,用 ctx.ui.custom 实现。
|
|
4
|
+
// 调用方在循环间持有 cursor: { index } 引用,菜单进出时光标位置不丢。
|
|
5
|
+
//
|
|
6
|
+
// 设计:列表页保留 KISS 的键盘模型,同时支持摘要、列头、详情区、底部快捷键。
|
|
7
|
+
// 表单页另有 Ctrl+S 保存;列表页可注册单键快捷操作,并用 / 做轻量过滤。
|
|
8
|
+
|
|
9
|
+
import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
|
|
10
|
+
import { Key, matchesKey, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
|
|
11
|
+
|
|
12
|
+
export interface MenuRow {
|
|
13
|
+
id: string;
|
|
14
|
+
label: string;
|
|
15
|
+
description?: string | readonly string[];
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface PersistentMenuOptions {
|
|
19
|
+
summaryLines?: readonly string[];
|
|
20
|
+
tableHeader?: string;
|
|
21
|
+
getDetailLines?: (selectedRow: MenuRow | undefined) => readonly string[];
|
|
22
|
+
footer?: string;
|
|
23
|
+
emptyLabel?: string;
|
|
24
|
+
visibleRows?: number;
|
|
25
|
+
searchable?: boolean;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface PersistentFormMenuOptions extends PersistentMenuOptions {
|
|
29
|
+
adjustableRowIds?: readonly string[];
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export type MenuAction =
|
|
33
|
+
| { type: "pick"; id: string }
|
|
34
|
+
| { type: "cancel" };
|
|
35
|
+
|
|
36
|
+
export type HorizontalDirection = "left" | "right";
|
|
37
|
+
export type FormMenuAction = MenuAction | { type: "save" } | { type: "adjust"; id: string; direction: HorizontalDirection };
|
|
38
|
+
|
|
39
|
+
export type ShortcutMenuAction<TShortcut extends string = string> = MenuAction | { type: "shortcut"; shortcut: TShortcut };
|
|
40
|
+
|
|
41
|
+
export interface MenuShortcut<TShortcut extends string = string> {
|
|
42
|
+
input: string;
|
|
43
|
+
shortcut: TShortcut;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface MenuCursor {
|
|
47
|
+
index: number;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function clampIndex(index: number, length: number): number {
|
|
51
|
+
if (length <= 0) return 0;
|
|
52
|
+
return Math.min(Math.max(0, index), length - 1);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function padToVisibleWidth(text: string, targetWidth: number): string {
|
|
56
|
+
const pad = Math.max(0, targetWidth - visibleWidth(text));
|
|
57
|
+
return text + " ".repeat(pad);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function padLabel(label: string, columns: number): string {
|
|
61
|
+
return padToVisibleWidth(label, columns);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function getDescriptionLines(row: MenuRow): string[] {
|
|
65
|
+
const source = row.description;
|
|
66
|
+
return typeof source === "string" ? source.split("\n") : [...(source ?? [])];
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function getSearchText(row: MenuRow): string {
|
|
70
|
+
return [row.id, row.label, ...getDescriptionLines(row)].join("\n").toLocaleLowerCase();
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function filterRows(rows: MenuRow[], query: string): MenuRow[] {
|
|
74
|
+
const needle = query.trim().toLocaleLowerCase();
|
|
75
|
+
if (!needle) return rows;
|
|
76
|
+
return rows.filter((row) => getSearchText(row).includes(needle));
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function isSearchTextInput(data: string): boolean {
|
|
80
|
+
return data.length > 0 && !data.startsWith("\x1b") && !/[\u0000-\u001f\u007f]/.test(data);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function createPersistentMenu<TAction extends MenuAction | FormMenuAction | ShortcutMenuAction>(
|
|
84
|
+
ctx: ExtensionCommandContext,
|
|
85
|
+
title: string,
|
|
86
|
+
help: string,
|
|
87
|
+
rows: MenuRow[],
|
|
88
|
+
cursor: MenuCursor,
|
|
89
|
+
createSaveAction: (() => TAction) | undefined,
|
|
90
|
+
shortcuts: MenuShortcut[],
|
|
91
|
+
createAdjustAction: ((id: string, direction: HorizontalDirection) => TAction) | undefined = undefined,
|
|
92
|
+
adjustableRowIds: ReadonlySet<string> = new Set(),
|
|
93
|
+
options: PersistentMenuOptions = {},
|
|
94
|
+
): Promise<TAction> {
|
|
95
|
+
cursor.index = clampIndex(cursor.index, rows.length);
|
|
96
|
+
return ctx.ui.custom<TAction>((tui, theme, _keybindings, done) => {
|
|
97
|
+
let selectedIndex = clampIndex(cursor.index, rows.length);
|
|
98
|
+
let searchActive = false;
|
|
99
|
+
let searchQuery = "";
|
|
100
|
+
const visibleRows = options.visibleRows ?? 18;
|
|
101
|
+
const searchable = options.searchable ?? false;
|
|
102
|
+
|
|
103
|
+
const getActiveRows = (): MenuRow[] => searchable ? filterRows(rows, searchQuery) : rows;
|
|
104
|
+
|
|
105
|
+
const syncCursor = (activeRows: MenuRow[]): void => {
|
|
106
|
+
const row = activeRows[selectedIndex];
|
|
107
|
+
cursor.index = row ? rows.indexOf(row) : rows.length;
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
const requestRender = (): void => {
|
|
111
|
+
const activeRows = getActiveRows();
|
|
112
|
+
selectedIndex = clampIndex(selectedIndex, activeRows.length);
|
|
113
|
+
syncCursor(activeRows);
|
|
114
|
+
tui.requestRender();
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
const clearSearch = (): boolean => {
|
|
118
|
+
if (!searchable || (!searchActive && !searchQuery)) return false;
|
|
119
|
+
searchActive = false;
|
|
120
|
+
searchQuery = "";
|
|
121
|
+
selectedIndex = clampIndex(cursor.index, rows.length);
|
|
122
|
+
requestRender();
|
|
123
|
+
return true;
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
const pickSelected = (): void => {
|
|
127
|
+
const activeRows = getActiveRows();
|
|
128
|
+
const row = activeRows[selectedIndex];
|
|
129
|
+
if (!row) return;
|
|
130
|
+
syncCursor(activeRows);
|
|
131
|
+
done({ type: "pick", id: row.id } as TAction);
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
const moveSelection = (nextIndex: number): void => {
|
|
135
|
+
selectedIndex = nextIndex;
|
|
136
|
+
requestRender();
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
return {
|
|
140
|
+
invalidate(): void {},
|
|
141
|
+
handleInput(data: string): void {
|
|
142
|
+
if (createSaveAction && matchesKey(data, Key.ctrl("s"))) {
|
|
143
|
+
syncCursor(getActiveRows());
|
|
144
|
+
done(createSaveAction());
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
const horizontalDirection: HorizontalDirection | undefined = matchesKey(data, Key.left)
|
|
148
|
+
? "left"
|
|
149
|
+
: matchesKey(data, Key.right)
|
|
150
|
+
? "right"
|
|
151
|
+
: undefined;
|
|
152
|
+
if (horizontalDirection && createAdjustAction) {
|
|
153
|
+
const row = getActiveRows()[selectedIndex];
|
|
154
|
+
if (row && adjustableRowIds.has(row.id)) {
|
|
155
|
+
syncCursor(getActiveRows());
|
|
156
|
+
done(createAdjustAction(row.id, horizontalDirection));
|
|
157
|
+
}
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
if (searchable && (matchesKey(data, Key.escape) || matchesKey(data, Key.ctrl("c")))) {
|
|
162
|
+
if (clearSearch()) return;
|
|
163
|
+
done({ type: "cancel" } as TAction);
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// 搜索激活后,可打印字符必须进入查询,不能触发同名的菜单快捷键。
|
|
168
|
+
if (searchable && searchActive) {
|
|
169
|
+
if (matchesKey(data, Key.backspace)) {
|
|
170
|
+
searchQuery = searchQuery.slice(0, -1);
|
|
171
|
+
selectedIndex = 0;
|
|
172
|
+
requestRender();
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
if (matchesKey(data, Key.enter)) {
|
|
176
|
+
pickSelected();
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
if (isSearchTextInput(data)) {
|
|
180
|
+
searchQuery += data;
|
|
181
|
+
selectedIndex = 0;
|
|
182
|
+
requestRender();
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const shortcut = shortcuts.find((candidate) => candidate.input === data);
|
|
188
|
+
if (shortcut) {
|
|
189
|
+
syncCursor(getActiveRows());
|
|
190
|
+
done({ type: "shortcut", shortcut: shortcut.shortcut } as TAction);
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
if (searchable && data === "/") {
|
|
194
|
+
searchActive = true;
|
|
195
|
+
requestRender();
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
if (matchesKey(data, Key.escape) || matchesKey(data, Key.ctrl("c"))) {
|
|
200
|
+
done({ type: "cancel" } as TAction);
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
if (matchesKey(data, Key.enter)) {
|
|
204
|
+
pickSelected();
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const activeRows = getActiveRows();
|
|
209
|
+
if (matchesKey(data, Key.up)) {
|
|
210
|
+
moveSelection(Math.max(0, selectedIndex - 1));
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
if (matchesKey(data, Key.down)) {
|
|
214
|
+
moveSelection(Math.min(activeRows.length - 1, selectedIndex + 1));
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
if (matchesKey(data, Key.pageUp)) {
|
|
218
|
+
moveSelection(Math.max(0, selectedIndex - visibleRows));
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
221
|
+
if (matchesKey(data, Key.pageDown)) {
|
|
222
|
+
moveSelection(Math.min(activeRows.length - 1, selectedIndex + visibleRows));
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
225
|
+
if (matchesKey(data, Key.home)) {
|
|
226
|
+
moveSelection(0);
|
|
227
|
+
return;
|
|
228
|
+
}
|
|
229
|
+
if (matchesKey(data, Key.end)) {
|
|
230
|
+
moveSelection(activeRows.length - 1);
|
|
231
|
+
return;
|
|
232
|
+
}
|
|
233
|
+
},
|
|
234
|
+
render(width: number): string[] {
|
|
235
|
+
const activeRows = getActiveRows();
|
|
236
|
+
selectedIndex = clampIndex(selectedIndex, activeRows.length);
|
|
237
|
+
syncCursor(activeRows);
|
|
238
|
+
const windowStart = Math.max(
|
|
239
|
+
0,
|
|
240
|
+
Math.min(selectedIndex - Math.floor(visibleRows / 2), Math.max(0, activeRows.length - visibleRows)),
|
|
241
|
+
);
|
|
242
|
+
const shownRows = activeRows.slice(windowStart, windowStart + visibleRows);
|
|
243
|
+
|
|
244
|
+
const border = theme.fg("borderMuted", "─".repeat(Math.max(0, Math.min(width, 100))));
|
|
245
|
+
const summaryLines = options.summaryLines ?? (help ? help.split("\n") : []);
|
|
246
|
+
const lines: string[] = [
|
|
247
|
+
border,
|
|
248
|
+
truncateToWidth(theme.fg("accent", theme.bold(title)), width),
|
|
249
|
+
];
|
|
250
|
+
|
|
251
|
+
for (const line of summaryLines) {
|
|
252
|
+
lines.push(truncateToWidth(theme.fg("dim", line), width));
|
|
253
|
+
}
|
|
254
|
+
if (searchable && searchActive) {
|
|
255
|
+
lines.push(truncateToWidth(theme.fg("accent", `搜索:${searchQuery || "<输入关键词>"}`), width));
|
|
256
|
+
}
|
|
257
|
+
lines.push("");
|
|
258
|
+
|
|
259
|
+
if (options.tableHeader) {
|
|
260
|
+
lines.push(truncateToWidth(theme.fg("dim", options.tableHeader), width));
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
if (shownRows.length === 0) {
|
|
264
|
+
const emptyLabel = searchQuery ? `无匹配项:${searchQuery}` : options.emptyLabel ?? "暂无条目";
|
|
265
|
+
lines.push(truncateToWidth(theme.fg("dim", ` ${emptyLabel}`), width));
|
|
266
|
+
} else {
|
|
267
|
+
for (let i = 0; i < shownRows.length; i += 1) {
|
|
268
|
+
const absoluteIndex = windowStart + i;
|
|
269
|
+
const row = shownRows[i]!;
|
|
270
|
+
const selected = absoluteIndex === selectedIndex;
|
|
271
|
+
const prefix = selected ? "❯ " : " ";
|
|
272
|
+
const line = `${prefix}${row.label}`;
|
|
273
|
+
lines.push(truncateToWidth(selected ? theme.fg("accent", line) : line, width));
|
|
274
|
+
for (const description of getDescriptionLines(row)) {
|
|
275
|
+
lines.push(truncateToWidth(theme.fg("dim", ` ${description}`), width));
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
const detailLines = options.getDetailLines?.(activeRows[selectedIndex]) ?? [];
|
|
281
|
+
if (detailLines.length > 0) {
|
|
282
|
+
lines.push("", border);
|
|
283
|
+
for (const line of detailLines) {
|
|
284
|
+
lines.push(truncateToWidth(theme.fg("dim", line), width));
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
const footerLines = options.footer ? options.footer.split("\n") : [];
|
|
289
|
+
if (searchable) {
|
|
290
|
+
footerLines.push(searchActive ? `/ 搜索:${searchQuery || "<输入关键词>"} Backspace 删除 Esc 清空` : "/ 搜索");
|
|
291
|
+
}
|
|
292
|
+
if (footerLines.length > 0) {
|
|
293
|
+
lines.push("");
|
|
294
|
+
for (const line of footerLines) {
|
|
295
|
+
lines.push(truncateToWidth(theme.fg("dim", line), width));
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
lines.push(border);
|
|
300
|
+
return lines.map((line) => truncateToWidth(line, width));
|
|
301
|
+
},
|
|
302
|
+
};
|
|
303
|
+
});
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
export async function showPersistentMenu(
|
|
307
|
+
ctx: ExtensionCommandContext,
|
|
308
|
+
title: string,
|
|
309
|
+
help: string,
|
|
310
|
+
rows: MenuRow[],
|
|
311
|
+
cursor: MenuCursor,
|
|
312
|
+
options: PersistentMenuOptions = {},
|
|
313
|
+
): Promise<MenuAction> {
|
|
314
|
+
return createPersistentMenu<MenuAction>(ctx, title, help, rows, cursor, undefined, [], undefined, undefined, options);
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
export async function showPersistentFormMenu(
|
|
318
|
+
ctx: ExtensionCommandContext,
|
|
319
|
+
title: string,
|
|
320
|
+
help: string,
|
|
321
|
+
rows: MenuRow[],
|
|
322
|
+
cursor: MenuCursor,
|
|
323
|
+
options: PersistentFormMenuOptions = {},
|
|
324
|
+
): Promise<FormMenuAction> {
|
|
325
|
+
return createPersistentMenu<FormMenuAction>(
|
|
326
|
+
ctx,
|
|
327
|
+
title,
|
|
328
|
+
help,
|
|
329
|
+
rows,
|
|
330
|
+
cursor,
|
|
331
|
+
() => ({ type: "save" }),
|
|
332
|
+
[],
|
|
333
|
+
(id, direction) => ({ type: "adjust", id, direction }),
|
|
334
|
+
new Set(options.adjustableRowIds ?? []),
|
|
335
|
+
options,
|
|
336
|
+
);
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
export async function showPersistentShortcutMenu<TShortcut extends string>(
|
|
340
|
+
ctx: ExtensionCommandContext,
|
|
341
|
+
title: string,
|
|
342
|
+
help: string,
|
|
343
|
+
rows: MenuRow[],
|
|
344
|
+
cursor: MenuCursor,
|
|
345
|
+
shortcuts: MenuShortcut<TShortcut>[],
|
|
346
|
+
options: PersistentMenuOptions = {},
|
|
347
|
+
): Promise<ShortcutMenuAction<TShortcut>> {
|
|
348
|
+
return createPersistentMenu<ShortcutMenuAction<TShortcut>>(ctx, title, help, rows, cursor, undefined, shortcuts, undefined, undefined, { searchable: true, ...options });
|
|
349
|
+
}
|
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
// tui/ui-helpers.ts
|
|
2
|
+
//
|
|
3
|
+
// 共用展示助手:把 stored / draft 数据格式化成 UI 字符串。
|
|
4
|
+
//
|
|
5
|
+
// dashboard 行设计准则:信息密度千万不要满。
|
|
6
|
+
// - 主列表使用固定列 + 选中详情;行内只放扫描所需信息
|
|
7
|
+
// - api 用短标签(Responses / Claude),详情区再显示完整上下文
|
|
8
|
+
// - auth 状态文本化为 key/env/cmd/auth?,避免把可选的外部认证误报成缺失
|
|
9
|
+
// - contextWindow 用 K/M 简写
|
|
10
|
+
|
|
11
|
+
import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
|
|
12
|
+
import { getApiKeyEnvVarName, getAuthStatusText, getProviderDisplayName } from "../state-document.ts";
|
|
13
|
+
import { CLIENT_HEADER_PROFILE_LABELS, getClientHeaderProfileDisplay, resolveClientHeaderProfile } from "../presets/client-headers.ts";
|
|
14
|
+
import type {
|
|
15
|
+
ApiKind,
|
|
16
|
+
ClientHeaderProfileId,
|
|
17
|
+
ModelInputKind,
|
|
18
|
+
StoredModel,
|
|
19
|
+
StoredProvider,
|
|
20
|
+
StoredRequestHeaderProfile,
|
|
21
|
+
} from "../types.ts";
|
|
22
|
+
|
|
23
|
+
export function maskSecret(value: string | undefined): string {
|
|
24
|
+
if (!value) return "<未填写>";
|
|
25
|
+
if (getApiKeyEnvVarName(value) || value.startsWith("!")) return value;
|
|
26
|
+
return "********";
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function authIcon(apiKey: string | undefined): string {
|
|
30
|
+
const status = getAuthStatusText(apiKey);
|
|
31
|
+
if (status === "no apiKey") return "?";
|
|
32
|
+
if (status === "command apiKey") return "$";
|
|
33
|
+
if (status.startsWith("env missing:")) return "⚠";
|
|
34
|
+
if (status.startsWith("env ")) return "✓";
|
|
35
|
+
return "•";
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function formatContextWindow(contextWindow: number): string {
|
|
39
|
+
if (contextWindow >= 1_000_000) return `${(contextWindow / 1_000_000).toFixed(contextWindow % 1_000_000 === 0 ? 0 : 1)}M`;
|
|
40
|
+
if (contextWindow >= 1_000) return `${Math.round(contextWindow / 1_000)}K`;
|
|
41
|
+
return String(contextWindow);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
type ColumnAlign = "left" | "right";
|
|
46
|
+
|
|
47
|
+
interface FixedColumn {
|
|
48
|
+
text: string;
|
|
49
|
+
width: number;
|
|
50
|
+
align?: ColumnAlign;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function fitColumn(text: string, columns: number, align: ColumnAlign = "left"): string {
|
|
54
|
+
const clipped = truncateToWidth(text, columns, "…");
|
|
55
|
+
const pad = " ".repeat(Math.max(0, columns - visibleWidth(clipped)));
|
|
56
|
+
return align === "right" ? pad + clipped : clipped + pad;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function joinFixedColumns(columns: readonly FixedColumn[], gap = " "): string {
|
|
60
|
+
return columns.map((column) => fitColumn(column.text, column.width, column.align)).join(gap).trimEnd();
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function formatTableHeader(line: string): string {
|
|
64
|
+
return ` ${line}`;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function sanitizeEndpoint(value: string): string {
|
|
68
|
+
return value.replace(/([?&](?:key|api_key|api-key)=)[^&]+/gi, "$1REDACTED");
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function formatApiShort(api: ApiKind): string {
|
|
72
|
+
if (api === "openai-responses") return "Responses";
|
|
73
|
+
if (api === "openai-completions") return "Chat";
|
|
74
|
+
if (api === "anthropic-messages") return "Claude";
|
|
75
|
+
if (api === "google-generative-ai") return "Gemini";
|
|
76
|
+
return api;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function getAuthKind(apiKey: string | undefined): string {
|
|
80
|
+
const status = getAuthStatusText(apiKey);
|
|
81
|
+
if (status === "no apiKey") return "auth?";
|
|
82
|
+
if (status === "command apiKey") return "cmd";
|
|
83
|
+
if (status.startsWith("env missing:")) return "miss";
|
|
84
|
+
if (status.startsWith("env ")) return "env";
|
|
85
|
+
return "key";
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function getProviderStatus(provider: StoredProvider): string {
|
|
89
|
+
const auth = getAuthKind(provider.apiKey);
|
|
90
|
+
return auth === "miss" || auth === "auth?" ? "check" : "ready";
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function getProviderProxyText(provider: StoredProvider): string {
|
|
94
|
+
return provider.httpProxyEnabled ? "proxy" : "direct";
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function formatProviderHeaderProfile(
|
|
98
|
+
provider: StoredProvider,
|
|
99
|
+
requestHeaderProfiles: Record<string, StoredRequestHeaderProfile> = {},
|
|
100
|
+
): string {
|
|
101
|
+
if (provider.clientHeaderProfile === "recommended") {
|
|
102
|
+
const resolved = resolveClientHeaderProfile(provider.clientHeaderProfile, provider.api);
|
|
103
|
+
return `Auto→${CLIENT_HEADER_PROFILE_LABELS[resolved]}`;
|
|
104
|
+
}
|
|
105
|
+
if (provider.clientHeaderProfile === "disabled") return "Off";
|
|
106
|
+
if (provider.clientHeaderProfile === "custom") {
|
|
107
|
+
if (provider.requestHeaderProfileId) {
|
|
108
|
+
const profile = requestHeaderProfiles[provider.requestHeaderProfileId];
|
|
109
|
+
return profile ? `Custom:${profile.name}` : `Custom:${provider.requestHeaderProfileId}`;
|
|
110
|
+
}
|
|
111
|
+
const inlineCount = Object.keys(provider.customClientHeaders ?? {}).length;
|
|
112
|
+
return inlineCount > 0 ? `Inline(${inlineCount})` : "Custom?";
|
|
113
|
+
}
|
|
114
|
+
return CLIENT_HEADER_PROFILE_LABELS[provider.clientHeaderProfile];
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export const PROVIDER_CONSOLE_HEADER = formatTableHeader(joinFixedColumns([
|
|
118
|
+
{ text: "接入", width: 22 },
|
|
119
|
+
{ text: "API", width: 9 },
|
|
120
|
+
{ text: "模型", width: 6, align: "right" },
|
|
121
|
+
{ text: "请求头", width: 15 },
|
|
122
|
+
{ text: "代理", width: 6 },
|
|
123
|
+
{ text: "认证", width: 6 },
|
|
124
|
+
{ text: "状态", width: 5 },
|
|
125
|
+
]));
|
|
126
|
+
|
|
127
|
+
function getModelListColumns(provider: StoredProvider): FixedColumn[] {
|
|
128
|
+
const columns: FixedColumn[] = [
|
|
129
|
+
{ text: "模型 ID", width: 30 },
|
|
130
|
+
{ text: "显示名", width: 16 },
|
|
131
|
+
{ text: "输入", width: 10 },
|
|
132
|
+
{ text: "Thinking", width: 8 },
|
|
133
|
+
{ text: "上下文", width: 7, align: "right" },
|
|
134
|
+
{ text: "输出", width: 7, align: "right" },
|
|
135
|
+
];
|
|
136
|
+
if (provider.api === "openai-responses") columns.push({ text: "Fast", width: 8 });
|
|
137
|
+
return columns;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export function formatModelListHeader(provider: StoredProvider): string {
|
|
141
|
+
return formatTableHeader(joinFixedColumns(getModelListColumns(provider)));
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export function formatProviderConsoleRow(
|
|
145
|
+
providerId: string,
|
|
146
|
+
provider: StoredProvider,
|
|
147
|
+
requestHeaderProfiles: Record<string, StoredRequestHeaderProfile> = {},
|
|
148
|
+
): string {
|
|
149
|
+
const name = getProviderDisplayName(providerId, provider);
|
|
150
|
+
const displayName = name === providerId ? providerId : `${name} (${providerId})`;
|
|
151
|
+
return joinFixedColumns([
|
|
152
|
+
{ text: displayName, width: 22 },
|
|
153
|
+
{ text: formatApiShort(provider.api), width: 9 },
|
|
154
|
+
{ text: String(provider.models.length), width: 6, align: "right" },
|
|
155
|
+
{ text: formatProviderHeaderProfile(provider, requestHeaderProfiles), width: 15 },
|
|
156
|
+
{ text: getProviderProxyText(provider), width: 6 },
|
|
157
|
+
{ text: getAuthKind(provider.apiKey), width: 6 },
|
|
158
|
+
{ text: getProviderStatus(provider), width: 5 },
|
|
159
|
+
]);
|
|
160
|
+
}
|
|
161
|
+
export function formatProviderRow(providerId: string, provider: StoredProvider): string {
|
|
162
|
+
const name = getProviderDisplayName(providerId, provider);
|
|
163
|
+
const head = name === providerId ? providerId : `${name} (${providerId})`;
|
|
164
|
+
return `${head} · ${provider.api} · ${provider.models.length}模型 · ${authIcon(provider.apiKey)}`;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export function formatProviderDetailLines(
|
|
168
|
+
providerId: string,
|
|
169
|
+
provider: StoredProvider,
|
|
170
|
+
requestHeaderProfiles: Record<string, StoredRequestHeaderProfile> = {},
|
|
171
|
+
): string[] {
|
|
172
|
+
const name = getProviderDisplayName(providerId, provider);
|
|
173
|
+
const title = name === providerId ? providerId : `${name} (${providerId})`;
|
|
174
|
+
const modelIds = provider.models.map((model) => model.id).join(", ") || "<无模型>";
|
|
175
|
+
return [
|
|
176
|
+
title,
|
|
177
|
+
` endpoint ${sanitizeEndpoint(provider.baseUrl)}`,
|
|
178
|
+
` proxy ${provider.httpProxyEnabled ? sanitizeEndpoint(provider.httpProxyUrl ?? "http://127.0.0.1:7890") : "direct"}`,
|
|
179
|
+
` api ${formatApiShort(provider.api)} · headers ${formatProviderHeaderProfile(provider, requestHeaderProfiles)} · auth ${getAuthKind(provider.apiKey)}`,
|
|
180
|
+
` models ${modelIds}`,
|
|
181
|
+
];
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
export function formatProviderSummaryLine(
|
|
185
|
+
provider: StoredProvider,
|
|
186
|
+
requestHeaderProfiles: Record<string, StoredRequestHeaderProfile> = {},
|
|
187
|
+
): string {
|
|
188
|
+
return `${formatApiShort(provider.api)} · ${provider.models.length} 模型 · headers ${formatProviderHeaderProfile(provider, requestHeaderProfiles)} · proxy ${getProviderProxyText(provider)} · auth ${getAuthKind(provider.apiKey)}`;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
export function formatProviderEndpointLine(provider: StoredProvider): string {
|
|
192
|
+
const proxy = provider.httpProxyEnabled ? ` · proxy ${sanitizeEndpoint(provider.httpProxyUrl ?? "http://127.0.0.1:7890")}` : "";
|
|
193
|
+
return `endpoint ${sanitizeEndpoint(provider.baseUrl)}${proxy}`;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function getThinkingFlag(model: StoredModel): string | undefined {
|
|
197
|
+
if (!model.reasoning) return undefined;
|
|
198
|
+
const map = model.thinkingLevelMap;
|
|
199
|
+
if (map?.max === "max" && map.xhigh === "xhigh") return "think:xhigh/max";
|
|
200
|
+
if (map?.max === "max") return "think:max";
|
|
201
|
+
return "think";
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
export function formatModelRow(model: StoredModel): string {
|
|
205
|
+
const flags: string[] = [];
|
|
206
|
+
const thinkingFlag = getThinkingFlag(model);
|
|
207
|
+
if (thinkingFlag) flags.push(thinkingFlag);
|
|
208
|
+
flags.push(model.input.includes("image") ? "视觉" : "文本");
|
|
209
|
+
flags.push(`${formatContextWindow(model.contextWindow)}ctx`);
|
|
210
|
+
const suffix = model.name && model.name !== model.id ? ` “${model.name}”` : "";
|
|
211
|
+
return `${model.id}${suffix} · ${flags.join(" · ")}`;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function formatModelNameCell(model: StoredModel): string {
|
|
215
|
+
return model.name && model.name !== model.id ? model.name : "默认";
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function formatModelInputCell(model: StoredModel): string {
|
|
219
|
+
return model.input.includes("image") ? "文本,视觉" : "文本";
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function formatModelThinkingCell(model: StoredModel): string {
|
|
223
|
+
return model.reasoning ? "开" : "关";
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
export function formatModelConsoleRow(model: StoredModel): string {
|
|
227
|
+
return joinFixedColumns([
|
|
228
|
+
{ text: model.id, width: 34 },
|
|
229
|
+
{ text: formatModelInputCell(model), width: 12 },
|
|
230
|
+
{ text: formatModelThinkingCell(model), width: 8 },
|
|
231
|
+
{ text: formatContextWindow(model.contextWindow), width: 7, align: "right" },
|
|
232
|
+
{ text: formatContextWindow(model.maxTokens), width: 7, align: "right" },
|
|
233
|
+
{ text: model.openAIServiceTier === "priority" ? "priority" : "-", width: 8 },
|
|
234
|
+
]);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
export function formatModelListRow(provider: StoredProvider, model: StoredModel): string {
|
|
238
|
+
const columns: FixedColumn[] = [
|
|
239
|
+
{ text: model.id, width: 30 },
|
|
240
|
+
{ text: formatModelNameCell(model), width: 16 },
|
|
241
|
+
{ text: formatModelInputCell(model), width: 10 },
|
|
242
|
+
{ text: formatModelThinkingCell(model), width: 8 },
|
|
243
|
+
{ text: formatContextWindow(model.contextWindow), width: 7, align: "right" },
|
|
244
|
+
{ text: formatContextWindow(model.maxTokens), width: 7, align: "right" },
|
|
245
|
+
];
|
|
246
|
+
if (provider.api === "openai-responses") {
|
|
247
|
+
columns.push({ text: model.openAIServiceTier === "priority" ? "priority" : "off", width: 8 });
|
|
248
|
+
}
|
|
249
|
+
return joinFixedColumns(columns);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
export const API_CHOICES: { id: ApiKind; label: string }[] = [
|
|
253
|
+
{ id: "openai-responses", label: "OpenAI Responses · 标准 instructions/input wire" },
|
|
254
|
+
{ id: "openai-completions", label: "OpenAI Chat · 传统 chat/completions 兼容" },
|
|
255
|
+
{ id: "anthropic-messages", label: "Anthropic Messages · Claude / Claude Code 兼容" },
|
|
256
|
+
{ id: "google-generative-ai", label: "Google Gemini · Gemini 原生 API" },
|
|
257
|
+
];
|
|
258
|
+
|
|
259
|
+
export const BUILT_IN_PROFILE_CHOICES: { id: Exclude<ClientHeaderProfileId, "custom">; label: string }[] = [
|
|
260
|
+
{ id: "recommended", label: "自动推荐" },
|
|
261
|
+
{ id: "disabled", label: "不添加" },
|
|
262
|
+
{ id: "claude-code", label: "ClaudeCode" },
|
|
263
|
+
{ id: "codex-cli", label: "Codex" },
|
|
264
|
+
];
|
|
265
|
+
|
|
266
|
+
export function describeProfile(
|
|
267
|
+
profile: ClientHeaderProfileId,
|
|
268
|
+
api: ApiKind,
|
|
269
|
+
requestHeaderProfileId?: string,
|
|
270
|
+
requestHeaderProfiles: Record<string, StoredRequestHeaderProfile> = {},
|
|
271
|
+
): string {
|
|
272
|
+
if (profile !== "custom") return getClientHeaderProfileDisplay(profile, api);
|
|
273
|
+
if (!requestHeaderProfileId) return "自定义请求头(未选择)";
|
|
274
|
+
const selected = requestHeaderProfiles[requestHeaderProfileId];
|
|
275
|
+
return selected ? `${selected.name} (${requestHeaderProfileId})` : `自定义请求头缺失:${requestHeaderProfileId}`;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
export const VISION_INPUT_CHOICES: { enabled: boolean; kinds: ModelInputKind[]; label: string }[] = [
|
|
279
|
+
{ enabled: false, kinds: ["text"], label: "关闭 — 仅文本输入" },
|
|
280
|
+
{ enabled: true, kinds: ["text", "image"], label: "开启 — 支持视觉(文本 + 图片)" },
|
|
281
|
+
];
|
|
282
|
+
|
|
283
|
+
export function supportsVisionInput(kinds: ModelInputKind[]): boolean {
|
|
284
|
+
return kinds.includes("image");
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
export function describeVisionInput(kinds: ModelInputKind[]): string {
|
|
288
|
+
return supportsVisionInput(kinds) ? "开启 · 支持视觉" : "关闭 · 仅文本";
|
|
289
|
+
}
|