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,266 @@
|
|
|
1
|
+
// tui/model-picker.ts
|
|
2
|
+
//
|
|
3
|
+
// 已拉取模型列表的本地选择器:8 行分页 + 直接输入搜索 + 匹配排序。
|
|
4
|
+
|
|
5
|
+
import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
|
|
6
|
+
import { CURSOR_MARKER, Key, matchesKey, truncateToWidth } from "@earendil-works/pi-tui";
|
|
7
|
+
|
|
8
|
+
const MODEL_PAGE_SIZE = 8;
|
|
9
|
+
const EXACT_MATCH_SCORE = 50_000;
|
|
10
|
+
const PREFIX_MATCH_SCORE = 40_000;
|
|
11
|
+
const SEGMENT_PREFIX_MATCH_SCORE = 30_000;
|
|
12
|
+
const SUBSTRING_MATCH_SCORE = 20_000;
|
|
13
|
+
const FUZZY_MATCH_SCORE = 10_000;
|
|
14
|
+
|
|
15
|
+
interface RankedModelId {
|
|
16
|
+
modelId: string;
|
|
17
|
+
score: number;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function clampSelectedIndex(index: number, modelCount: number): number {
|
|
21
|
+
if (modelCount <= 0) return 0;
|
|
22
|
+
return Math.min(Math.max(0, index), modelCount - 1);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function getPageCount(modelCount: number): number {
|
|
26
|
+
if (modelCount <= 0) return 0;
|
|
27
|
+
return Math.ceil(modelCount / MODEL_PAGE_SIZE);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function getPageStart(selectedIndex: number): number {
|
|
31
|
+
return Math.floor(selectedIndex / MODEL_PAGE_SIZE) * MODEL_PAGE_SIZE;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function normalizeSearchText(text: string): string {
|
|
35
|
+
return text.trim().toLowerCase();
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function splitSearchTokens(searchText: string): string[] {
|
|
39
|
+
const normalized = normalizeSearchText(searchText);
|
|
40
|
+
if (!normalized) return [];
|
|
41
|
+
return normalized.split(/\s+/).filter(Boolean);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function splitModelSegments(normalizedModelId: string): string[] {
|
|
45
|
+
return normalizedModelId.split(/[\s/:._-]+/).filter(Boolean);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function getFuzzySpan(normalizedModelId: string, token: string): number | undefined {
|
|
49
|
+
let searchStart = 0;
|
|
50
|
+
let firstMatchIndex = -1;
|
|
51
|
+
let lastMatchIndex = -1;
|
|
52
|
+
for (const character of token) {
|
|
53
|
+
const matchIndex = normalizedModelId.indexOf(character, searchStart);
|
|
54
|
+
if (matchIndex < 0) return undefined;
|
|
55
|
+
if (firstMatchIndex < 0) firstMatchIndex = matchIndex;
|
|
56
|
+
lastMatchIndex = matchIndex;
|
|
57
|
+
searchStart = matchIndex + character.length;
|
|
58
|
+
}
|
|
59
|
+
return lastMatchIndex - firstMatchIndex + 1;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function rankTokenWithinModel(normalizedModelId: string, token: string): number | undefined {
|
|
63
|
+
if (normalizedModelId === token) return EXACT_MATCH_SCORE;
|
|
64
|
+
if (normalizedModelId.startsWith(token)) return PREFIX_MATCH_SCORE - normalizedModelId.length;
|
|
65
|
+
|
|
66
|
+
const segmentIndex = splitModelSegments(normalizedModelId).findIndex((segment) => segment.startsWith(token));
|
|
67
|
+
if (segmentIndex >= 0) {
|
|
68
|
+
return SEGMENT_PREFIX_MATCH_SCORE - segmentIndex * 100 - normalizedModelId.length;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const substringIndex = normalizedModelId.indexOf(token);
|
|
72
|
+
if (substringIndex >= 0) {
|
|
73
|
+
return SUBSTRING_MATCH_SCORE - substringIndex * 100 - normalizedModelId.length;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const fuzzySpan = getFuzzySpan(normalizedModelId, token);
|
|
77
|
+
if (fuzzySpan === undefined) return undefined;
|
|
78
|
+
return FUZZY_MATCH_SCORE - fuzzySpan * 10 - normalizedModelId.length;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function rankModelId(modelId: string, searchText: string): number | undefined {
|
|
82
|
+
const tokens = splitSearchTokens(searchText);
|
|
83
|
+
if (tokens.length === 0) return 0;
|
|
84
|
+
|
|
85
|
+
const normalizedModelId = modelId.toLowerCase();
|
|
86
|
+
let combinedScore = 0;
|
|
87
|
+
for (const token of tokens) {
|
|
88
|
+
const tokenScore = rankTokenWithinModel(normalizedModelId, token);
|
|
89
|
+
if (tokenScore === undefined) return undefined;
|
|
90
|
+
combinedScore += tokenScore;
|
|
91
|
+
}
|
|
92
|
+
return combinedScore - normalizedModelId.length;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function sortModelIdsForSearch(modelIds: string[], searchText: string): string[] {
|
|
96
|
+
const tokens = splitSearchTokens(searchText);
|
|
97
|
+
if (tokens.length === 0) return [...modelIds].sort((a, b) => a.localeCompare(b));
|
|
98
|
+
|
|
99
|
+
const rankedModelIds: RankedModelId[] = [];
|
|
100
|
+
for (const modelId of modelIds) {
|
|
101
|
+
const score = rankModelId(modelId, searchText);
|
|
102
|
+
if (score !== undefined) rankedModelIds.push({ modelId, score });
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
rankedModelIds.sort((a, b) => {
|
|
106
|
+
const scoreOrder = b.score - a.score;
|
|
107
|
+
if (scoreOrder !== 0) return scoreOrder;
|
|
108
|
+
const lengthOrder = a.modelId.length - b.modelId.length;
|
|
109
|
+
if (lengthOrder !== 0) return lengthOrder;
|
|
110
|
+
return a.modelId.localeCompare(b.modelId);
|
|
111
|
+
});
|
|
112
|
+
return rankedModelIds.map((model) => model.modelId);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function isPlainTextInput(data: string): boolean {
|
|
116
|
+
if (!data) return false;
|
|
117
|
+
for (const character of data) {
|
|
118
|
+
const codePoint = character.codePointAt(0);
|
|
119
|
+
if (codePoint === undefined || codePoint < 32 || codePoint === 127) return false;
|
|
120
|
+
}
|
|
121
|
+
return !data.includes("\x1b");
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function formatPageText(modelCount: number, selectedIndex: number): string {
|
|
125
|
+
const pageCount = getPageCount(modelCount);
|
|
126
|
+
if (pageCount === 0) return "页码 0 / 0";
|
|
127
|
+
return `页码 ${Math.floor(selectedIndex / MODEL_PAGE_SIZE) + 1} / ${pageCount}`;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export async function pickModelIdFromList(
|
|
131
|
+
ctx: ExtensionCommandContext,
|
|
132
|
+
title: string,
|
|
133
|
+
modelIds: string[],
|
|
134
|
+
currentModelId: string,
|
|
135
|
+
): Promise<string | undefined> {
|
|
136
|
+
const sortedModelIds = [...new Set(modelIds)].sort((a, b) => a.localeCompare(b));
|
|
137
|
+
const normalizedCurrentModelId = currentModelId.trim();
|
|
138
|
+
|
|
139
|
+
return ctx.ui.custom<string | undefined>((tui, theme, _keybindings, done) => {
|
|
140
|
+
let searchText = "";
|
|
141
|
+
let matchedModelIds = sortModelIdsForSearch(sortedModelIds, searchText);
|
|
142
|
+
let selectedIndex = clampSelectedIndex(matchedModelIds.indexOf(normalizedCurrentModelId), matchedModelIds.length);
|
|
143
|
+
let focused = false;
|
|
144
|
+
|
|
145
|
+
const requestRender = (): void => {
|
|
146
|
+
selectedIndex = clampSelectedIndex(selectedIndex, matchedModelIds.length);
|
|
147
|
+
tui.requestRender();
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
const replaceSearchText = (nextSearchText: string): void => {
|
|
151
|
+
searchText = nextSearchText;
|
|
152
|
+
matchedModelIds = sortModelIdsForSearch(sortedModelIds, searchText);
|
|
153
|
+
const selectedModelId = normalizeSearchText(searchText) ? undefined : normalizedCurrentModelId;
|
|
154
|
+
const selectedModelIndex = selectedModelId ? matchedModelIds.indexOf(selectedModelId) : -1;
|
|
155
|
+
selectedIndex = clampSelectedIndex(selectedModelIndex >= 0 ? selectedModelIndex : 0, matchedModelIds.length);
|
|
156
|
+
requestRender();
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
const moveSelection = (delta: number): void => {
|
|
160
|
+
selectedIndex = clampSelectedIndex(selectedIndex + delta, matchedModelIds.length);
|
|
161
|
+
requestRender();
|
|
162
|
+
};
|
|
163
|
+
|
|
164
|
+
const movePage = (delta: number): void => {
|
|
165
|
+
if (matchedModelIds.length === 0) return;
|
|
166
|
+
const pageCount = getPageCount(matchedModelIds.length);
|
|
167
|
+
const currentPageIndex = Math.floor(selectedIndex / MODEL_PAGE_SIZE);
|
|
168
|
+
const rowOffset = selectedIndex % MODEL_PAGE_SIZE;
|
|
169
|
+
const nextPageIndex = Math.min(Math.max(0, currentPageIndex + delta), pageCount - 1);
|
|
170
|
+
selectedIndex = clampSelectedIndex(nextPageIndex * MODEL_PAGE_SIZE + rowOffset, matchedModelIds.length);
|
|
171
|
+
requestRender();
|
|
172
|
+
};
|
|
173
|
+
|
|
174
|
+
return {
|
|
175
|
+
get focused(): boolean {
|
|
176
|
+
return focused;
|
|
177
|
+
},
|
|
178
|
+
set focused(value: boolean) {
|
|
179
|
+
focused = value;
|
|
180
|
+
},
|
|
181
|
+
invalidate(): void {},
|
|
182
|
+
handleInput(data: string): void {
|
|
183
|
+
if (matchesKey(data, Key.escape) || matchesKey(data, Key.ctrl("c"))) {
|
|
184
|
+
done(undefined);
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
if (matchesKey(data, Key.enter)) {
|
|
188
|
+
const selectedModelId = matchedModelIds[selectedIndex];
|
|
189
|
+
if (selectedModelId) done(selectedModelId);
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
if (matchesKey(data, Key.backspace)) {
|
|
193
|
+
if (searchText.length > 0) replaceSearchText(Array.from(searchText).slice(0, -1).join(""));
|
|
194
|
+
return;
|
|
195
|
+
}
|
|
196
|
+
if (matchesKey(data, Key.up)) {
|
|
197
|
+
moveSelection(-1);
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
if (matchesKey(data, Key.down)) {
|
|
201
|
+
moveSelection(1);
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
if (matchesKey(data, Key.pageUp) || matchesKey(data, Key.left)) {
|
|
205
|
+
movePage(-1);
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
if (matchesKey(data, Key.pageDown) || matchesKey(data, Key.right)) {
|
|
209
|
+
movePage(1);
|
|
210
|
+
return;
|
|
211
|
+
}
|
|
212
|
+
if (matchesKey(data, Key.home)) {
|
|
213
|
+
selectedIndex = 0;
|
|
214
|
+
requestRender();
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
if (matchesKey(data, Key.end)) {
|
|
218
|
+
selectedIndex = Math.max(0, matchedModelIds.length - 1);
|
|
219
|
+
requestRender();
|
|
220
|
+
return;
|
|
221
|
+
}
|
|
222
|
+
if (isPlainTextInput(data)) {
|
|
223
|
+
replaceSearchText(searchText + data);
|
|
224
|
+
}
|
|
225
|
+
},
|
|
226
|
+
render(width: number): string[] {
|
|
227
|
+
selectedIndex = clampSelectedIndex(selectedIndex, matchedModelIds.length);
|
|
228
|
+
const pageStart = getPageStart(selectedIndex);
|
|
229
|
+
const visibleModelIds = matchedModelIds.slice(pageStart, pageStart + MODEL_PAGE_SIZE);
|
|
230
|
+
const border = theme.fg("borderMuted", "─".repeat(Math.max(0, Math.min(width, 100))));
|
|
231
|
+
const queryCursor = focused ? `${CURSOR_MARKER}${theme.fg("accent", "▌")}` : "";
|
|
232
|
+
const searchLine = searchText
|
|
233
|
+
? `${theme.fg("muted", "搜索: ")}${searchText}${queryCursor}`
|
|
234
|
+
: `${theme.fg("muted", "搜索: ")}${queryCursor}${theme.fg("dim", "<直接输入搜索>")}`;
|
|
235
|
+
const lines: string[] = [
|
|
236
|
+
border,
|
|
237
|
+
theme.fg("accent", theme.bold(title)),
|
|
238
|
+
searchLine,
|
|
239
|
+
theme.fg("dim", `匹配 ${matchedModelIds.length} / ${sortedModelIds.length} · ${formatPageText(matchedModelIds.length, selectedIndex)}`),
|
|
240
|
+
"",
|
|
241
|
+
];
|
|
242
|
+
|
|
243
|
+
if (visibleModelIds.length === 0) {
|
|
244
|
+
lines.push(theme.fg("warning", "没有匹配模型;Backspace 删除搜索词,Esc 返回后可手动输入。"));
|
|
245
|
+
} else {
|
|
246
|
+
for (let rowIndex = 0; rowIndex < visibleModelIds.length; rowIndex += 1) {
|
|
247
|
+
const absoluteIndex = pageStart + rowIndex;
|
|
248
|
+
const modelId = visibleModelIds[rowIndex]!;
|
|
249
|
+
const selected = absoluteIndex === selectedIndex;
|
|
250
|
+
const currentSuffix = modelId === normalizedCurrentModelId ? theme.fg("dim", " ← 当前") : "";
|
|
251
|
+
const prefix = selected ? "❯ " : " ";
|
|
252
|
+
const rowText = `${prefix}${modelId}${currentSuffix}`;
|
|
253
|
+
lines.push(selected ? theme.fg("accent", rowText) : rowText);
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
lines.push(
|
|
258
|
+
"",
|
|
259
|
+
theme.fg("dim", "↑↓ 选择 · ←→/PgUp/PgDn 翻页 · 输入搜索 · Backspace 删除 · Enter 确认 · Esc 取消"),
|
|
260
|
+
border,
|
|
261
|
+
);
|
|
262
|
+
return lines.map((line) => truncateToWidth(line, width));
|
|
263
|
+
},
|
|
264
|
+
};
|
|
265
|
+
});
|
|
266
|
+
}
|
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
// tui/models-json-panel.ts
|
|
2
|
+
//
|
|
3
|
+
// 二级面板:查看 / 管理 pi 的 ~/.pi/agent/models.json。
|
|
4
|
+
//
|
|
5
|
+
// 说明:models.json 是 provider/model 定义权威源;本面板是高级入口。
|
|
6
|
+
// 删除扩展管理中的同名条目时,会同步清理插件元数据、enabledModels 与当前运行时注册。
|
|
7
|
+
|
|
8
|
+
import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
|
|
9
|
+
import { formatUnknownError } from "../common.ts";
|
|
10
|
+
import {
|
|
11
|
+
MODELS_JSON_PATH,
|
|
12
|
+
readModelsJson,
|
|
13
|
+
type ModelsJsonDocument,
|
|
14
|
+
type ModelsJsonModelEntry,
|
|
15
|
+
type ModelsJsonProviderEntry,
|
|
16
|
+
} from "../models-json-manager.ts";
|
|
17
|
+
import { deleteModelsJsonModelConfiguration, deleteModelsJsonProviderConfiguration } from "../models-json-mutations.ts";
|
|
18
|
+
import { getModelFullId } from "../state-document.ts";
|
|
19
|
+
import { readState } from "../state-store.ts";
|
|
20
|
+
import { showPersistentShortcutMenu, type MenuCursor } from "./persistent-menu.ts";
|
|
21
|
+
import { formatTableHeader, joinFixedColumns } from "./ui-helpers.ts";
|
|
22
|
+
|
|
23
|
+
interface ProviderRow {
|
|
24
|
+
providerId: string;
|
|
25
|
+
label: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
interface ProviderMenuRow {
|
|
29
|
+
modelId: string;
|
|
30
|
+
model: ModelsJsonModelEntry;
|
|
31
|
+
label: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function modelCount(entry: ModelsJsonProviderEntry): number {
|
|
35
|
+
return Array.isArray(entry.models) ? entry.models.length : 0;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function formatApiLabel(api: string | undefined): string {
|
|
39
|
+
if (api === "openai-responses") return "Responses";
|
|
40
|
+
if (api === "openai-completions") return "Chat";
|
|
41
|
+
if (api === "anthropic-messages") return "Claude";
|
|
42
|
+
if (api === "google-generative-ai") return "Gemini";
|
|
43
|
+
return api ?? "未知";
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function formatTokenLimit(value: unknown): string {
|
|
47
|
+
if (typeof value !== "number" || !Number.isFinite(value)) return "-";
|
|
48
|
+
if (value >= 1_000_000) return `${(value / 1_000_000).toFixed(value % 1_000_000 === 0 ? 0 : 1)}M`;
|
|
49
|
+
if (value >= 1_000) return `${Math.round(value / 1_000)}K`;
|
|
50
|
+
return String(value);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function sanitizeEndpoint(value: string | undefined): string {
|
|
54
|
+
return (value ?? "<未配置>").replace(/([?&](?:key|api_key|api-key)=)[^&]+/gi, "$1REDACTED");
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function formatModelInput(input: unknown): string {
|
|
58
|
+
return Array.isArray(input) && input.includes("image") ? "文本,视觉" : "文本";
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function formatNativeModelNameCell(model: ModelsJsonModelEntry): string {
|
|
62
|
+
return model.name && model.name !== model.id ? model.name : "默认";
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function formatNativeModelThinkingCell(model: ModelsJsonModelEntry): string {
|
|
66
|
+
return model.reasoning === true ? "开" : "关";
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const NATIVE_PROVIDER_HEADER = formatTableHeader(joinFixedColumns([
|
|
70
|
+
{ text: "Provider", width: 24 },
|
|
71
|
+
{ text: "API", width: 9 },
|
|
72
|
+
{ text: "Models", width: 6, align: "right" },
|
|
73
|
+
{ text: "Source", width: 8 },
|
|
74
|
+
]));
|
|
75
|
+
|
|
76
|
+
const NATIVE_MODEL_HEADER = formatTableHeader(joinFixedColumns([
|
|
77
|
+
{ text: "模型 ID", width: 30 },
|
|
78
|
+
{ text: "显示名", width: 16 },
|
|
79
|
+
{ text: "输入", width: 10 },
|
|
80
|
+
{ text: "Thinking", width: 8 },
|
|
81
|
+
{ text: "上下文", width: 7, align: "right" },
|
|
82
|
+
{ text: "输出", width: 7, align: "right" },
|
|
83
|
+
{ text: "Headers", width: 8, align: "right" },
|
|
84
|
+
]));
|
|
85
|
+
|
|
86
|
+
function formatNativeModelRow(model: ModelsJsonModelEntry): string {
|
|
87
|
+
return joinFixedColumns([
|
|
88
|
+
{ text: model.id, width: 30 },
|
|
89
|
+
{ text: formatNativeModelNameCell(model), width: 16 },
|
|
90
|
+
{ text: formatModelInput(model.input), width: 10 },
|
|
91
|
+
{ text: formatNativeModelThinkingCell(model), width: 8 },
|
|
92
|
+
{ text: formatTokenLimit(model.contextWindow), width: 7, align: "right" },
|
|
93
|
+
{ text: formatTokenLimit(model.maxTokens), width: 7, align: "right" },
|
|
94
|
+
{ text: String(Object.keys(model.headers ?? {}).length), width: 8, align: "right" },
|
|
95
|
+
]);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function formatProviderRow(providerId: string, entry: ModelsJsonProviderEntry, managedInState: boolean): string {
|
|
99
|
+
return joinFixedColumns([
|
|
100
|
+
{ text: providerId, width: 24 },
|
|
101
|
+
{ text: formatApiLabel(entry.api), width: 9 },
|
|
102
|
+
{ text: String(modelCount(entry)), width: 6, align: "right" },
|
|
103
|
+
{ text: managedInState ? "managed" : "native", width: 8 },
|
|
104
|
+
]);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function formatProviderDetailLines(providerId: string, entry: ModelsJsonProviderEntry, managedInState: boolean): string[] {
|
|
108
|
+
const title = entry.name && entry.name !== providerId ? `${entry.name} (${providerId})` : providerId;
|
|
109
|
+
return [
|
|
110
|
+
title,
|
|
111
|
+
` endpoint ${sanitizeEndpoint(entry.baseUrl)}`,
|
|
112
|
+
` api ${formatApiLabel(entry.api)} · ${modelCount(entry)} models · ${managedInState ? "extension metadata" : "native only"}`,
|
|
113
|
+
` source ${MODELS_JSON_PATH}`,
|
|
114
|
+
];
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function buildProviderRows(document: ModelsJsonDocument, managedIds: ReadonlySet<string>): ProviderRow[] {
|
|
118
|
+
return Object.keys(document.providers)
|
|
119
|
+
.sort((a, b) => a.localeCompare(b))
|
|
120
|
+
.map((providerId) => ({
|
|
121
|
+
providerId,
|
|
122
|
+
label: formatProviderRow(providerId, document.providers[providerId]!, managedIds.has(providerId)),
|
|
123
|
+
}));
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function buildProviderMenuRows(entry: ModelsJsonProviderEntry): ProviderMenuRow[] {
|
|
127
|
+
return [...(entry.models ?? [])]
|
|
128
|
+
.sort((a, b) => a.id.localeCompare(b.id))
|
|
129
|
+
.map((model) => ({
|
|
130
|
+
modelId: model.id,
|
|
131
|
+
model,
|
|
132
|
+
label: formatNativeModelRow(model),
|
|
133
|
+
}));
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
async function deleteProvider(pi: ExtensionAPI, ctx: ExtensionCommandContext, providerId: string): Promise<void> {
|
|
138
|
+
const ok = await ctx.ui.confirm(`删除 ${providerId}`, "将从 models.json 删除整个接入,并同步清理插件元数据与 enabledModels。");
|
|
139
|
+
if (!ok) return;
|
|
140
|
+
try {
|
|
141
|
+
await deleteModelsJsonProviderConfiguration(pi, ctx, providerId);
|
|
142
|
+
} catch (error) {
|
|
143
|
+
ctx.ui.notify(`删除失败:${formatUnknownError(error)}`, "error");
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
async function deleteModel(pi: ExtensionAPI, ctx: ExtensionCommandContext, providerId: string, modelId: string): Promise<void> {
|
|
148
|
+
const fullId = getModelFullId(providerId, modelId);
|
|
149
|
+
const docForPrompt = await readModelsJson();
|
|
150
|
+
const modelCount = docForPrompt.providers[providerId]?.models?.length ?? 0;
|
|
151
|
+
const ok = await ctx.ui.confirm(
|
|
152
|
+
`删除 ${fullId}`,
|
|
153
|
+
modelCount <= 1 ? "这是该接入下最后一个模型;删除后该接入也会从 models.json 中移除。" : "只从 models.json 的模型列表移除该模型。",
|
|
154
|
+
);
|
|
155
|
+
if (!ok) return;
|
|
156
|
+
try {
|
|
157
|
+
await deleteModelsJsonModelConfiguration(pi, ctx, providerId, modelId);
|
|
158
|
+
} catch (error) {
|
|
159
|
+
ctx.ui.notify(`删除失败:${formatUnknownError(error)}`, "error");
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
type ModelsJsonProviderShortcut = "delete-model";
|
|
164
|
+
|
|
165
|
+
async function showProviderMenu(
|
|
166
|
+
pi: ExtensionAPI,
|
|
167
|
+
ctx: ExtensionCommandContext,
|
|
168
|
+
providerId: string,
|
|
169
|
+
managedInState: boolean,
|
|
170
|
+
): Promise<void> {
|
|
171
|
+
const cursor: MenuCursor = { index: 0 };
|
|
172
|
+
while (true) {
|
|
173
|
+
const doc = await readModelsJson();
|
|
174
|
+
const entry = doc.providers[providerId];
|
|
175
|
+
if (!entry) {
|
|
176
|
+
ctx.ui.notify(`接入不存在:${providerId}`, "warning");
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
const rows = buildProviderMenuRows(entry);
|
|
180
|
+
const action = await showPersistentShortcutMenu<ModelsJsonProviderShortcut>(
|
|
181
|
+
ctx,
|
|
182
|
+
`/model-manager / 原生配置 / ${providerId}`,
|
|
183
|
+
"",
|
|
184
|
+
rows.map((row, index) => ({ id: `${index}`, label: row.label })),
|
|
185
|
+
cursor,
|
|
186
|
+
[{ input: "d", shortcut: "delete-model" }],
|
|
187
|
+
{
|
|
188
|
+
summaryLines: formatProviderDetailLines(providerId, entry, managedInState).slice(1, 3),
|
|
189
|
+
tableHeader: NATIVE_MODEL_HEADER,
|
|
190
|
+
visibleRows: Math.min(12, Math.max(1, rows.length)),
|
|
191
|
+
footer: "↑↓ 选择 d 删除模型 Esc 返回",
|
|
192
|
+
emptyLabel: "暂无模型;返回上一级可删除接入",
|
|
193
|
+
},
|
|
194
|
+
);
|
|
195
|
+
if (action.type === "cancel") return;
|
|
196
|
+
if (action.type === "shortcut") {
|
|
197
|
+
const selectedModelId = rows[cursor.index]?.modelId;
|
|
198
|
+
if (!selectedModelId) {
|
|
199
|
+
ctx.ui.notify("没有可删除的模型;删除接入请返回上一级按 d。", "info");
|
|
200
|
+
continue;
|
|
201
|
+
}
|
|
202
|
+
await deleteModel(pi, ctx, providerId, selectedModelId);
|
|
203
|
+
continue;
|
|
204
|
+
}
|
|
205
|
+
ctx.ui.notify("models.json 模型只能删除;请按 d 删除选中模型。", "info");
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
type ModelsJsonShortcut = "delete-provider";
|
|
210
|
+
|
|
211
|
+
export async function runModelsJsonPanel(pi: ExtensionAPI, ctx: ExtensionCommandContext): Promise<void> {
|
|
212
|
+
const cursor: MenuCursor = { index: 0 };
|
|
213
|
+
while (true) {
|
|
214
|
+
let document: ModelsJsonDocument;
|
|
215
|
+
let managedIds: Set<string>;
|
|
216
|
+
try {
|
|
217
|
+
document = await readModelsJson();
|
|
218
|
+
managedIds = await readState()
|
|
219
|
+
.then((state) => new Set(Object.keys(state.providers)))
|
|
220
|
+
.catch(() => new Set<string>());
|
|
221
|
+
} catch (error) {
|
|
222
|
+
ctx.ui.notify(`读取 models.json 失败:${formatUnknownError(error)}`, "error");
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
const rows = buildProviderRows(document, managedIds);
|
|
227
|
+
const menuRows = rows.map((row, index) => ({ id: `${index}`, label: row.label }));
|
|
228
|
+
const action = await showPersistentShortcutMenu<ModelsJsonShortcut>(
|
|
229
|
+
ctx,
|
|
230
|
+
"/model-manager / 原生配置",
|
|
231
|
+
"",
|
|
232
|
+
menuRows,
|
|
233
|
+
cursor,
|
|
234
|
+
[{ input: "d", shortcut: "delete-provider" }],
|
|
235
|
+
{
|
|
236
|
+
summaryLines: [
|
|
237
|
+
"Pi / workflow 直接读取的最终模型配置",
|
|
238
|
+
MODELS_JSON_PATH,
|
|
239
|
+
],
|
|
240
|
+
tableHeader: NATIVE_PROVIDER_HEADER,
|
|
241
|
+
getDetailLines: (selectedRow) => {
|
|
242
|
+
const row = rows[Number.parseInt(selectedRow?.id ?? "", 10)];
|
|
243
|
+
const entry = row ? document.providers[row.providerId] : undefined;
|
|
244
|
+
return row && entry ? formatProviderDetailLines(row.providerId, entry, managedIds.has(row.providerId)) : [];
|
|
245
|
+
},
|
|
246
|
+
footer: "↑↓ 选择 Enter 查看接入 d 删除接入 Esc 返回",
|
|
247
|
+
emptyLabel: "models.json 中暂无接入",
|
|
248
|
+
},
|
|
249
|
+
);
|
|
250
|
+
if (action.type === "cancel") return;
|
|
251
|
+
if (action.type === "shortcut") {
|
|
252
|
+
const selectedProviderId = rows[cursor.index]?.providerId;
|
|
253
|
+
if (!selectedProviderId) {
|
|
254
|
+
ctx.ui.notify("没有可删除的接入。", "info");
|
|
255
|
+
continue;
|
|
256
|
+
}
|
|
257
|
+
await deleteProvider(pi, ctx, selectedProviderId);
|
|
258
|
+
continue;
|
|
259
|
+
}
|
|
260
|
+
const index = Number.parseInt(action.id, 10);
|
|
261
|
+
if (!Number.isFinite(index) || index < 0 || index >= rows.length) continue;
|
|
262
|
+
const row = rows[index]!;
|
|
263
|
+
await showProviderMenu(pi, ctx, row.providerId, managedIds.has(row.providerId));
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
export function getModelsJsonPanelPath(): string {
|
|
268
|
+
return MODELS_JSON_PATH;
|
|
269
|
+
}
|