pi-models-discovery 1.5.0 → 1.6.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +13 -6
- package/README.zh-CN.md +13 -6
- package/SKILL.md +1 -1
- package/locales/index.json +104 -0
- package/package.json +6 -4
- package/src/config-panel.ts +553 -0
- package/src/index.ts +259 -125
package/README.md
CHANGED
|
@@ -18,7 +18,7 @@ pi install npm:pi-models-discovery
|
|
|
18
18
|
/config:model-discovery
|
|
19
19
|
```
|
|
20
20
|
|
|
21
|
-
Interactively add / remove / rediscover providers
|
|
21
|
+
Interactively add / remove / rediscover providers. The command opens a settings panel that lists every provider marked with `discoverModels`; selecting one opens its fields (enable/disable discovery, baseUrl, api type, apiKey, display name) plus actions to rediscover or remove it. Adding a provider asks for id, baseUrl, api type, apiKey (optional) and display name (optional). Changes are written to models.json and take effect **immediately** (no /reload required). A backup is written to `models.json.discovery-bak` before each write.
|
|
22
22
|
|
|
23
23
|
Note: comments and custom formatting in models.json are not preserved on write (the file is reformatted with 4-space indentation).
|
|
24
24
|
|
|
@@ -77,21 +77,28 @@ Forces a rediscovery of every discovery provider and updates the local cache, no
|
|
|
77
77
|
- **Offline / fetch failure**: handwritten `models` in models.json (if any) are kept as a fallback, and an explicit warning is surfaced via in-session notify — never a silent degradation. One provider failing does not affect the others.
|
|
78
78
|
- **apiKey resolution** (discovery request only): supports literals and `$ENV_VAR` / `${ENV_VAR}` interpolation; `!command` values skip discovery with an explicit warning (chat requests are still resolved by pi itself and are unaffected).
|
|
79
79
|
- Default parameters for discovered models: `reasoning: true`, `input: ["text", "image"]`, zero cost, `contextWindow` 1M, `maxTokens` 64K, `compat.supportsDeveloperRole: false`. Provider-level `compat` is merged into every discovered model.
|
|
80
|
-
- Discovered models
|
|
80
|
+
- Discovered models default `thinkingLevelMap.xhigh` / `max` to `null`; extra tiers are no longer assumed to work on every backend. Standard tiers keep Pi's default mapping. Opt in with provider-level `compat.thinkingLevelMap` when the service supports extra tiers, or set any unsupported tier to `null`.
|
|
81
|
+
- For non-reasoning models, set provider-level `compat.reasoning: false` so `/thinking` offers only `off`. `reasoning` and `thinkingLevelMap` are extracted to the model's top level; other `compat` fields (such as `supportsReasoningEffort` and `thinkingFormat`) are forwarded normally. Mixed-model gateways can use `modelOverrides` by model.id. Existing caches apply the current thinking configuration at startup without a network refresh.
|
|
82
|
+
|
|
83
|
+
Example for a local non-reasoning service. To enable verified extra tiers instead, use `"thinkingLevelMap": { "xhigh": "xhigh", "max": "max" }` and remove `"reasoning": false`.
|
|
81
84
|
|
|
82
85
|
```json
|
|
83
86
|
{
|
|
84
87
|
"providers": {
|
|
85
|
-
"
|
|
88
|
+
"local": {
|
|
89
|
+
"baseUrl": "http://127.0.0.1:8000/v1",
|
|
90
|
+
"api": "openai-completions",
|
|
86
91
|
"discoverModels": true,
|
|
87
|
-
"
|
|
88
|
-
"
|
|
92
|
+
"compat": {
|
|
93
|
+
"reasoning": false,
|
|
94
|
+
"supportsReasoningEffort": false,
|
|
95
|
+
"thinkingLevelMap": { "xhigh": null, "max": null }
|
|
89
96
|
}
|
|
90
97
|
}
|
|
91
98
|
}
|
|
92
99
|
}
|
|
93
100
|
```
|
|
94
|
-
- Model metadata may carry `name` / `context_window` (or `contextWindow`) / `max_tokens` (or `maxTokens`); defaults are used when absent.
|
|
101
|
+
- Model metadata may carry `name` / `context_window` (or `contextWindow`, or vLLM's `max_model_len`) / `max_tokens` (or `maxTokens`); defaults are used when absent.
|
|
95
102
|
|
|
96
103
|
The old `/model-discovery`, `/model-discovery-refresh`, `/pi-model-discovery`, and `/pi-model-discovery-refresh` names remain available as compatibility aliases.
|
|
97
104
|
|
package/README.zh-CN.md
CHANGED
|
@@ -18,7 +18,7 @@ pi install npm:pi-models-discovery
|
|
|
18
18
|
/config:model-discovery
|
|
19
19
|
```
|
|
20
20
|
|
|
21
|
-
|
|
21
|
+
命令打开配置面板,列出所有标记了 `discoverModels` 的 provider;选中某个 provider 打开它的字段页(发现开关、baseUrl、api 类型、apiKey、显示名),并可立即重新发现或删除它。添加 provider 时依次问 id、baseUrl、api 类型、apiKey(可留空)、显示名(可留空),写入 models.json 后**立即生效**(无需 /reload)。写入前自动备份到 `models.json.discovery-bak`。
|
|
22
22
|
|
|
23
23
|
注意:写回时 models.json 中的注释与自定义排版不被保留(会格式化为 4 空格缩进)。
|
|
24
24
|
|
|
@@ -77,21 +77,28 @@ pi install npm:pi-models-discovery
|
|
|
77
77
|
- **离线 / 拉取失败**:保留 models.json 里手写的 `models`(如有,作为回退),并通过会话内 notify 显式警告,不静默降级;单个 provider 失败不影响其他 provider。
|
|
78
78
|
- **apiKey 解析**(仅发现请求):支持字面量与 `$ENV_VAR` / `${ENV_VAR}` 插值;`!command` 形式跳过发现并显式警告(聊天请求仍由 pi 自身解析执行,不受影响)。
|
|
79
79
|
- 发现的模型默认参数:`reasoning: true`、`input: ["text", "image"]`、cost 全 0、`contextWindow` 1M、`maxTokens` 64K、`compat.supportsDeveloperRole: false`;provider 级 `compat` 会合并进每个发现的模型。
|
|
80
|
-
-
|
|
80
|
+
- 发现的模型默认将 `thinkingLevelMap.xhigh` / `max` 设为 `null`,不再假定所有后端支持这些额外档位。标准档位仍沿用 Pi 默认映射。若服务支持额外档位,可在 provider 的 `compat.thinkingLevelMap` 中显式开启,也可将任一不支持的档位置为 `null`。
|
|
81
|
+
- 非推理模型可设置 provider 的 `compat.reasoning: false`,让 `/thinking` 只提供 `off`。`reasoning` 和 `thinkingLevelMap` 会提取到模型顶层,其余 `compat` 字段(如 `supportsReasoningEffort`、`thinkingFormat`)正常转发。混合模型网关可用 `modelOverrides` 按 model.id 覆盖。旧缓存会在启动时应用当前推理配置,无需联网刷新。
|
|
82
|
+
|
|
83
|
+
下面是本地非推理服务的配置;若要开启已确认支持的额外档位,改用 `"thinkingLevelMap": { "xhigh": "xhigh", "max": "max" }` 并移除 `"reasoning": false`。
|
|
81
84
|
|
|
82
85
|
```json
|
|
83
86
|
{
|
|
84
87
|
"providers": {
|
|
85
|
-
"
|
|
88
|
+
"local": {
|
|
89
|
+
"baseUrl": "http://127.0.0.1:8000/v1",
|
|
90
|
+
"api": "openai-completions",
|
|
86
91
|
"discoverModels": true,
|
|
87
|
-
"
|
|
88
|
-
"
|
|
92
|
+
"compat": {
|
|
93
|
+
"reasoning": false,
|
|
94
|
+
"supportsReasoningEffort": false,
|
|
95
|
+
"thinkingLevelMap": { "xhigh": null, "max": null }
|
|
89
96
|
}
|
|
90
97
|
}
|
|
91
98
|
}
|
|
92
99
|
}
|
|
93
100
|
```
|
|
94
|
-
- 模型元数据可携带 `name` / `context_window`(或 `contextWindow`)/ `max_tokens`(或 `maxTokens`),缺失时用默认值。
|
|
101
|
+
- 模型元数据可携带 `name` / `context_window`(或 `contextWindow`,或 vLLM 的 `max_model_len`)/ `max_tokens`(或 `maxTokens`),缺失时用默认值。
|
|
95
102
|
|
|
96
103
|
旧的 `/model-discovery`、`/model-discovery-refresh`、`/pi-model-discovery` 和 `/pi-model-discovery-refresh` 名称仍作为兼容别名保留。
|
|
97
104
|
|
package/SKILL.md
CHANGED
|
@@ -23,7 +23,7 @@ description: "配置与排查 models.json 的动态模型发现、缓存刷新
|
|
|
23
23
|
|
|
24
24
|
## 思考等级
|
|
25
25
|
|
|
26
|
-
|
|
26
|
+
发现的模型默认隐藏 `xhigh` / `max`(映射为 `null`),标准档位沿用 Pi 默认映射。provider 的 `compat.thinkingLevelMap` 可显式开启支持的档位或将不支持的档位置 `null`;非推理服务用 `compat.reasoning: false` 关闭推理,必要时配合 `compat.supportsReasoningEffort: false`。这两个扩展配置字段会提取到模型顶层,其余 compat 正常转发。逐模型差异用 `modelOverrides` 按 model.id 覆盖。旧缓存启动时会应用当前配置,无需联网刷新。
|
|
27
27
|
|
|
28
28
|
## 验证
|
|
29
29
|
|
package/locales/index.json
CHANGED
|
@@ -182,5 +182,109 @@
|
|
|
182
182
|
"removed": {
|
|
183
183
|
"zh-CN": "已删除 {id}(备份:{backup})",
|
|
184
184
|
"en-US": "Removed {id} (backup: {backup})"
|
|
185
|
+
},
|
|
186
|
+
"panelTitle": {
|
|
187
|
+
"zh-CN": "模型发现 provider(共 {count} 个)",
|
|
188
|
+
"en-US": "Model-discovery providers ({count})"
|
|
189
|
+
},
|
|
190
|
+
"panelProviderTitle": {
|
|
191
|
+
"zh-CN": "provider:{id}",
|
|
192
|
+
"en-US": "Provider: {id}"
|
|
193
|
+
},
|
|
194
|
+
"panelLabelAdd": {
|
|
195
|
+
"zh-CN": "添加 provider",
|
|
196
|
+
"en-US": "Add provider"
|
|
197
|
+
},
|
|
198
|
+
"panelDescAdd": {
|
|
199
|
+
"zh-CN": "填入 id、baseUrl、api 后写入 models.json 并立即发现模型。",
|
|
200
|
+
"en-US": "Enter an id, baseUrl and api; the entry is written to models.json and models are discovered immediately."
|
|
201
|
+
},
|
|
202
|
+
"panelLabelEnabled": {
|
|
203
|
+
"zh-CN": "模型发现",
|
|
204
|
+
"en-US": "Model discovery"
|
|
205
|
+
},
|
|
206
|
+
"panelDescEnabled": {
|
|
207
|
+
"zh-CN": "关闭后去掉该 provider 的 discoverModels 标记,模型条目不参与发现(provider 本身保留)。",
|
|
208
|
+
"en-US": "Turning this off removes the discoverModels marker so this provider is no longer discovered; the provider entry itself is kept."
|
|
209
|
+
},
|
|
210
|
+
"panelLabelName": {
|
|
211
|
+
"zh-CN": "显示名",
|
|
212
|
+
"en-US": "Display name"
|
|
213
|
+
},
|
|
214
|
+
"panelDescName": {
|
|
215
|
+
"zh-CN": "模型列表里显示的名字;留空回落到 provider id。",
|
|
216
|
+
"en-US": "Name shown in the model list; empty falls back to the provider id."
|
|
217
|
+
},
|
|
218
|
+
"panelLabelBaseUrl": {
|
|
219
|
+
"zh-CN": "baseUrl",
|
|
220
|
+
"en-US": "baseUrl"
|
|
221
|
+
},
|
|
222
|
+
"panelDescBaseUrl": {
|
|
223
|
+
"zh-CN": "发现请求打向 {baseUrl}/models;必须以 http:// 或 https:// 开头。",
|
|
224
|
+
"en-US": "Discovery requests go to {baseUrl}/models; it must start with http:// or https://."
|
|
225
|
+
},
|
|
226
|
+
"panelLabelApi": {
|
|
227
|
+
"zh-CN": "api 类型",
|
|
228
|
+
"en-US": "API type"
|
|
229
|
+
},
|
|
230
|
+
"panelDescApi": {
|
|
231
|
+
"zh-CN": "Pi 用哪种协议与这个 provider 对话。",
|
|
232
|
+
"en-US": "Which protocol Pi uses to talk to this provider."
|
|
233
|
+
},
|
|
234
|
+
"panelLabelApiKey": {
|
|
235
|
+
"zh-CN": "apiKey",
|
|
236
|
+
"en-US": "apiKey"
|
|
237
|
+
},
|
|
238
|
+
"panelDescApiKey": {
|
|
239
|
+
"zh-CN": "可留空;支持 $ENV_VAR 插值。",
|
|
240
|
+
"en-US": "Optional; supports $ENV_VAR interpolation."
|
|
241
|
+
},
|
|
242
|
+
"panelLabelRediscover": {
|
|
243
|
+
"zh-CN": "立即重新发现模型",
|
|
244
|
+
"en-US": "Rediscover models now"
|
|
245
|
+
},
|
|
246
|
+
"panelDescRediscover": {
|
|
247
|
+
"zh-CN": "立刻请求 {baseUrl}/models 刷新模型列表并更新缓存。",
|
|
248
|
+
"en-US": "Requests {baseUrl}/models now, refreshes the model list and updates the cache."
|
|
249
|
+
},
|
|
250
|
+
"panelLabelRemove": {
|
|
251
|
+
"zh-CN": "删除此 provider",
|
|
252
|
+
"en-US": "Remove this provider"
|
|
253
|
+
},
|
|
254
|
+
"panelDescRemove": {
|
|
255
|
+
"zh-CN": "从 models.json 移除该 provider 并注销其模型,会先让你确认。",
|
|
256
|
+
"en-US": "Removes the provider and unregisters its models from models.json; you are asked to confirm first."
|
|
257
|
+
},
|
|
258
|
+
"panelActionHint": {
|
|
259
|
+
"zh-CN": "回车执行",
|
|
260
|
+
"en-US": "Press Enter to run"
|
|
261
|
+
},
|
|
262
|
+
"panelValueUnset": {
|
|
263
|
+
"zh-CN": "(未设置)",
|
|
264
|
+
"en-US": "(unset)"
|
|
265
|
+
},
|
|
266
|
+
"panelOn": {
|
|
267
|
+
"zh-CN": "开",
|
|
268
|
+
"en-US": "On"
|
|
269
|
+
},
|
|
270
|
+
"panelOff": {
|
|
271
|
+
"zh-CN": "关",
|
|
272
|
+
"en-US": "Off"
|
|
273
|
+
},
|
|
274
|
+
"panelInputPrompt": {
|
|
275
|
+
"zh-CN": "值",
|
|
276
|
+
"en-US": "Value"
|
|
277
|
+
},
|
|
278
|
+
"panelConfirmRemove": {
|
|
279
|
+
"zh-CN": "删除 provider「{id}」?",
|
|
280
|
+
"en-US": "Remove provider \"{id}\"?"
|
|
281
|
+
},
|
|
282
|
+
"panelRemoveMessage": {
|
|
283
|
+
"zh-CN": "将从 models.json 移除该 provider 并注销其模型。此操作可重新添加恢复。",
|
|
284
|
+
"en-US": "This removes the provider and unregisters its models from models.json. It can be added again later."
|
|
285
|
+
},
|
|
286
|
+
"panelProviderMissing": {
|
|
287
|
+
"zh-CN": "models.json 中找不到 provider「{id}」的条目,无法写回。",
|
|
288
|
+
"en-US": "models.json has no entry for provider \"{id}\"; the change cannot be written back."
|
|
185
289
|
}
|
|
186
290
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-models-discovery",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.6.1",
|
|
4
4
|
"description": "Pi extension: discover models from {baseUrl}/models for providers marked with discoverModels in models.json",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./index.ts",
|
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
"SKILL.md"
|
|
17
17
|
],
|
|
18
18
|
"scripts": {
|
|
19
|
-
"test": "tsx --test tests/catalog.test.ts",
|
|
19
|
+
"test": "tsx --test tests/catalog.test.ts tests/config-panel.test.ts tests/config-panel-wiring.test.ts",
|
|
20
20
|
"typecheck": "tsc --noEmit --pretty false",
|
|
21
21
|
"build": "npm run typecheck",
|
|
22
22
|
"check": "npm run typecheck && npm test && npm pack --dry-run --json > /dev/null"
|
|
@@ -54,14 +54,16 @@
|
|
|
54
54
|
],
|
|
55
55
|
"peerDependencies": {
|
|
56
56
|
"@earendil-works/pi-ai": ">=0.80.0",
|
|
57
|
-
"@earendil-works/pi-coding-agent": ">=0.80.0"
|
|
57
|
+
"@earendil-works/pi-coding-agent": ">=0.80.0",
|
|
58
|
+
"@earendil-works/pi-tui": ">=0.80.0"
|
|
58
59
|
},
|
|
59
60
|
"dependencies": {
|
|
60
|
-
"pi-extensions-i18n": "^0.
|
|
61
|
+
"pi-extensions-i18n": "^0.9.0"
|
|
61
62
|
},
|
|
62
63
|
"devDependencies": {
|
|
63
64
|
"@earendil-works/pi-ai": "0.85.1",
|
|
64
65
|
"@earendil-works/pi-coding-agent": "0.85.1",
|
|
66
|
+
"@earendil-works/pi-tui": "0.85.1",
|
|
65
67
|
"@types/node": "24.12.4",
|
|
66
68
|
"tsx": "4.23.1",
|
|
67
69
|
"typescript": "5.9.3"
|
|
@@ -0,0 +1,553 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 模型发现 provider 的 TUI 配置面板。
|
|
3
|
+
*
|
|
4
|
+
* 原来用 `ctx.ui.select` 摆一串「id — baseUrl(api)」文本,看不出哪行是状态、按下去会发生什么。
|
|
5
|
+
* 这里改用 Pi 自带 `SettingsList`:左边字段名、右边当前值、选中项下方一行说明。
|
|
6
|
+
* 面板分两层:第一层列出所有带 discoverModels 标记的 provider,回车进入某个 provider 的第二层,
|
|
7
|
+
* 第二层就是这个 provider 的字段(发现开关、baseUrl、api、apiKey、显示名)和动作(立即发现、删除)。
|
|
8
|
+
*
|
|
9
|
+
* 枚举项回车开二级列表,自由文本回车开预填当前值的 Input 子菜单;改一项先写回内存配置,
|
|
10
|
+
* 由调用方负责落盘与运行期生效,不需要 /reload。
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import {
|
|
14
|
+
getSelectListTheme,
|
|
15
|
+
getSettingsListTheme,
|
|
16
|
+
type ExtensionCommandContext,
|
|
17
|
+
} from "@earendil-works/pi-coding-agent";
|
|
18
|
+
import {
|
|
19
|
+
Container,
|
|
20
|
+
type Component,
|
|
21
|
+
Input,
|
|
22
|
+
SelectList,
|
|
23
|
+
SettingsList,
|
|
24
|
+
type SelectItem,
|
|
25
|
+
type SettingItem,
|
|
26
|
+
Text,
|
|
27
|
+
} from "@earendil-works/pi-tui";
|
|
28
|
+
import { createTranslator, loadCatalog } from "pi-extensions-i18n";
|
|
29
|
+
|
|
30
|
+
const i18n = createTranslator(loadCatalog(new URL("../locales/index.json", import.meta.url)));
|
|
31
|
+
|
|
32
|
+
/** `models.json` 里 api 类型的候选值。 */
|
|
33
|
+
export const API_CHOICES = [
|
|
34
|
+
"openai-completions",
|
|
35
|
+
"anthropic-messages",
|
|
36
|
+
"openai-responses",
|
|
37
|
+
"google-generative-ai",
|
|
38
|
+
] as const;
|
|
39
|
+
|
|
40
|
+
/** 某个 provider 在面板上的可编辑字段名。 */
|
|
41
|
+
export const PROVIDER_FIELD = {
|
|
42
|
+
/** 是否参与模型发现;关闭时只去掉 discoverModels 标记,provider 本身保留。 */
|
|
43
|
+
enabled: "enabled",
|
|
44
|
+
/** 显示名。 */
|
|
45
|
+
name: "name",
|
|
46
|
+
/** API 地址。 */
|
|
47
|
+
baseUrl: "baseUrl",
|
|
48
|
+
/** api 类型。 */
|
|
49
|
+
api: "api",
|
|
50
|
+
/** API Key;支持 $ENV_VAR 插值。 */
|
|
51
|
+
apiKey: "apiKey",
|
|
52
|
+
/** 动作:立刻请求 {baseUrl}/models 刷新模型。 */
|
|
53
|
+
rediscover: "rediscover",
|
|
54
|
+
/** 动作:从 models.json 删除该 provider。 */
|
|
55
|
+
remove: "remove",
|
|
56
|
+
} as const;
|
|
57
|
+
|
|
58
|
+
/** provider 面板字段名联合类型。 */
|
|
59
|
+
export type ProviderFieldId = (typeof PROVIDER_FIELD)[keyof typeof PROVIDER_FIELD];
|
|
60
|
+
|
|
61
|
+
/** 面板顶层:新增 provider 的入口字段名。 */
|
|
62
|
+
export const ROOT_FIELD = {
|
|
63
|
+
/** 添加 provider。 */
|
|
64
|
+
add: "add",
|
|
65
|
+
} as const;
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* 面板覆盖的全部字段名(顶层 + provider 层),测试用它核对没有漏掉可编辑字段。
|
|
69
|
+
* provider 层字段名带 `provider.` 前缀,与顶层区分开。
|
|
70
|
+
*/
|
|
71
|
+
export const PANEL_FIELD_IDS: readonly string[] = [
|
|
72
|
+
ROOT_FIELD.add,
|
|
73
|
+
...Object.values(PROVIDER_FIELD).map((field) => `provider.${field}`),
|
|
74
|
+
];
|
|
75
|
+
|
|
76
|
+
/** 面板里一个 provider 的读形态。 */
|
|
77
|
+
export interface PanelProvider {
|
|
78
|
+
/** provider id,也是 models.json 里的键。 */
|
|
79
|
+
id: string;
|
|
80
|
+
/** baseUrl。 */
|
|
81
|
+
baseUrl: string;
|
|
82
|
+
/** api 类型。 */
|
|
83
|
+
api: string;
|
|
84
|
+
/** apiKey;空串表示未设置。 */
|
|
85
|
+
apiKey: string;
|
|
86
|
+
/** 显示名;空串表示未设置(回落为 id)。 */
|
|
87
|
+
name: string;
|
|
88
|
+
/** 是否带 discoverModels 标记。 */
|
|
89
|
+
enabled: boolean;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** 开关在面板上显示的两个文案。 */
|
|
93
|
+
function toggleLabels(): { on: string; off: string } {
|
|
94
|
+
return { on: i18n.t("panelOn"), off: i18n.t("panelOff") };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** 下拉列表的一项:label 是展示文本,value 是写回配置的值。 */
|
|
98
|
+
interface PanelOption {
|
|
99
|
+
/** 展示文本。 */
|
|
100
|
+
label: string;
|
|
101
|
+
/** 写回配置的值。 */
|
|
102
|
+
value: string;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** 二级列表最多同时显示几行;超出的部分由 SelectList 滚动。 */
|
|
106
|
+
const CHOICE_MENU_MAX_VISIBLE = 10;
|
|
107
|
+
|
|
108
|
+
/** provider 面板的行顺序:开关在最前,动作用特殊后缀区分。 */
|
|
109
|
+
const PROVIDER_ROWS: readonly {
|
|
110
|
+
/** 字段名。 */
|
|
111
|
+
id: ProviderFieldId;
|
|
112
|
+
/** 取值方式:开关原地切换,枚举开列表,文本开输入框,动作直接执行。 */
|
|
113
|
+
kind: "toggle" | "choice" | "text" | "action";
|
|
114
|
+
/** 标题文案 key。 */
|
|
115
|
+
labelKey: string;
|
|
116
|
+
/** 说明文案 key。 */
|
|
117
|
+
descriptionKey: string;
|
|
118
|
+
}[] = [
|
|
119
|
+
{ id: PROVIDER_FIELD.enabled, kind: "toggle", labelKey: "panelLabelEnabled", descriptionKey: "panelDescEnabled" },
|
|
120
|
+
{ id: PROVIDER_FIELD.name, kind: "text", labelKey: "panelLabelName", descriptionKey: "panelDescName" },
|
|
121
|
+
{ id: PROVIDER_FIELD.baseUrl, kind: "text", labelKey: "panelLabelBaseUrl", descriptionKey: "panelDescBaseUrl" },
|
|
122
|
+
{ id: PROVIDER_FIELD.api, kind: "choice", labelKey: "panelLabelApi", descriptionKey: "panelDescApi" },
|
|
123
|
+
{ id: PROVIDER_FIELD.apiKey, kind: "text", labelKey: "panelLabelApiKey", descriptionKey: "panelDescApiKey" },
|
|
124
|
+
{ id: PROVIDER_FIELD.rediscover, kind: "action", labelKey: "panelLabelRediscover", descriptionKey: "panelDescRediscover" },
|
|
125
|
+
{ id: PROVIDER_FIELD.remove, kind: "action", labelKey: "panelLabelRemove", descriptionKey: "panelDescRemove" },
|
|
126
|
+
];
|
|
127
|
+
|
|
128
|
+
/** 按配置值取展示文本;不在候选表里就直接显示原值。 */
|
|
129
|
+
export function optionLabelForValue(options: readonly PanelOption[], value: string): string {
|
|
130
|
+
return options.find((option) => option.value === value)?.label ?? value;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** 按展示文本反查写回值;找不到返回 undefined,由调用方保持不变。 */
|
|
134
|
+
export function optionValueFromLabel(
|
|
135
|
+
options: readonly PanelOption[],
|
|
136
|
+
label: string,
|
|
137
|
+
): string | undefined {
|
|
138
|
+
return options.find((option) => option.label === label)?.value;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** 开关候选项。 */
|
|
142
|
+
function toggleOptions(): PanelOption[] {
|
|
143
|
+
const labels = toggleLabels();
|
|
144
|
+
return [
|
|
145
|
+
{ label: labels.on, value: "on" },
|
|
146
|
+
{ label: labels.off, value: "off" },
|
|
147
|
+
];
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** api 类型候选项。 */
|
|
151
|
+
function apiOptions(): PanelOption[] {
|
|
152
|
+
return API_CHOICES.map((value) => ({ label: value, value }));
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** 某个字段的候选表;文本和动作字段没有候选项。 */
|
|
156
|
+
function fieldOptions(id: ProviderFieldId): PanelOption[] {
|
|
157
|
+
if (id === PROVIDER_FIELD.enabled) return toggleOptions();
|
|
158
|
+
if (id === PROVIDER_FIELD.api) return apiOptions();
|
|
159
|
+
return [];
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** 读取一个字段当前值的机器形态。 */
|
|
163
|
+
function readProviderField(provider: PanelProvider, id: ProviderFieldId): string {
|
|
164
|
+
switch (id) {
|
|
165
|
+
case PROVIDER_FIELD.enabled:
|
|
166
|
+
return provider.enabled ? "on" : "off";
|
|
167
|
+
case PROVIDER_FIELD.name:
|
|
168
|
+
return provider.name;
|
|
169
|
+
case PROVIDER_FIELD.baseUrl:
|
|
170
|
+
return provider.baseUrl;
|
|
171
|
+
case PROVIDER_FIELD.api:
|
|
172
|
+
return provider.api;
|
|
173
|
+
case PROVIDER_FIELD.apiKey:
|
|
174
|
+
return provider.apiKey;
|
|
175
|
+
default:
|
|
176
|
+
return "";
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** 面板上显示的当前值;文本字段留空时显示「未设置」。 */
|
|
181
|
+
function displayProviderValue(provider: PanelProvider, id: ProviderFieldId): string {
|
|
182
|
+
const stored = readProviderField(provider, id);
|
|
183
|
+
if (id === PROVIDER_FIELD.enabled) {
|
|
184
|
+
const labels = toggleLabels();
|
|
185
|
+
return stored === "on" ? labels.on : labels.off;
|
|
186
|
+
}
|
|
187
|
+
if (id === PROVIDER_FIELD.api) return optionLabelForValue(apiOptions(), stored);
|
|
188
|
+
if (id === PROVIDER_FIELD.apiKey || id === PROVIDER_FIELD.name) {
|
|
189
|
+
return stored || i18n.t("panelValueUnset");
|
|
190
|
+
}
|
|
191
|
+
return stored;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* 把面板回传的展示文本写回 provider。
|
|
196
|
+
*
|
|
197
|
+
* 返回 undefined 表示这一项不该由面板改(未知字段,或候选表里没有这个标签),
|
|
198
|
+
* 调用方保持原值。动作字段(rediscover/remove)不走这里,由面板单独处理。
|
|
199
|
+
*/
|
|
200
|
+
export function applyProviderChange(
|
|
201
|
+
provider: PanelProvider,
|
|
202
|
+
id: string,
|
|
203
|
+
value: string,
|
|
204
|
+
): PanelProvider | undefined {
|
|
205
|
+
const labels = toggleLabels();
|
|
206
|
+
switch (id) {
|
|
207
|
+
case PROVIDER_FIELD.enabled: {
|
|
208
|
+
if (value !== labels.on && value !== labels.off) return undefined;
|
|
209
|
+
return { ...provider, enabled: value === labels.on };
|
|
210
|
+
}
|
|
211
|
+
case PROVIDER_FIELD.name:
|
|
212
|
+
return { ...provider, name: value.trim() };
|
|
213
|
+
case PROVIDER_FIELD.baseUrl:
|
|
214
|
+
return { ...provider, baseUrl: value.trim() };
|
|
215
|
+
case PROVIDER_FIELD.api: {
|
|
216
|
+
const api = optionValueFromLabel(apiOptions(), value);
|
|
217
|
+
return api === undefined ? undefined : { ...provider, api };
|
|
218
|
+
}
|
|
219
|
+
case PROVIDER_FIELD.apiKey:
|
|
220
|
+
return { ...provider, apiKey: value.trim() };
|
|
221
|
+
default:
|
|
222
|
+
return undefined;
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/** 自由文本子菜单:一个预填当前值的输入框。 */
|
|
227
|
+
function createTextSubmenu(
|
|
228
|
+
value: string,
|
|
229
|
+
placeholder: string,
|
|
230
|
+
done: (value?: string) => void,
|
|
231
|
+
): Component {
|
|
232
|
+
const input = new Input({ prompt: `${i18n.t("panelInputPrompt")} `, placeholder });
|
|
233
|
+
input.setValue(value);
|
|
234
|
+
input.focused = true;
|
|
235
|
+
input.onSubmit = (submitted) => done(submitted);
|
|
236
|
+
input.onEscape = () => done(undefined);
|
|
237
|
+
return {
|
|
238
|
+
/** 一行可编辑文本。 */
|
|
239
|
+
render: (width) => input.render(width),
|
|
240
|
+
/** 无本地缓存。 */
|
|
241
|
+
invalidate: () => input.invalidate(),
|
|
242
|
+
/** 按键全部交给输入框。 */
|
|
243
|
+
handleInput: (data) => input.handleInput(data),
|
|
244
|
+
};
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/** 二级选择列表:Enter 选定并回传展示文本,Esc 不改动直接返回。 */
|
|
248
|
+
function createChoiceSubmenu(
|
|
249
|
+
options: readonly PanelOption[],
|
|
250
|
+
currentLabel: string,
|
|
251
|
+
done: (selectedLabel?: string) => void,
|
|
252
|
+
): Component {
|
|
253
|
+
const items: SelectItem[] = options.map((option) => ({ value: option.label, label: option.label }));
|
|
254
|
+
const list = new SelectList(
|
|
255
|
+
items,
|
|
256
|
+
Math.max(1, Math.min(items.length, CHOICE_MENU_MAX_VISIBLE)),
|
|
257
|
+
getSelectListTheme(),
|
|
258
|
+
);
|
|
259
|
+
const currentIndex = options.findIndex((option) => option.label === currentLabel);
|
|
260
|
+
if (currentIndex >= 0) list.setSelectedIndex(currentIndex);
|
|
261
|
+
list.onSelect = (item) => done(item.value);
|
|
262
|
+
list.onCancel = () => done(undefined);
|
|
263
|
+
return {
|
|
264
|
+
/** 直接渲染候选列表。 */
|
|
265
|
+
render: (width) => list.render(width),
|
|
266
|
+
/** 无本地缓存。 */
|
|
267
|
+
invalidate: () => list.invalidate(),
|
|
268
|
+
/** 按键全部交给列表。 */
|
|
269
|
+
handleInput: (data) => list.handleInput(data),
|
|
270
|
+
};
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/** provider 面板行的动作钩子:由入口注入,负责真正执行发现或删除。 */
|
|
274
|
+
export interface ProviderRowActions {
|
|
275
|
+
/** 立刻重新发现该 provider 的模型。 */
|
|
276
|
+
rediscover(provider: PanelProvider): Promise<void>;
|
|
277
|
+
/** 删除该 provider;返回 true 表示已删除,面板回到顶层。 */
|
|
278
|
+
remove(provider: PanelProvider): Promise<boolean>;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* 把一行变成「Enter 就触发」的动作行。
|
|
283
|
+
*
|
|
284
|
+
* SettingsList 只在有 submenu 或 values 时才会触发 onChange,所以动作行给一个只有单值的
|
|
285
|
+
* values:Enter/空格 会把同一个值再报一次,等于「按下执行」,而不会改变任何配置。
|
|
286
|
+
*/
|
|
287
|
+
function actionRow(item: SettingItem): SettingItem {
|
|
288
|
+
return { ...item, values: [item.currentValue] };
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/** 构造 provider 层的设置行。 */
|
|
292
|
+
export function buildProviderItems(provider: PanelProvider): SettingItem[] {
|
|
293
|
+
return PROVIDER_ROWS.map((row) => {
|
|
294
|
+
const label = i18n.t(row.labelKey);
|
|
295
|
+
const description = i18n.t(row.descriptionKey);
|
|
296
|
+
const currentValue = displayProviderValue(provider, row.id);
|
|
297
|
+
if (row.kind === "toggle") {
|
|
298
|
+
return {
|
|
299
|
+
id: row.id,
|
|
300
|
+
label,
|
|
301
|
+
description,
|
|
302
|
+
currentValue,
|
|
303
|
+
values: toggleOptions().map((option) => option.label),
|
|
304
|
+
};
|
|
305
|
+
}
|
|
306
|
+
if (row.kind === "choice") {
|
|
307
|
+
return {
|
|
308
|
+
id: row.id,
|
|
309
|
+
label,
|
|
310
|
+
description,
|
|
311
|
+
currentValue,
|
|
312
|
+
// 二级列表接管 Enter,避免在少量候选之间反复循环。
|
|
313
|
+
submenu: (current, done) =>
|
|
314
|
+
createChoiceSubmenu(apiOptions(), current, done),
|
|
315
|
+
};
|
|
316
|
+
}
|
|
317
|
+
if (row.kind === "text") {
|
|
318
|
+
return {
|
|
319
|
+
id: row.id,
|
|
320
|
+
label,
|
|
321
|
+
description,
|
|
322
|
+
currentValue,
|
|
323
|
+
submenu: (_current, done) =>
|
|
324
|
+
createTextSubmenu(readProviderField(provider, row.id), description, done),
|
|
325
|
+
};
|
|
326
|
+
}
|
|
327
|
+
return actionRow({ id: row.id, label, description, currentValue: i18n.t("panelActionHint") });
|
|
328
|
+
});
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/** 构造顶层的设置行:一个 provider 一行,外加「添加 provider」。 */
|
|
332
|
+
export function buildRootItems(providers: readonly PanelProvider[]): SettingItem[] {
|
|
333
|
+
const items: SettingItem[] = providers.map((provider) => actionRow({
|
|
334
|
+
id: `provider:${provider.id}`,
|
|
335
|
+
label: `${provider.enabled ? "●" : "○"} ${provider.id}`,
|
|
336
|
+
description: `${provider.baseUrl || "?"} · ${provider.api || "?"}`,
|
|
337
|
+
currentValue: provider.name || provider.id,
|
|
338
|
+
}));
|
|
339
|
+
items.push(actionRow({
|
|
340
|
+
id: ROOT_FIELD.add,
|
|
341
|
+
label: i18n.t("panelLabelAdd"),
|
|
342
|
+
description: i18n.t("panelDescAdd"),
|
|
343
|
+
currentValue: i18n.t("panelActionHint"),
|
|
344
|
+
}));
|
|
345
|
+
return items;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/** 面板与宿主之间的接口;provider 列表、写回动作都由入口提供。 */
|
|
349
|
+
export interface PanelState {
|
|
350
|
+
/** 当前所有带发现标记的 provider;每次现读,因为增删会改变列表。 */
|
|
351
|
+
getProviders(): PanelProvider[];
|
|
352
|
+
/** provider 配置被改动后调用;由调用方负责落盘与注册。 */
|
|
353
|
+
onProviderChange(provider: PanelProvider): void;
|
|
354
|
+
/** provider 被删除后调用。 */
|
|
355
|
+
onProviderRemoved(id: string): void;
|
|
356
|
+
/** 新增 provider;由调用方负责落盘与首次发现。 */
|
|
357
|
+
onProviderAdd(provider: PanelProvider): void;
|
|
358
|
+
/** 行动作。 */
|
|
359
|
+
actions: ProviderRowActions;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
/** 顶层列表:选中 provider 进第二层,选中「添加」走新增流程。 */
|
|
363
|
+
async function openProviderPanel(
|
|
364
|
+
ctx: ExtensionCommandContext,
|
|
365
|
+
state: PanelState,
|
|
366
|
+
provider: PanelProvider,
|
|
367
|
+
): Promise<"back" | "removed"> {
|
|
368
|
+
/** 当前这一层的 provider 副本;改动即时写回内存。 */
|
|
369
|
+
let current: PanelProvider = { ...provider };
|
|
370
|
+
/** 用户在子菜单里选择的动作结果。 */
|
|
371
|
+
let pending: { kind: "rediscover" } | { kind: "remove" } | undefined;
|
|
372
|
+
|
|
373
|
+
const settingsList = await ctx.ui.custom<string | undefined>((tui, theme, _keybindings, done) => {
|
|
374
|
+
const container = new Container();
|
|
375
|
+
container.addChild(
|
|
376
|
+
new Text(theme.fg("accent", theme.bold(i18n.t("panelProviderTitle", { id: current.id }))), 1, 1),
|
|
377
|
+
);
|
|
378
|
+
const list = new SettingsList(
|
|
379
|
+
buildProviderItems(current),
|
|
380
|
+
PROVIDER_ROWS.length,
|
|
381
|
+
getSettingsListTheme(),
|
|
382
|
+
(id, newValue) => {
|
|
383
|
+
if (id === PROVIDER_FIELD.rediscover) {
|
|
384
|
+
pending = { kind: "rediscover" };
|
|
385
|
+
done(undefined);
|
|
386
|
+
return;
|
|
387
|
+
}
|
|
388
|
+
if (id === PROVIDER_FIELD.remove) {
|
|
389
|
+
pending = { kind: "remove" };
|
|
390
|
+
done(undefined);
|
|
391
|
+
return;
|
|
392
|
+
}
|
|
393
|
+
const next = applyProviderChange(current, id, newValue);
|
|
394
|
+
if (!next) return;
|
|
395
|
+
current = next;
|
|
396
|
+
state.onProviderChange(current);
|
|
397
|
+
list.updateValue(id, displayProviderValue(current, id as ProviderFieldId));
|
|
398
|
+
},
|
|
399
|
+
() => done(undefined),
|
|
400
|
+
);
|
|
401
|
+
container.addChild(list);
|
|
402
|
+
return {
|
|
403
|
+
/** 布局交给 Container。 */
|
|
404
|
+
render: (width) => container.render(width),
|
|
405
|
+
/** 无本地缓存。 */
|
|
406
|
+
invalidate: () => container.invalidate(),
|
|
407
|
+
/** 按键交给列表,并触发一次重绘。 */
|
|
408
|
+
handleInput: (data) => {
|
|
409
|
+
list.handleInput(data);
|
|
410
|
+
tui.requestRender();
|
|
411
|
+
},
|
|
412
|
+
};
|
|
413
|
+
});
|
|
414
|
+
|
|
415
|
+
if (pending?.kind === "rediscover") {
|
|
416
|
+
await state.actions.rediscover(current);
|
|
417
|
+
return openProviderPanel(ctx, state, current);
|
|
418
|
+
}
|
|
419
|
+
if (pending?.kind === "remove") {
|
|
420
|
+
const confirmed = await ctx.ui.confirm(
|
|
421
|
+
i18n.t("panelConfirmRemove", { id: current.id }),
|
|
422
|
+
i18n.t("panelRemoveMessage"),
|
|
423
|
+
);
|
|
424
|
+
if (confirmed && (await state.actions.remove(current))) {
|
|
425
|
+
state.onProviderRemoved(current.id);
|
|
426
|
+
return "removed";
|
|
427
|
+
}
|
|
428
|
+
return openProviderPanel(ctx, state, current);
|
|
429
|
+
}
|
|
430
|
+
return "back";
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
/** 新增 provider 的表单结果。 */
|
|
434
|
+
interface NewProviderInput {
|
|
435
|
+
/** provider id。 */
|
|
436
|
+
id: string;
|
|
437
|
+
/** baseUrl。 */
|
|
438
|
+
baseUrl: string;
|
|
439
|
+
/** api 类型。 */
|
|
440
|
+
api: string;
|
|
441
|
+
/** apiKey。 */
|
|
442
|
+
apiKey: string;
|
|
443
|
+
/** 显示名。 */
|
|
444
|
+
name: string;
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
/** 提示并校验一个值;返回 undefined 表示用户取消或校验失败(失败已提示)。 */
|
|
448
|
+
async function askValidated(
|
|
449
|
+
ctx: ExtensionCommandContext,
|
|
450
|
+
options: {
|
|
451
|
+
/** 输入框标题。 */
|
|
452
|
+
title: string;
|
|
453
|
+
/** 预填值。 */
|
|
454
|
+
initial?: string;
|
|
455
|
+
/** 校验函数;通过返回 undefined,失败返回错误文案 key 的参数。 */
|
|
456
|
+
validate(value: string): { key: string; params?: Record<string, string> } | undefined;
|
|
457
|
+
},
|
|
458
|
+
): Promise<string | undefined> {
|
|
459
|
+
const value = (await ctx.ui.input(options.title, options.initial))?.trim();
|
|
460
|
+
if (value === undefined) return undefined;
|
|
461
|
+
if (!value) return undefined;
|
|
462
|
+
const failure = options.validate(value);
|
|
463
|
+
if (failure) {
|
|
464
|
+
ctx.ui.notify(i18n.t(failure.key, failure.params), "error");
|
|
465
|
+
return undefined;
|
|
466
|
+
}
|
|
467
|
+
return value;
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
/** 逐项问出新增 provider 需要的字段;任一项取消就整体放弃。 */
|
|
471
|
+
async function collectNewProvider(
|
|
472
|
+
ctx: ExtensionCommandContext,
|
|
473
|
+
existingIds: ReadonlySet<string>,
|
|
474
|
+
): Promise<NewProviderInput | undefined> {
|
|
475
|
+
const id = await askValidated(ctx, {
|
|
476
|
+
title: i18n.t("providerId"),
|
|
477
|
+
validate: (value) => {
|
|
478
|
+
if (!/^[a-z0-9][a-z0-9-]*$/i.test(value)) return { key: "invalidId" };
|
|
479
|
+
if (existingIds.has(value)) return { key: "exists", params: { id: value } };
|
|
480
|
+
return undefined;
|
|
481
|
+
},
|
|
482
|
+
});
|
|
483
|
+
if (!id) return undefined;
|
|
484
|
+
const baseUrl = await askValidated(ctx, {
|
|
485
|
+
title: i18n.t("baseUrl"),
|
|
486
|
+
validate: (value) => (/^https?:\/\//.test(value) ? undefined : { key: "invalidUrl" }),
|
|
487
|
+
});
|
|
488
|
+
if (!baseUrl) return undefined;
|
|
489
|
+
const api = await ctx.ui.select(i18n.t("api"), [...API_CHOICES]);
|
|
490
|
+
if (!api) return undefined;
|
|
491
|
+
const apiKey = (await ctx.ui.input(i18n.t("apiKey")))?.trim() ?? "";
|
|
492
|
+
const name = (await ctx.ui.input(i18n.t("displayName", { id })))?.trim() ?? "";
|
|
493
|
+
return { id, baseUrl, api, apiKey, name };
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
/** 打开 /config:model-discovery 面板;Esc 关闭,改动已经即时写回。 */
|
|
497
|
+
export async function openDiscoveryPanel(
|
|
498
|
+
ctx: ExtensionCommandContext,
|
|
499
|
+
state: PanelState,
|
|
500
|
+
): Promise<void> {
|
|
501
|
+
while (true) {
|
|
502
|
+
const providers = state.getProviders();
|
|
503
|
+
const selection = await ctx.ui.custom<string | undefined>((tui, theme, _keybindings, done) => {
|
|
504
|
+
const container = new Container();
|
|
505
|
+
container.addChild(
|
|
506
|
+
new Text(theme.fg("accent", theme.bold(i18n.t("panelTitle", { count: providers.length }))), 1, 1),
|
|
507
|
+
);
|
|
508
|
+
const items = buildRootItems(providers);
|
|
509
|
+
const list = new SettingsList(
|
|
510
|
+
items,
|
|
511
|
+
Math.max(1, items.length),
|
|
512
|
+
getSettingsListTheme(),
|
|
513
|
+
(id) => done(id),
|
|
514
|
+
() => done(undefined),
|
|
515
|
+
);
|
|
516
|
+
container.addChild(list);
|
|
517
|
+
return {
|
|
518
|
+
/** 布局交给 Container。 */
|
|
519
|
+
render: (width) => container.render(width),
|
|
520
|
+
/** 无本地缓存。 */
|
|
521
|
+
invalidate: () => container.invalidate(),
|
|
522
|
+
/** 按键交给列表,并触发一次重绘。 */
|
|
523
|
+
handleInput: (data) => {
|
|
524
|
+
list.handleInput(data);
|
|
525
|
+
tui.requestRender();
|
|
526
|
+
},
|
|
527
|
+
};
|
|
528
|
+
});
|
|
529
|
+
|
|
530
|
+
if (!selection) return;
|
|
531
|
+
if (selection === ROOT_FIELD.add) {
|
|
532
|
+
const existingIds = new Set(state.getProviders().map((provider) => provider.id));
|
|
533
|
+
const input = await collectNewProvider(ctx, existingIds);
|
|
534
|
+
if (!input) continue;
|
|
535
|
+
const provider: PanelProvider = {
|
|
536
|
+
id: input.id,
|
|
537
|
+
baseUrl: input.baseUrl,
|
|
538
|
+
api: input.api,
|
|
539
|
+
apiKey: input.apiKey,
|
|
540
|
+
name: input.name,
|
|
541
|
+
enabled: true,
|
|
542
|
+
};
|
|
543
|
+
state.onProviderAdd(provider);
|
|
544
|
+
continue;
|
|
545
|
+
}
|
|
546
|
+
if (!selection.startsWith("provider:")) continue;
|
|
547
|
+
const id = selection.slice("provider:".length);
|
|
548
|
+
const provider = state.getProviders().find((candidate) => candidate.id === id);
|
|
549
|
+
if (!provider) continue;
|
|
550
|
+
const result = await openProviderPanel(ctx, state, provider);
|
|
551
|
+
if (result === "removed") continue;
|
|
552
|
+
}
|
|
553
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -35,9 +35,8 @@
|
|
|
35
35
|
* - 注册 refreshModels:打开 /model 触发在线刷新时重新发现;
|
|
36
36
|
* 离线初始化(allowNetwork=false)返回上次成功列表(含缓存),尚无成功记录时抛错,
|
|
37
37
|
* 以免空列表清掉 models.json 手写 models。
|
|
38
|
-
* -
|
|
39
|
-
*
|
|
40
|
-
* 上游不认这些值时用 models.json 的 provider.modelOverrides 按 model.id 覆盖。
|
|
38
|
+
* - 发现模型默认隐藏 xhigh/max;provider.compat.thinkingLevelMap 可显式开启或覆盖档位,
|
|
39
|
+
* provider.compat.reasoning 可关闭推理;逐模型差异用 provider.modelOverrides 覆盖。
|
|
41
40
|
* - 发现请求的 apiKey 解析仅支持字面量与 $ENV_VAR/${ENV_VAR} 插值;
|
|
42
41
|
* "!command" 形式的 apiKey 跳过发现(显式警告),pi 发起聊天请求时仍由 pi 自身解析。
|
|
43
42
|
* - /config:model-discovery 命令对 models.json 的修改立即生效(registerProvider 运行时可直接调用);
|
|
@@ -52,6 +51,7 @@ import type { ExtensionAPI, ProviderModelConfig } from "@earendil-works/pi-codin
|
|
|
52
51
|
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
53
52
|
import { dirname, join } from "node:path";
|
|
54
53
|
import { NOTICE_TAG_COLOR, createTranslator, installNoticeRenderer, loadCatalog, notifyWithSource, type NoticeColor, type NoticeSource } from "pi-extensions-i18n";
|
|
54
|
+
import { openDiscoveryPanel, type PanelProvider } from "./config-panel.ts";
|
|
55
55
|
|
|
56
56
|
const i18n = createTranslator(loadCatalog(new URL("../locales/index.json", import.meta.url)));
|
|
57
57
|
|
|
@@ -64,7 +64,7 @@ const NOTICE_TAG = "models";
|
|
|
64
64
|
const NOTICE_COLOR: NoticeColor = NOTICE_TAG_COLOR;
|
|
65
65
|
/** 本扩展的提示来源。 */
|
|
66
66
|
const NOTICE_SOURCE: NoticeSource = { tag: NOTICE_TAG, color: NOTICE_COLOR };
|
|
67
|
-
|
|
67
|
+
|
|
68
68
|
|
|
69
69
|
/** 用户可见消息(级别与 ctx.ui.notify 的 type 对齐);供通知中继与无 ctx 的收集队列共用 */
|
|
70
70
|
interface Notice {
|
|
@@ -85,6 +85,8 @@ interface DiscoveryProviderEntry {
|
|
|
85
85
|
api?: string;
|
|
86
86
|
headers?: Record<string, string>;
|
|
87
87
|
compat?: Record<string, unknown>;
|
|
88
|
+
/** 发现标记;面板关闭发现时写 false(实际会删掉这个键)。 */
|
|
89
|
+
discoverModels?: boolean;
|
|
88
90
|
}
|
|
89
91
|
|
|
90
92
|
interface ModelsResponse {
|
|
@@ -93,6 +95,8 @@ interface ModelsResponse {
|
|
|
93
95
|
name?: string;
|
|
94
96
|
context_window?: number;
|
|
95
97
|
contextWindow?: number;
|
|
98
|
+
/** vLLM reports the served context window here. */
|
|
99
|
+
max_model_len?: number;
|
|
96
100
|
max_tokens?: number;
|
|
97
101
|
maxTokens?: number;
|
|
98
102
|
}>;
|
|
@@ -295,6 +299,21 @@ function resolveEnvValue(raw: string): { value: string | null; error: string | n
|
|
|
295
299
|
return { value, error: null };
|
|
296
300
|
}
|
|
297
301
|
|
|
302
|
+
/** 扩展专用的推理默认值从 compat 提取,不作为请求兼容字段转发。 */
|
|
303
|
+
function modelThinking(providerCompat: Record<string, unknown> | undefined) {
|
|
304
|
+
const { reasoning, thinkingLevelMap, ...compat } = providerCompat ?? {};
|
|
305
|
+
return {
|
|
306
|
+
reasoning: typeof reasoning === "boolean" ? reasoning : true,
|
|
307
|
+
thinkingLevelMap: {
|
|
308
|
+
xhigh: null,
|
|
309
|
+
max: null,
|
|
310
|
+
...(thinkingLevelMap && typeof thinkingLevelMap === "object" && !Array.isArray(thinkingLevelMap)
|
|
311
|
+
? thinkingLevelMap as ProviderModelConfig["thinkingLevelMap"] : {}),
|
|
312
|
+
},
|
|
313
|
+
compat: { supportsDeveloperRole: false, ...compat },
|
|
314
|
+
};
|
|
315
|
+
}
|
|
316
|
+
|
|
298
317
|
export function buildModel(
|
|
299
318
|
id: string,
|
|
300
319
|
name: string | undefined,
|
|
@@ -305,18 +324,11 @@ export function buildModel(
|
|
|
305
324
|
return {
|
|
306
325
|
id,
|
|
307
326
|
name: name ?? id,
|
|
308
|
-
|
|
309
|
-
// xhigh/max 必须给非 null 值才会出现在 /thinking(缺省键等同不支持);
|
|
310
|
-
// 标准档位保持缺省,仍走 pi 的 provider 默认映射,语义不变。
|
|
311
|
-
thinkingLevelMap: { xhigh: "xhigh", max: "max" },
|
|
327
|
+
...modelThinking(providerCompat),
|
|
312
328
|
input: ["text", "image"],
|
|
313
329
|
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
|
314
330
|
contextWindow: contextWindow ?? 1_000_000,
|
|
315
331
|
maxTokens: maxTokens ?? 65_536,
|
|
316
|
-
compat: {
|
|
317
|
-
supportsDeveloperRole: false,
|
|
318
|
-
...providerCompat,
|
|
319
|
-
},
|
|
320
332
|
};
|
|
321
333
|
}
|
|
322
334
|
|
|
@@ -372,7 +384,7 @@ async function fetchModels(
|
|
|
372
384
|
buildModel(
|
|
373
385
|
m.id,
|
|
374
386
|
m.name,
|
|
375
|
-
m.context_window ?? m.contextWindow,
|
|
387
|
+
m.context_window ?? m.contextWindow ?? m.max_model_len,
|
|
376
388
|
m.max_tokens ?? m.maxTokens,
|
|
377
389
|
entry.compat,
|
|
378
390
|
),
|
|
@@ -483,6 +495,8 @@ function registerFromCache(
|
|
|
483
495
|
notices: Notice[],
|
|
484
496
|
): void {
|
|
485
497
|
if (!validateEntry(entry, notices)) return;
|
|
498
|
+
// 旧缓存含硬编码的 xhigh/max,启动时按当前 provider 配置重建,保持离线可用。
|
|
499
|
+
models = models.map((model) => ({ ...model, ...modelThinking(entry.compat) }));
|
|
486
500
|
const state: LastModelsState = { lastModels: models };
|
|
487
501
|
const refreshModels = createRefreshModels(entry, notices, state);
|
|
488
502
|
pi.registerProvider(entry.id, {
|
|
@@ -493,41 +507,220 @@ function registerFromCache(
|
|
|
493
507
|
});
|
|
494
508
|
}
|
|
495
509
|
|
|
510
|
+
/**
|
|
511
|
+
* 把带 discoverModels 标记的 provider 转成面板读的形态。
|
|
512
|
+
* 面板不直接碰磁盘,改完由 persistProvider 写回 models.json。
|
|
513
|
+
*/
|
|
514
|
+
function toPanelProvider(entry: DiscoveryProviderEntry): PanelProvider {
|
|
515
|
+
return {
|
|
516
|
+
id: entry.id,
|
|
517
|
+
baseUrl: entry.baseUrl ?? "",
|
|
518
|
+
api: entry.api ?? "",
|
|
519
|
+
apiKey: entry.apiKey ?? "",
|
|
520
|
+
name: entry.name ?? "",
|
|
521
|
+
enabled: true,
|
|
522
|
+
};
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
/** 读取当前 models.json 里所有带发现标记的 provider,供面板渲染。 */
|
|
526
|
+
async function readPanelProviders(): Promise<{ providers: PanelProvider[]; error: string | null }> {
|
|
527
|
+
const { data, error } = await readModelsFile();
|
|
528
|
+
if (error) return { providers: [], error };
|
|
529
|
+
return { providers: pickDiscoveryProviders(data).map(toPanelProvider), error: null };
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
/**
|
|
533
|
+
* 面板改一个 provider 的字段后写回 models.json。
|
|
534
|
+
* 沿用原条目的其余字段(headers/compat 等面板不管的键),只覆盖面板能改的四项。
|
|
535
|
+
*/
|
|
536
|
+
async function persistProvider(provider: PanelProvider): Promise<{ backup: string }> {
|
|
537
|
+
const { data, error } = await readModelsFile();
|
|
538
|
+
if (error) throw new Error(error);
|
|
539
|
+
const providers = (data.providers ?? {}) as Record<string, Record<string, unknown>>;
|
|
540
|
+
const existing = providers[provider.id];
|
|
541
|
+
if (!existing) throw new Error(i18n.t("panelProviderMissing", { id: provider.id }));
|
|
542
|
+
const entry: Record<string, unknown> = { ...existing, baseUrl: provider.baseUrl, api: provider.api };
|
|
543
|
+
if (provider.enabled) entry[DISCOVERY_MARKER] = true;
|
|
544
|
+
else delete entry[DISCOVERY_MARKER];
|
|
545
|
+
if (provider.apiKey) entry.apiKey = provider.apiKey;
|
|
546
|
+
else delete entry.apiKey;
|
|
547
|
+
if (provider.name) entry.name = provider.name;
|
|
548
|
+
else delete entry.name;
|
|
549
|
+
providers[provider.id] = entry;
|
|
550
|
+
data.providers = providers;
|
|
551
|
+
return writeModelsFile(data);
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
/** 新增 provider 时写入 models.json 的条目。 */
|
|
555
|
+
async function persistNewProvider(provider: PanelProvider): Promise<{ backup: string }> {
|
|
556
|
+
const { data, error } = await readModelsFile();
|
|
557
|
+
if (error) throw new Error(error);
|
|
558
|
+
const providers = (data.providers ?? {}) as Record<string, Record<string, unknown>>;
|
|
559
|
+
const entry: Record<string, unknown> = {
|
|
560
|
+
baseUrl: provider.baseUrl,
|
|
561
|
+
api: provider.api,
|
|
562
|
+
[DISCOVERY_MARKER]: true,
|
|
563
|
+
};
|
|
564
|
+
if (provider.apiKey) entry.apiKey = provider.apiKey;
|
|
565
|
+
if (provider.name) entry.name = provider.name;
|
|
566
|
+
providers[provider.id] = entry;
|
|
567
|
+
data.providers = providers;
|
|
568
|
+
return writeModelsFile(data);
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
/** 从 models.json 删掉一个 provider 条目。 */
|
|
572
|
+
async function deleteProvider(id: string): Promise<{ backup: string }> {
|
|
573
|
+
const { data, error } = await readModelsFile();
|
|
574
|
+
if (error) throw new Error(error);
|
|
575
|
+
const providers = (data.providers ?? {}) as Record<string, Record<string, unknown>>;
|
|
576
|
+
delete providers[id];
|
|
577
|
+
data.providers = providers;
|
|
578
|
+
return writeModelsFile(data);
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
/**
|
|
582
|
+
* 用 models.json 当前内容重新注册一个 provider,行为与启动期一致:
|
|
583
|
+
* 指纹命中缓存就直接注册缓存里的模型,不请求网络;否则走在线发现。
|
|
584
|
+
*/
|
|
585
|
+
async function reloadProvider(options: {
|
|
586
|
+
/** 扩展 API,用来注册或注销 provider。 */
|
|
587
|
+
pi: ExtensionAPI;
|
|
588
|
+
/** models.json 中读到的 provider 条目。 */
|
|
589
|
+
entry: DiscoveryProviderEntry;
|
|
590
|
+
/** 本次会话共享的 /models 请求缓存。 */
|
|
591
|
+
fetchCache: FetchCache;
|
|
592
|
+
/** 收集过程中产生的提示,由调用方决定发给谁。 */
|
|
593
|
+
notices: Notice[];
|
|
594
|
+
}): Promise<void> {
|
|
595
|
+
const { pi, entry, fetchCache, notices } = options;
|
|
596
|
+
if (entry.discoverModels === false) {
|
|
597
|
+
pi.unregisterProvider(entry.id);
|
|
598
|
+
return;
|
|
599
|
+
}
|
|
600
|
+
const cache = await readCache(notices);
|
|
601
|
+
const cached = cache.providers[entry.id];
|
|
602
|
+
if (
|
|
603
|
+
cached &&
|
|
604
|
+
cached.fingerprint === providerFingerprint(entry) &&
|
|
605
|
+
Array.isArray(cached.models) &&
|
|
606
|
+
cached.models.length > 0
|
|
607
|
+
) {
|
|
608
|
+
registerFromCache(pi, entry, cached.models, notices);
|
|
609
|
+
return;
|
|
610
|
+
}
|
|
611
|
+
await discoverAndRegister(pi, entry, fetchCache, notices);
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
/** 按 id 从 models.json 现读一个 provider 条目;没有发现标记时返回 undefined。 */
|
|
615
|
+
async function readProviderEntry(id: string): Promise<DiscoveryProviderEntry | undefined> {
|
|
616
|
+
const { data } = await readModelsFile();
|
|
617
|
+
return pickDiscoveryProviders(data).find((entry) => entry.id === id);
|
|
618
|
+
}
|
|
619
|
+
|
|
496
620
|
/** /config:model-discovery 交互式配置命令;旧名称保留为兼容别名。 */
|
|
497
621
|
function registerDiscoveryCommand(pi: ExtensionAPI, fetchCache: FetchCache, sink: NoticeSink) {
|
|
622
|
+
/** 面板读 provider 列表、写回 models.json,并把改动即时注册到运行期。 */
|
|
623
|
+
const openPanel = async (ctx: CommandCtx): Promise<void> => {
|
|
624
|
+
const first = await readPanelProviders();
|
|
625
|
+
if (first.error) {
|
|
626
|
+
sink({ level: "error", message: `${LOG_PREFIX} ${first.error}` }, ctx);
|
|
627
|
+
return;
|
|
628
|
+
}
|
|
629
|
+
let providers = first.providers;
|
|
630
|
+
/**
|
|
631
|
+
* 变更队列。
|
|
632
|
+
*
|
|
633
|
+
* SettingsList 的 onChange 是同步回调,无法 await,所以写盘+重新注册按顺序排成一条链;
|
|
634
|
+
* 面板关闭后等这条链清空,保证最后一次改动已落盘并已注册(不静默丢改动)。
|
|
635
|
+
*/
|
|
636
|
+
let pendingChange: Promise<void> = Promise.resolve();
|
|
637
|
+
/** 把一个变更追加到队尾,保证先后顺序与面板操作一致。 */
|
|
638
|
+
const enqueue = (change: () => Promise<void>): void => {
|
|
639
|
+
pendingChange = pendingChange.then(change);
|
|
640
|
+
};
|
|
641
|
+
await openDiscoveryPanel(ctx, {
|
|
642
|
+
getProviders: () => providers,
|
|
643
|
+
onProviderChange: (provider) => {
|
|
644
|
+
// 面板下一次读到的必须是刚改过的值,所以内存副本同步更新,落盘排队跟上。
|
|
645
|
+
providers = providers.map((current) => (current.id === provider.id ? provider : current));
|
|
646
|
+
enqueue(async () => {
|
|
647
|
+
const notices: Notice[] = [];
|
|
648
|
+
try {
|
|
649
|
+
await persistProvider(provider);
|
|
650
|
+
} catch (err) {
|
|
651
|
+
flushNotices(ctx, [{
|
|
652
|
+
level: "error",
|
|
653
|
+
message: `${LOG_PREFIX} ${err instanceof Error ? err.message : String(err)}`,
|
|
654
|
+
}]);
|
|
655
|
+
return;
|
|
656
|
+
}
|
|
657
|
+
const entry = await readProviderEntry(provider.id);
|
|
658
|
+
if (!entry) {
|
|
659
|
+
pi.unregisterProvider(provider.id);
|
|
660
|
+
} else {
|
|
661
|
+
await reloadProvider({ pi, entry, fetchCache, notices });
|
|
662
|
+
}
|
|
663
|
+
// 面板行上的当前值来自本地副本,网络提示照常发出来,不静默降级。
|
|
664
|
+
flushNotices(ctx, notices);
|
|
665
|
+
});
|
|
666
|
+
},
|
|
667
|
+
onProviderRemoved: (id) => {
|
|
668
|
+
providers = providers.filter((provider) => provider.id !== id);
|
|
669
|
+
},
|
|
670
|
+
onProviderAdd: (provider) => {
|
|
671
|
+
providers = [...providers, provider];
|
|
672
|
+
enqueue(() => addProviderFromPanel({ pi, ctx, provider, fetchCache, sink }));
|
|
673
|
+
},
|
|
674
|
+
actions: {
|
|
675
|
+
rediscover: (provider) => {
|
|
676
|
+
// 也走同一条队列:保证“改字段”与“立即重新发现”不会交叠写盘。
|
|
677
|
+
enqueue(async () => {
|
|
678
|
+
const entry = await readProviderEntry(provider.id) ?? {
|
|
679
|
+
id: provider.id,
|
|
680
|
+
baseUrl: provider.baseUrl,
|
|
681
|
+
api: provider.api,
|
|
682
|
+
apiKey: provider.apiKey || undefined,
|
|
683
|
+
name: provider.name || undefined,
|
|
684
|
+
};
|
|
685
|
+
const notices: Notice[] = [];
|
|
686
|
+
const result = await discoverAndRegister(pi, entry, fetchCache, notices);
|
|
687
|
+
flushNotices(ctx, notices);
|
|
688
|
+
if (result) {
|
|
689
|
+
sink({ level: "info", message: i18n.t("rediscovered", { id: provider.id, count: result.count }) }, ctx);
|
|
690
|
+
}
|
|
691
|
+
});
|
|
692
|
+
return pendingChange;
|
|
693
|
+
},
|
|
694
|
+
remove: async (provider) => {
|
|
695
|
+
/** 删除结果;由队列里的那段代码写入。 */
|
|
696
|
+
let removed = false;
|
|
697
|
+
enqueue(async () => {
|
|
698
|
+
try {
|
|
699
|
+
const { backup } = await deleteProvider(provider.id);
|
|
700
|
+
pi.unregisterProvider(provider.id);
|
|
701
|
+
const notices: Notice[] = [];
|
|
702
|
+
await removeCachedModels(provider.id, notices);
|
|
703
|
+
flushNotices(ctx, notices);
|
|
704
|
+
sink({ level: "info", message: i18n.t("removed", { id: provider.id, backup }) }, ctx);
|
|
705
|
+
removed = true;
|
|
706
|
+
} catch (err) {
|
|
707
|
+
sink({ level: "error", message: `${LOG_PREFIX} ${err instanceof Error ? err.message : String(err)}` }, ctx);
|
|
708
|
+
}
|
|
709
|
+
});
|
|
710
|
+
await pendingChange;
|
|
711
|
+
return removed;
|
|
712
|
+
},
|
|
713
|
+
},
|
|
714
|
+
});
|
|
715
|
+
// 面板已关,但队列里可能还有写盘与重新注册;等它们完成再返回。
|
|
716
|
+
await pendingChange;
|
|
717
|
+
};
|
|
718
|
+
|
|
498
719
|
const command = {
|
|
499
720
|
description: i18n.t("commandDescription"),
|
|
500
721
|
handler: async (_args, ctx) => {
|
|
501
722
|
if (!ctx.hasUI) return;
|
|
502
|
-
|
|
503
|
-
const { data, error } = await readModelsFile();
|
|
504
|
-
if (error) {
|
|
505
|
-
sink({ level: "error", message: `${LOG_PREFIX} ${error}` }, ctx);
|
|
506
|
-
return;
|
|
507
|
-
}
|
|
508
|
-
const providers = pickDiscoveryProviders(data);
|
|
509
|
-
const ADD = i18n.t("add");
|
|
510
|
-
const EXIT = i18n.t("exit");
|
|
511
|
-
const choices = [
|
|
512
|
-
...providers.map((p) => `${p.id} — ${p.baseUrl ?? "?"}(${p.api ?? "?"})`),
|
|
513
|
-
ADD,
|
|
514
|
-
EXIT,
|
|
515
|
-
];
|
|
516
|
-
const choice = await ctx.ui.select(
|
|
517
|
-
providers.length > 0 ? i18n.t("title", { count: providers.length }) : i18n.t("emptyTitle"),
|
|
518
|
-
choices,
|
|
519
|
-
);
|
|
520
|
-
if (choice === undefined || choice === EXIT) return;
|
|
521
|
-
|
|
522
|
-
if (choice === ADD) {
|
|
523
|
-
await addProviderFlow(pi, ctx, data, fetchCache, sink);
|
|
524
|
-
continue;
|
|
525
|
-
}
|
|
526
|
-
const selected = providers[choices.indexOf(choice)];
|
|
527
|
-
if (selected) {
|
|
528
|
-
await manageProviderFlow(pi, ctx, data, selected, fetchCache, sink);
|
|
529
|
-
}
|
|
530
|
-
}
|
|
723
|
+
await openPanel(ctx);
|
|
531
724
|
},
|
|
532
725
|
};
|
|
533
726
|
for (const name of ["config:model-discovery", "model-discovery", "pi-model-discovery"] as const) {
|
|
@@ -590,104 +783,45 @@ function flushNotices(ctx: CommandCtx, notices: Notice[]): void {
|
|
|
590
783
|
for (const notice of notices) notifyWithSource({ ctx, source: NOTICE_SOURCE, level: notice.level, message: notice.message });
|
|
591
784
|
}
|
|
592
785
|
|
|
593
|
-
/** /config:model-discovery
|
|
594
|
-
async function
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
const
|
|
607
|
-
if (!baseUrl) return;
|
|
608
|
-
if (!/^https?:\/\//.test(baseUrl)) {
|
|
609
|
-
sink({ level: "error", message: i18n.t("invalidUrl") }, ctx);
|
|
610
|
-
return;
|
|
611
|
-
}
|
|
612
|
-
const api = await ctx.ui.select(i18n.t("api"), [...API_CHOICES]);
|
|
613
|
-
if (!api) return;
|
|
614
|
-
const apiKey = (await ctx.ui.input(i18n.t("apiKey")))?.trim();
|
|
615
|
-
const name = (await ctx.ui.input(i18n.t("displayName", { id })))?.trim();
|
|
616
|
-
|
|
617
|
-
const entry: Record<string, unknown> = { baseUrl, api, [DISCOVERY_MARKER]: true };
|
|
618
|
-
if (name) entry.name = name;
|
|
619
|
-
if (apiKey) entry.apiKey = apiKey;
|
|
620
|
-
const summary = i18n.t("summary", { id, baseUrl, api, apiKey: apiKey || i18n.t("noKey"), name: name || id });
|
|
621
|
-
if (!(await ctx.ui.confirm(i18n.t("confirmAdd"), summary))) return;
|
|
622
|
-
|
|
623
|
-
const providers = (data.providers ?? {}) as Record<string, unknown>;
|
|
624
|
-
providers[id] = entry;
|
|
625
|
-
data.providers = providers;
|
|
786
|
+
/** /config:model-discovery 新增 provider:写回 models.json 后立刻做首次发现。 */
|
|
787
|
+
async function addProviderFromPanel(options: {
|
|
788
|
+
/** 扩展 API。 */
|
|
789
|
+
pi: ExtensionAPI;
|
|
790
|
+
/** 命令上下文,用来发提示。 */
|
|
791
|
+
ctx: CommandCtx;
|
|
792
|
+
/** 面板里填好的新 provider。 */
|
|
793
|
+
provider: PanelProvider;
|
|
794
|
+
/** 本次会话共享的 /models 请求缓存。 */
|
|
795
|
+
fetchCache: FetchCache;
|
|
796
|
+
/** 提示出口。 */
|
|
797
|
+
sink: NoticeSink;
|
|
798
|
+
}): Promise<void> {
|
|
799
|
+
const { pi, ctx, provider, fetchCache, sink } = options;
|
|
626
800
|
try {
|
|
627
|
-
const { backup } = await
|
|
801
|
+
const { backup } = await persistNewProvider(provider);
|
|
628
802
|
sink({ level: "info", message: i18n.t("written", { backup }) }, ctx);
|
|
629
803
|
} catch (err) {
|
|
630
804
|
sink({ level: "error", message: `${LOG_PREFIX} ${err instanceof Error ? err.message : err}` }, ctx);
|
|
631
805
|
return;
|
|
632
806
|
}
|
|
633
|
-
|
|
634
807
|
const notices: Notice[] = [];
|
|
635
808
|
const result = await discoverAndRegister(
|
|
636
809
|
pi,
|
|
637
|
-
{
|
|
810
|
+
{
|
|
811
|
+
id: provider.id,
|
|
812
|
+
name: provider.name || undefined,
|
|
813
|
+
baseUrl: provider.baseUrl,
|
|
814
|
+
apiKey: provider.apiKey || undefined,
|
|
815
|
+
api: provider.api,
|
|
816
|
+
},
|
|
638
817
|
fetchCache,
|
|
639
818
|
notices,
|
|
640
819
|
);
|
|
641
820
|
flushNotices(ctx, notices);
|
|
642
821
|
if (result) {
|
|
643
|
-
sink({ level: "info", message: i18n.t("discovered", { id, count: result.count }) }, ctx);
|
|
822
|
+
sink({ level: "info", message: i18n.t("discovered", { id: provider.id, count: result.count }) }, ctx);
|
|
644
823
|
} else {
|
|
645
|
-
sink({ level: "warning", message: i18n.t("firstFailed", { id }) }, ctx);
|
|
646
|
-
}
|
|
647
|
-
}
|
|
648
|
-
|
|
649
|
-
/** /config:model-discovery 管理 provider(重新发现/删除)交互流程 */
|
|
650
|
-
async function manageProviderFlow(
|
|
651
|
-
pi: ExtensionAPI,
|
|
652
|
-
ctx: CommandCtx,
|
|
653
|
-
data: Record<string, unknown>,
|
|
654
|
-
entry: DiscoveryProviderEntry,
|
|
655
|
-
fetchCache: FetchCache,
|
|
656
|
-
sink: NoticeSink,
|
|
657
|
-
) {
|
|
658
|
-
const REDISCOVER = i18n.t("rediscover");
|
|
659
|
-
const REMOVE = i18n.t("remove");
|
|
660
|
-
const BACK = i18n.t("back");
|
|
661
|
-
const action = await ctx.ui.select(
|
|
662
|
-
i18n.t("manageTitle", { id: entry.id, baseUrl: entry.baseUrl ?? "?", api: entry.api ?? "?" }),
|
|
663
|
-
[REDISCOVER, REMOVE, BACK],
|
|
664
|
-
);
|
|
665
|
-
if (action === undefined || action === BACK) return;
|
|
666
|
-
|
|
667
|
-
if (action === REDISCOVER) {
|
|
668
|
-
const notices: Notice[] = [];
|
|
669
|
-
const result = await discoverAndRegister(pi, entry, fetchCache, notices);
|
|
670
|
-
flushNotices(ctx, notices);
|
|
671
|
-
if (result) {
|
|
672
|
-
sink({ level: "info", message: i18n.t("rediscovered", { id: entry.id, count: result.count }) }, ctx);
|
|
673
|
-
}
|
|
674
|
-
return;
|
|
675
|
-
}
|
|
676
|
-
|
|
677
|
-
// REMOVE
|
|
678
|
-
if (!(await ctx.ui.confirm(i18n.t("confirmRemove", { id: entry.id }), i18n.t("removeMessage")))) return;
|
|
679
|
-
const providers = (data.providers ?? {}) as Record<string, unknown>;
|
|
680
|
-
delete providers[entry.id];
|
|
681
|
-
data.providers = providers;
|
|
682
|
-
try {
|
|
683
|
-
const { backup } = await writeModelsFile(data);
|
|
684
|
-
pi.unregisterProvider(entry.id);
|
|
685
|
-
const notices: Notice[] = [];
|
|
686
|
-
await removeCachedModels(entry.id, notices);
|
|
687
|
-
flushNotices(ctx, notices);
|
|
688
|
-
sink({ level: "info", message: i18n.t("removed", { id: entry.id, backup }) }, ctx);
|
|
689
|
-
} catch (err) {
|
|
690
|
-
sink({ level: "error", message: `${LOG_PREFIX} ${err instanceof Error ? err.message : err}` }, ctx);
|
|
824
|
+
sink({ level: "warning", message: i18n.t("firstFailed", { id: provider.id }) }, ctx);
|
|
691
825
|
}
|
|
692
826
|
}
|
|
693
827
|
|