@bolloon/bolloon-agent 0.5.0 → 0.5.2
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 +39 -0
- package/dist/agents/agent-lang.js +101 -0
- package/dist/agents/agent-manifest-protocol.js +4 -0
- package/dist/agents/execution-supervisor.js +87 -5
- package/dist/agents/gateway-group.js +44 -3
- package/dist/agents/model-policy.js +651 -0
- package/dist/agents/pi-sdk-tools.js +84 -33
- package/dist/agents/pi-sdk.js +184 -31
- package/dist/agents/run-store.js +53 -0
- package/dist/agents/task/task-runner.js +8 -1
- package/dist/agents/workflow-pivot-loop.js +22 -13
- package/dist/cli/clipboard.js +65 -0
- package/dist/cli/ink-app.js +145 -17
- package/dist/cli/log-gate.js +386 -0
- package/dist/cli/mention-data.js +2 -0
- package/dist/cli/model-selector.js +538 -0
- package/dist/cli/reply-hygiene.js +54 -0
- package/dist/cli/setup-wizard.js +686 -69
- package/dist/cli/startup-notice.js +160 -0
- package/dist/cli/stores.js +22 -1
- package/dist/cli/theme.js +49 -0
- package/dist/cli/tui-select.js +970 -0
- package/dist/cli-entry.js +125 -18
- package/dist/efficode/compress.js +78 -0
- package/dist/efficode/crc32.js +38 -0
- package/dist/efficode/did.js +66 -0
- package/dist/efficode/index.js +24 -0
- package/dist/efficode/lz77.js +170 -0
- package/dist/efficode/negotiate.js +187 -0
- package/dist/efficode/ops.js +87 -0
- package/dist/efficode/packet.js +271 -0
- package/dist/efficode/types.js +45 -0
- package/dist/electron-build/agents/agent-manifest-protocol.js +4 -0
- package/dist/electron-build/agents/agent-manifest-protocol.js.map +1 -1
- package/dist/electron-build/agents/execution-supervisor.js +86 -4
- package/dist/electron-build/agents/execution-supervisor.js.map +1 -1
- package/dist/electron-build/agents/goal-store.js.map +1 -1
- package/dist/electron-build/agents/model-policy.js +706 -0
- package/dist/electron-build/agents/model-policy.js.map +1 -0
- package/dist/electron-build/agents/pi-sdk-tools.js +84 -33
- package/dist/electron-build/agents/pi-sdk-tools.js.map +1 -1
- package/dist/electron-build/agents/pi-sdk-types.js.map +1 -1
- package/dist/electron-build/agents/pi-sdk.js +185 -31
- package/dist/electron-build/agents/pi-sdk.js.map +1 -1
- package/dist/electron-build/agents/run-store.js +55 -1
- package/dist/electron-build/agents/run-store.js.map +1 -1
- package/dist/electron-build/agents/task/task-runner.js +8 -1
- package/dist/electron-build/agents/task/task-runner.js.map +1 -1
- package/dist/electron-build/agents/workflow-pivot-loop.js +22 -13
- package/dist/electron-build/agents/workflow-pivot-loop.js.map +1 -1
- package/dist/electron-build/llm/config-store.js +146 -6
- package/dist/electron-build/llm/config-store.js.map +1 -1
- package/dist/electron-build/llm/connection-probe.js +764 -0
- package/dist/electron-build/llm/connection-probe.js.map +1 -0
- package/dist/electron-build/llm/custom-provider-store.js +272 -0
- package/dist/electron-build/llm/custom-provider-store.js.map +1 -0
- package/dist/electron-build/llm/data/provider-catalog-baked.js +23 -0
- package/dist/electron-build/llm/data/provider-catalog-baked.js.map +1 -0
- package/dist/electron-build/llm/model-catalog.js +778 -0
- package/dist/electron-build/llm/model-catalog.js.map +1 -0
- package/dist/electron-build/llm/model-discovery.js +1202 -0
- package/dist/electron-build/llm/model-discovery.js.map +1 -0
- package/dist/electron-build/llm/model-selection.js +1315 -0
- package/dist/electron-build/llm/model-selection.js.map +1 -0
- package/dist/electron-build/llm/pi-ai.js +751 -55
- package/dist/electron-build/llm/pi-ai.js.map +1 -1
- package/dist/electron-build/llm/provider-catalog.js +969 -0
- package/dist/electron-build/llm/provider-catalog.js.map +1 -0
- package/dist/electron-build/llm/provider-registry.js +950 -0
- package/dist/electron-build/llm/provider-registry.js.map +1 -0
- package/dist/electron-build/llm/tool-name.js +266 -0
- package/dist/electron-build/llm/tool-name.js.map +1 -0
- package/dist/electron-build/utils/dual-source-facts.js +236 -0
- package/dist/electron-build/utils/dual-source-facts.js.map +1 -0
- package/dist/electron-build/utils/dual-source.js +32 -156
- package/dist/electron-build/utils/dual-source.js.map +1 -1
- package/dist/electron-build/utils/update-manager.js +22 -72
- package/dist/electron-build/utils/update-manager.js.map +1 -1
- package/dist/electron-build/utils/update-state.js +2 -19
- package/dist/electron-build/utils/update-state.js.map +1 -1
- package/dist/electron-build/utils/version-identity.js +193 -0
- package/dist/electron-build/utils/version-identity.js.map +1 -0
- package/dist/electron-build/utils/version-info.js +47 -101
- package/dist/electron-build/utils/version-info.js.map +1 -1
- package/dist/index.js +282 -96
- package/dist/ios/bolloon-web.json +10 -0
- package/dist/ios/mobile-contacts.js +44 -11
- package/dist/ios/mobile-core.js +1723 -84
- package/dist/ios/mobile-tasks.js +339 -0
- package/dist/ios/mobile-update.js +1442 -0
- package/dist/ios/mobile.js +308 -35
- package/dist/ios/routes-llm-config.js +344 -52
- package/dist/ios/routes-mobile-tasks.js +353 -0
- package/dist/ios/server.js +163 -4
- package/dist/ios/sw.js +1 -1
- package/dist/llm/config-store.js +146 -6
- package/dist/llm/connection-probe.js +744 -0
- package/dist/llm/custom-provider-store.js +224 -0
- package/dist/llm/data/provider-catalog-baked.js +19 -0
- package/dist/llm/model-catalog.js +717 -0
- package/dist/llm/model-discovery.js +1137 -0
- package/dist/llm/model-selection.js +1241 -0
- package/dist/llm/pi-ai.js +732 -54
- package/dist/llm/provider-catalog.js +898 -0
- package/dist/llm/provider-registry.js +917 -0
- package/dist/llm/tool-name.js +220 -0
- package/dist/setup/onboard.js +34 -8
- package/dist/utils/dual-source-facts.js +223 -0
- package/dist/utils/dual-source.js +12 -141
- package/dist/utils/update-manager.js +16 -59
- package/dist/utils/update-state.js +6 -18
- package/dist/utils/version-identity.js +176 -0
- package/dist/utils/version-info.js +9 -78
- package/dist/web/bolloon-web.json +10 -0
- package/dist/web/mobile-core.js +1721 -84
- package/dist/web/mobile-update.js +1442 -0
- package/dist/web/mobile.js +372 -35
- package/dist/web/routes-llm-config.js +344 -52
- package/dist/web/sw.js +1 -1
- package/package.json +1 -1
- package/scripts/build-web.ts +36 -0
|
@@ -0,0 +1,717 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* model-catalog.ts — 模型目录与「模型元数据」的读写口 (2026-09-26)
|
|
3
|
+
*
|
|
4
|
+
* ## 这一层解决什么
|
|
5
|
+
*
|
|
6
|
+
* 选择器要显示模型的**能力**, 用户才知道为什么某个模型不能用来做工具调用。但在当前的
|
|
7
|
+
* 内置数据里, 每个供应商只有一张**模型 ID 清单** (`PROVIDER_INFO[provider].models`),
|
|
8
|
+
* 没有任何能力数据: 没有 tool_calls、没有 reasoning、没有上下文长度。真实的能力数据
|
|
9
|
+
* 只能来自两处, 而这两处**现在都还没接**:
|
|
10
|
+
*
|
|
11
|
+
* ① 供应商注册表 (每个供应商的协议/鉴权/默认 URL/是否本地/是否允许当长期执行器)
|
|
12
|
+
* ② 模型发现 (`GET /models` 实时目录 + 缓存 + 手工自定义)
|
|
13
|
+
*
|
|
14
|
+
* 所以本层的纪律是: **有真来源的字段给真值, 没有真来源的字段一律 `'unknown'` (或 `null`),
|
|
15
|
+
* 并把"为什么不知道"记在 `ModelEntry.unknowns` 里由界面直接显示** —— 绝不允许在下游渲染层
|
|
16
|
+
* 写一个"看起来像真的"的常量。渲染层只做三态到人话的翻译 (`capabilityZh` / `contextZh`),
|
|
17
|
+
* 一个能力值都不许在那里产生。
|
|
18
|
+
*
|
|
19
|
+
* ## 元数据填充点 (唯一)
|
|
20
|
+
*
|
|
21
|
+
* 外部数据一律通过 `ModelMetadataSource` 注入 (`registerModelMetadataSource`), 不通过
|
|
22
|
+
* 改本文件的常量、也不通过改渲染层。填充者只回答"这一项你知道什么":
|
|
23
|
+
* 返回 `undefined` 表示**这一项没有数据**, 该字段就保持 `unknown`。
|
|
24
|
+
* 这样 P3/P5 填真数据时不会出现"两处真相"。
|
|
25
|
+
*
|
|
26
|
+
* `requiresApiKey` / `credentialReady` / `isLocal` **不走填充点**, 它们是本层能**当场算出真值**的字段:
|
|
27
|
+
* 前者来自内置注册表 (`PROVIDER_INFO`), 中间那个来自"配置里的 key / 环境变量现在有没有",
|
|
28
|
+
* 后者来自 base URL 的主机名判定。
|
|
29
|
+
* 配置里那格 `ProviderConfig.requiresApiKey` 是**另一个事实** (= "别再问我了"), 单独放在
|
|
30
|
+
* `ProviderSummary.configRequiresKey`, 与注册表说法不一致时用 `requiresKeyConflict` 如实标出 ——
|
|
31
|
+
* 不许在渲染层挑一个装作不知道 (那就是两处真相里随机的一半)。
|
|
32
|
+
*/
|
|
33
|
+
import { llmConfigStore, DEFAULT_PROVIDER_CONFIGS, PROVIDER_INFO, } from './config-store.js';
|
|
34
|
+
import { protocolOf, supportsReasoning, envKeyNamesOf, envApiKeyOf, effectiveModelConfig, normalizeBaseUrl, } from './model-selection.js';
|
|
35
|
+
/** 允许为 `unknown` 的字段 (其余字段一律有真值) */
|
|
36
|
+
export const UNKNOWN_TOLERANT_FIELDS = ['toolCalling', 'reasoning', 'contextLength'];
|
|
37
|
+
// ============================================================
|
|
38
|
+
// 填充点注册表
|
|
39
|
+
// ============================================================
|
|
40
|
+
const metadataSources = [];
|
|
41
|
+
/**
|
|
42
|
+
* 注册一个元数据填充点。
|
|
43
|
+
*
|
|
44
|
+
* 优先级 = **注册顺序** (先注册的赢); 同一个 `id` 再注册 = **原地替换, 优先级不变**
|
|
45
|
+
* (不是挪到队尾 —— 否则"后注册的就悄悄抢走了优先级", 与本文档说法矛盾)。
|
|
46
|
+
*/
|
|
47
|
+
export function registerModelMetadataSource(src) {
|
|
48
|
+
const i = metadataSources.findIndex((s) => s.id === src.id);
|
|
49
|
+
if (i >= 0)
|
|
50
|
+
metadataSources.splice(i, 1, src);
|
|
51
|
+
else
|
|
52
|
+
metadataSources.push(src);
|
|
53
|
+
}
|
|
54
|
+
/** 已注册的填充点名 (顺序 = 生效优先级) */
|
|
55
|
+
export function listModelMetadataSources() {
|
|
56
|
+
return metadataSources.map((s) => s.id);
|
|
57
|
+
}
|
|
58
|
+
/** 清空填充点 (测试用; 生产路径不调用) */
|
|
59
|
+
export function resetModelMetadataSources() {
|
|
60
|
+
metadataSources.length = 0;
|
|
61
|
+
}
|
|
62
|
+
function factsOf(provider, model) {
|
|
63
|
+
const out = {};
|
|
64
|
+
for (const src of metadataSources) {
|
|
65
|
+
if (src.providers && !src.providers.includes(provider))
|
|
66
|
+
continue;
|
|
67
|
+
let facts;
|
|
68
|
+
try {
|
|
69
|
+
facts = src.metadataOf({ provider, model });
|
|
70
|
+
}
|
|
71
|
+
catch {
|
|
72
|
+
facts = undefined;
|
|
73
|
+
}
|
|
74
|
+
if (!facts)
|
|
75
|
+
continue;
|
|
76
|
+
for (const k of Object.keys(facts)) {
|
|
77
|
+
if (out[k] === undefined && facts[k] !== undefined)
|
|
78
|
+
out[k] = facts[k];
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
return out;
|
|
82
|
+
}
|
|
83
|
+
// ============================================================
|
|
84
|
+
// 判定真值的小工具 (这些字段不需要填充点)
|
|
85
|
+
// ============================================================
|
|
86
|
+
/** 本地端点 = 主机名是环回地址 (真判定, 不是供应商白名单) */
|
|
87
|
+
export function isLocalBaseUrl(raw) {
|
|
88
|
+
const url = normalizeBaseUrl(raw);
|
|
89
|
+
if (!url)
|
|
90
|
+
return false;
|
|
91
|
+
try {
|
|
92
|
+
const host = new URL(url).hostname.toLowerCase();
|
|
93
|
+
return host === 'localhost' || host === '127.0.0.1' || host === '::1' || host === '0.0.0.0' || host.endsWith('.local');
|
|
94
|
+
}
|
|
95
|
+
catch {
|
|
96
|
+
return false;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
/** 该供应商登记在册的模型 ID 清单 (内置目录; 没有清单 → 空数组) */
|
|
100
|
+
export function curatedModelIds(provider) {
|
|
101
|
+
const info = PROVIDER_INFO[provider];
|
|
102
|
+
const list = Array.isArray(info?.models) ? info.models : [];
|
|
103
|
+
return list.filter((m) => typeof m === 'string' && m);
|
|
104
|
+
}
|
|
105
|
+
/** 内置目录给不给得出这一家的清单 —— 拿不到时界面必须说"无内置目录", 不是"0 个模型" */
|
|
106
|
+
export function curatedOriginOf(provider) {
|
|
107
|
+
return curatedModelIds(provider).length ? 'curated' : 'unavailable';
|
|
108
|
+
}
|
|
109
|
+
/** 三态 → 中文 (渲染层只翻译, 不产生值) */
|
|
110
|
+
export function capabilityZh(c) {
|
|
111
|
+
return c === 'yes' ? '支持' : c === 'no' ? '不支持' : '未知';
|
|
112
|
+
}
|
|
113
|
+
/** 上下文长度 → 人话 (`null` = 未知) */
|
|
114
|
+
export function contextZh(n) {
|
|
115
|
+
if (n === null || n === undefined || !Number.isFinite(n) || n <= 0)
|
|
116
|
+
return '未知';
|
|
117
|
+
if (n >= 1_000_000)
|
|
118
|
+
return `${Math.round(n / 100_000) / 10}M`;
|
|
119
|
+
if (n >= 1000)
|
|
120
|
+
return `${Math.round(n / 1000)}K`;
|
|
121
|
+
return String(n);
|
|
122
|
+
}
|
|
123
|
+
// ============================================================
|
|
124
|
+
// 条目构造
|
|
125
|
+
// ============================================================
|
|
126
|
+
/** 内置目录为什么给不出能力数据 —— 逐字段一句人话 (真出处: 目录里只有 ID) */
|
|
127
|
+
export const CURATED_ONLY_REASON = '内置目录只有模型 ID, 没有能力字段 (供应商目录/注册表未接)';
|
|
128
|
+
export function buildModelEntry(model, ctx) {
|
|
129
|
+
const facts = factsOf(ctx.provider, model);
|
|
130
|
+
const origin = facts.origin || ctx.originDefault || curatedOriginOf(ctx.provider);
|
|
131
|
+
const unknowns = [];
|
|
132
|
+
const toolCalling = facts.toolCalling ?? 'unknown';
|
|
133
|
+
const reasoning = facts.reasoning ?? 'unknown';
|
|
134
|
+
const contextLength = typeof facts.contextLength === 'number' ? facts.contextLength : null;
|
|
135
|
+
const why = ctx.unknownReason || CURATED_ONLY_REASON;
|
|
136
|
+
if (toolCalling === 'unknown')
|
|
137
|
+
unknowns.push({ field: 'toolCalling', reason: why });
|
|
138
|
+
if (reasoning === 'unknown')
|
|
139
|
+
unknowns.push({ field: 'reasoning', reason: why });
|
|
140
|
+
if (contextLength === null)
|
|
141
|
+
unknowns.push({ field: 'contextLength', reason: why });
|
|
142
|
+
// 注册表是"这家要不要 key"的主来源; 填充点只在**知道得更准**时覆盖它
|
|
143
|
+
const requiresApiKey = facts.requiresApiKey ?? ctx.requiresApiKey;
|
|
144
|
+
return {
|
|
145
|
+
id: model,
|
|
146
|
+
provider: ctx.provider,
|
|
147
|
+
displayName: facts.displayName || model,
|
|
148
|
+
toolCalling,
|
|
149
|
+
reasoning,
|
|
150
|
+
contextLength,
|
|
151
|
+
requiresApiKey,
|
|
152
|
+
credentialReady: !requiresApiKey || ctx.credentialReady,
|
|
153
|
+
isLocal: isLocalBaseUrl(ctx.baseUrl),
|
|
154
|
+
origin,
|
|
155
|
+
reachability: ctx.reachability || 'unknown',
|
|
156
|
+
...(ctx.failureReason ? { failureReason: ctx.failureReason } : {}),
|
|
157
|
+
unknowns,
|
|
158
|
+
current: ctx.currentProvider === ctx.provider && ctx.currentModel === model,
|
|
159
|
+
...(ctx.upstreamModels
|
|
160
|
+
? { upstreamSeen: ctx.upstreamModels.has(model), upstreamOrigin: ctx.upstreamOrigin }
|
|
161
|
+
: {}),
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
// ============================================================
|
|
165
|
+
// 目录读取
|
|
166
|
+
// ============================================================
|
|
167
|
+
async function providerConfigMap() {
|
|
168
|
+
await llmConfigStore.initialize();
|
|
169
|
+
const cfg = await llmConfigStore.getConfig();
|
|
170
|
+
return (cfg.providers || {});
|
|
171
|
+
}
|
|
172
|
+
function keyStateOf(cfg, provider) {
|
|
173
|
+
if (cfg?.apiKey)
|
|
174
|
+
return 'configured';
|
|
175
|
+
if (envApiKeyOf(provider))
|
|
176
|
+
return 'env';
|
|
177
|
+
if (cfg?.requiresApiKey === false)
|
|
178
|
+
return 'not_required';
|
|
179
|
+
return 'missing';
|
|
180
|
+
}
|
|
181
|
+
/**
|
|
182
|
+
* 该供应商**现在能用**的凭证 (真来源: 配置里的 key → 环境变量)。
|
|
183
|
+
*
|
|
184
|
+
* 用途: 选择器第 6 步的预检必须用**和真正落盘时同一份凭证**去探, 否则"已经配好 key 的供应商"
|
|
185
|
+
* 在预检里会被上游按未鉴权打回 (401), 用户看到一句假的"探测失败"。
|
|
186
|
+
* 返回值只用于当次请求头, 不进任何打印/快照 (渲染层只回显尾 4 位)。
|
|
187
|
+
*/
|
|
188
|
+
export async function resolvedApiKeyOf(provider) {
|
|
189
|
+
const providers = await providerConfigMap();
|
|
190
|
+
const own = providers[provider]?.apiKey;
|
|
191
|
+
if (own)
|
|
192
|
+
return own;
|
|
193
|
+
return envApiKeyOf(provider)?.value;
|
|
194
|
+
}
|
|
195
|
+
/** 供应商列表 (按内置推荐顺序给出; 顺序不参与任何判定) */
|
|
196
|
+
export async function buildProviderSummaries(opts = {}) {
|
|
197
|
+
const providers = await providerConfigMap();
|
|
198
|
+
const eff = await effectiveModelConfig({ sessionKey: opts.sessionKey }).catch(() => null);
|
|
199
|
+
const out = [];
|
|
200
|
+
for (const id of Object.keys(DEFAULT_PROVIDER_CONFIGS)) {
|
|
201
|
+
const def = DEFAULT_PROVIDER_CONFIGS[id];
|
|
202
|
+
const cfg = providers[id];
|
|
203
|
+
const baseUrl = normalizeBaseUrl(cfg?.baseUrl || def.baseUrl);
|
|
204
|
+
// 注册表 (PROVIDER_INFO) 是"这家要不要 key"的主来源; config 里那格是"问过没有"
|
|
205
|
+
const registryRequires = (PROVIDER_INFO[id]?.requiresApiKey ?? def.requiresApiKey) !== false;
|
|
206
|
+
const configRequires = cfg?.requiresApiKey !== false;
|
|
207
|
+
const requiresApiKey = registryRequires;
|
|
208
|
+
const keyState = keyStateOf(cfg, id);
|
|
209
|
+
const credentialReady = keyState === 'configured' || keyState === 'env' || !registryRequires;
|
|
210
|
+
const ids = curatedModelIds(id);
|
|
211
|
+
const probe = opts.probes?.[id];
|
|
212
|
+
out.push({
|
|
213
|
+
id,
|
|
214
|
+
name: PROVIDER_INFO[id]?.name || id,
|
|
215
|
+
protocol: protocolOf(id),
|
|
216
|
+
configured: credentialReady,
|
|
217
|
+
requiresApiKey,
|
|
218
|
+
configRequiresKey: configRequires,
|
|
219
|
+
requiresKeyConflict: configRequires !== registryRequires,
|
|
220
|
+
isLocal: isLocalBaseUrl(baseUrl),
|
|
221
|
+
modelCount: ids.length,
|
|
222
|
+
modelCountOrigin: curatedOriginOf(id),
|
|
223
|
+
configuredModel: String(cfg?.model || ''),
|
|
224
|
+
baseUrl,
|
|
225
|
+
keyState,
|
|
226
|
+
current: !!eff && eff.provider === id,
|
|
227
|
+
active: !!eff && eff.provider === id,
|
|
228
|
+
providerReasoning: supportsReasoning(id) ? 'yes' : 'unknown',
|
|
229
|
+
reachability: probe ? (probe.ok ? 'ok' : 'failed') : 'unknown',
|
|
230
|
+
origin: 'builtin',
|
|
231
|
+
...(probe && !probe.ok && probe.detail ? { failureReason: probe.detail } : {}),
|
|
232
|
+
});
|
|
233
|
+
}
|
|
234
|
+
// ★ 2026-09-26 (P3 接线收口): **自定义供应商也必须出现在这个列表里**。
|
|
235
|
+
// 只遍历 `DEFAULT_PROVIDER_CONFIGS` 的话, 用户在 `/model` 里根本看不到自己配的那一家
|
|
236
|
+
// (它不在内置表里) —— 只能靠记住 id 手打, 而"这家到底配没配 key / 是不是本地端点"更没有地方可看。
|
|
237
|
+
// 数据来源是注册表读口 (`listProviderRegistry`) 与自定义供应商的**声明** (真出处), 不在这里
|
|
238
|
+
// 编任何一条: 拿不到声明就不出这一行。
|
|
239
|
+
for (const { entry, spec } of await customProviderRows()) {
|
|
240
|
+
if (Object.prototype.hasOwnProperty.call(DEFAULT_PROVIDER_CONFIGS, entry.id))
|
|
241
|
+
continue; // 内置的上面已经出过
|
|
242
|
+
const cfg = providers[entry.id];
|
|
243
|
+
const baseUrl = normalizeBaseUrl(cfg?.baseUrl || entry.defaultBaseUrl || '');
|
|
244
|
+
const declaredEnv = Array.isArray(entry.apiKeyEnvVars) && entry.apiKeyEnvVars.length ? String(entry.apiKeyEnvVars[0]) : '';
|
|
245
|
+
const envHas = declaredEnv ? !!String(process.env[declaredEnv] || '').trim() : false;
|
|
246
|
+
const ownKey = String(spec.apiKey || '').trim();
|
|
247
|
+
const requiresApiKey = entry.requiresApiKey !== false;
|
|
248
|
+
const keyState = ownKey ? 'configured' : envHas ? 'env' : (requiresApiKey ? 'missing' : 'not_required');
|
|
249
|
+
const credentialReady = keyState === 'configured' || keyState === 'env' || !requiresApiKey;
|
|
250
|
+
const declaredIds = Array.isArray(entry.declaredModelIds) ? entry.declaredModelIds : [];
|
|
251
|
+
// 配置那一格只有**真的存在**时才算一个事实 (自定义供应商本来就不在 providers 里)
|
|
252
|
+
const hasConfigRow = !!cfg && (cfg.requiresApiKey !== undefined || cfg.baseUrl !== undefined || cfg.model !== undefined);
|
|
253
|
+
const configRequiresKey = hasConfigRow ? cfg.requiresApiKey !== false : requiresApiKey;
|
|
254
|
+
const probe = opts.probes?.[entry.id];
|
|
255
|
+
out.push({
|
|
256
|
+
id: entry.id,
|
|
257
|
+
name: String(entry.displayName || entry.id),
|
|
258
|
+
protocol: entry.protocol,
|
|
259
|
+
configured: credentialReady,
|
|
260
|
+
requiresApiKey,
|
|
261
|
+
configRequiresKey,
|
|
262
|
+
requiresKeyConflict: configRequiresKey !== requiresApiKey,
|
|
263
|
+
isLocal: !!entry.isLocal || isLocalBaseUrl(baseUrl),
|
|
264
|
+
modelCount: declaredIds.length,
|
|
265
|
+
modelCountOrigin: declaredIds.length ? 'custom' : 'unavailable',
|
|
266
|
+
configuredModel: String(cfg?.model || spec.model || ''),
|
|
267
|
+
baseUrl,
|
|
268
|
+
keyState,
|
|
269
|
+
current: !!eff && eff.provider === entry.id,
|
|
270
|
+
active: !!eff && eff.provider === entry.id,
|
|
271
|
+
providerReasoning: entry.reasoning,
|
|
272
|
+
reachability: probe ? (probe.ok ? 'ok' : 'failed') : 'unknown',
|
|
273
|
+
origin: 'custom',
|
|
274
|
+
...(probe && !probe.ok && probe.detail ? { failureReason: probe.detail } : {}),
|
|
275
|
+
});
|
|
276
|
+
}
|
|
277
|
+
// ★ 2026-09-27 (目录驱动): **目录里的家也进这个列表** —— 默认全部进来 (内置同名项跳过, 不重复)。
|
|
278
|
+
// 数据全部来自目录层 (`origin='catalog'`), 这里**不编**任何一条: 目录里没有这一家就不出这一行。
|
|
279
|
+
// 家数多 (=一屏放不下) 由界面层用"固定高度视窗 + 可折叠分组 + 搜索"消化 —— 见 `providerTierOf`。
|
|
280
|
+
const catalogMode = opts.catalog ?? 'all';
|
|
281
|
+
if (catalogMode !== 'none') {
|
|
282
|
+
const cat = await import('./provider-catalog.js');
|
|
283
|
+
await cat.initializeProviderCatalog();
|
|
284
|
+
const seen = new Set(out.map((s) => s.id));
|
|
285
|
+
for (const v of cat.catalogProviders()) {
|
|
286
|
+
if (seen.has(v.id))
|
|
287
|
+
continue; // 内置与自定义优先 (同名目录项直接跳过)
|
|
288
|
+
if (Object.prototype.hasOwnProperty.call(DEFAULT_PROVIDER_CONFIGS, v.id))
|
|
289
|
+
continue;
|
|
290
|
+
const cfg = providers[v.id];
|
|
291
|
+
const envHas = v.auth?.envVar ? !!String(process.env[String(v.auth.envVar)] || '').trim() : false;
|
|
292
|
+
const ownKey = String(cfg?.apiKey || '').trim();
|
|
293
|
+
const configured = envHas || !!ownKey;
|
|
294
|
+
if (catalogMode === 'configured' && !configured)
|
|
295
|
+
continue;
|
|
296
|
+
const baseUrl = normalizeBaseUrl(String(cfg?.baseUrl || v.api || ''));
|
|
297
|
+
const keyState = ownKey ? 'configured' : envHas ? 'env' : 'missing';
|
|
298
|
+
const probe = opts.probes?.[v.id];
|
|
299
|
+
const requiresApiKey = true;
|
|
300
|
+
out.push({
|
|
301
|
+
id: v.id,
|
|
302
|
+
name: String(v.name || v.id),
|
|
303
|
+
protocol: v.family === 'anthropic' ? 'anthropic' : v.family === 'gemini' ? 'gemini' : 'openai-compatible',
|
|
304
|
+
configured,
|
|
305
|
+
requiresApiKey,
|
|
306
|
+
configRequiresKey: cfg?.requiresApiKey !== false,
|
|
307
|
+
requiresKeyConflict: false,
|
|
308
|
+
isLocal: isLocalBaseUrl(baseUrl),
|
|
309
|
+
modelCount: v.modelIds.length,
|
|
310
|
+
modelCountOrigin: 'catalog',
|
|
311
|
+
configuredModel: String(cfg?.model || ''),
|
|
312
|
+
baseUrl,
|
|
313
|
+
keyState,
|
|
314
|
+
current: !!eff && eff.provider === v.id,
|
|
315
|
+
active: !!eff && eff.provider === v.id,
|
|
316
|
+
providerReasoning: catalogProviderReasoningOf(v),
|
|
317
|
+
reachability: probe ? (probe.ok ? 'ok' : 'failed') : 'unknown',
|
|
318
|
+
origin: 'catalog',
|
|
319
|
+
catalogFamily: String(v.family),
|
|
320
|
+
...(v.auth?.supported ? {} : { catalogAuthUnsupported: true }),
|
|
321
|
+
...(v.hasBaseUrl ? {} : { catalogBaseUrlMissing: true }),
|
|
322
|
+
...(v.auth?.envVar ? { catalogEnvVar: String(v.auth.envVar) } : {}),
|
|
323
|
+
...(!v.auth?.supported || !v.hasBaseUrl ? { catalogNote: String(v.unusableReason || '') } : {}),
|
|
324
|
+
...(probe && !probe.ok && probe.detail ? { failureReason: probe.detail } : {}),
|
|
325
|
+
});
|
|
326
|
+
seen.add(v.id);
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
return out;
|
|
330
|
+
}
|
|
331
|
+
/** 目录家**供应商级** reasoning (目录里字面声明过才给结论; 没声明 → unknown) */
|
|
332
|
+
function catalogProviderReasoningOf(v) {
|
|
333
|
+
const facts = Object.values(v?.models || {});
|
|
334
|
+
if (!facts.length)
|
|
335
|
+
return 'unknown';
|
|
336
|
+
if (facts.some((f) => f?.reasoning === true))
|
|
337
|
+
return 'yes';
|
|
338
|
+
if (facts.every((f) => f?.reasoning === false))
|
|
339
|
+
return 'no';
|
|
340
|
+
return 'unknown';
|
|
341
|
+
}
|
|
342
|
+
/**
|
|
343
|
+
* 列表分组/计数: 内置/自定义/目录各多少家, 各几家可用。只数, 不产生值。
|
|
344
|
+
* (2026-09-27: 默认已经**全部列出**, 所以这里不再说"目录家未列出"; 只有显式 `catalog:'none'` 时才是那样)
|
|
345
|
+
*/
|
|
346
|
+
export function providerGroupSummary(s) {
|
|
347
|
+
const g = (list) => `${list.length} 家 (可用 ${list.filter((x) => x.configured).length})`;
|
|
348
|
+
const builtin = s.filter((x) => (x.origin || 'builtin') === 'builtin');
|
|
349
|
+
const custom = s.filter((x) => x.origin === 'custom');
|
|
350
|
+
const catalog = s.filter((x) => x.origin === 'catalog');
|
|
351
|
+
return `分组: 内置 ${g(builtin)} · 自定义 ${g(custom)} · 目录 ${g(catalog)} · 合计 ${s.length} 家`
|
|
352
|
+
+ (catalog.length ? '' : ' · 目录家未列出 (catalog:none; 看 /model catalog)');
|
|
353
|
+
}
|
|
354
|
+
// ============================================================
|
|
355
|
+
// 第 1 步的"分组/排序"依据 (2026-09-27: 全量列出 + 分组折叠, 不再靠藏家数)
|
|
356
|
+
// ============================================================
|
|
357
|
+
/** 第 1 步的分组名 (顺序 = 优先级: 当前 → 可用 → 未配置凭据 → 需专用鉴权 → 无 api 基址) */
|
|
358
|
+
export const PROVIDER_GROUPS = {
|
|
359
|
+
current: '当前生效',
|
|
360
|
+
usable: '可用 (有凭证 / 免 key)',
|
|
361
|
+
noCredential: '未配置凭据 (选了会先要 key)',
|
|
362
|
+
specialAuth: '需专用鉴权 (未支持)',
|
|
363
|
+
noBaseUrl: '无 api 基址 (需自定义 baseUrl)',
|
|
364
|
+
};
|
|
365
|
+
const TIER_RANK = { current: 0, usable: 1, noCredential: 2, specialAuth: 3, noBaseUrl: 4 };
|
|
366
|
+
/**
|
|
367
|
+
* 这家落在第 1 步的哪一组。**分组只是排版, 不是候选集** ——
|
|
368
|
+
* 后两组 (需专用鉴权未支持 / 目录没给基址) 照样在列表里、照样搜得到、标题上照写家数,
|
|
369
|
+
* 它们只是**默认收起** (界面层按 `providerTierCollapsedByDefault` 决定)。
|
|
370
|
+
*/
|
|
371
|
+
export function providerTierOf(s) {
|
|
372
|
+
if (s.current)
|
|
373
|
+
return 'current';
|
|
374
|
+
if (s.catalogAuthUnsupported)
|
|
375
|
+
return 'specialAuth';
|
|
376
|
+
if (s.catalogBaseUrlMissing)
|
|
377
|
+
return 'noBaseUrl';
|
|
378
|
+
return s.configured ? 'usable' : 'noCredential';
|
|
379
|
+
}
|
|
380
|
+
/**
|
|
381
|
+
* 哪些分组**默认收起** (leo 2026-09-27: "默认只展开'当前生效 + 可用', 其余分组折叠但显示家数")。
|
|
382
|
+
* 后三组家数最多 (目录 200+ 家), 默认收起才不会把页面塞满; 按 `空格` 随时展开看全。
|
|
383
|
+
*/
|
|
384
|
+
export function providerTierCollapsedByDefault(t) {
|
|
385
|
+
return t === 'noCredential' || t === 'specialAuth' || t === 'noBaseUrl';
|
|
386
|
+
}
|
|
387
|
+
/** 按"当前 → 可用 → 未配置 → 需专用鉴权 → 无基址"排 (稳定排序: 同组内保持目录层给的顺序) */
|
|
388
|
+
export function orderProvidersForMenu(list) {
|
|
389
|
+
return [...list].sort((a, b) => TIER_RANK[providerTierOf(a)] - TIER_RANK[providerTierOf(b)]);
|
|
390
|
+
}
|
|
391
|
+
/**
|
|
392
|
+
* 「在册的自定义供应商」的注册表记录 + 声明 (真出处: 注册表读口 / 自定义供应商存储)。
|
|
393
|
+
*
|
|
394
|
+
* 两个都是动态 import: `provider-registry` ↔ `model-catalog` 之间本来就是循环 import
|
|
395
|
+
* (见 provider-registry 文件头对模块体时机的说明), 静态 import 会把循环带进本模块的初始化路径。
|
|
396
|
+
* 读失败 → 返回空数组 (列表少一行自定义供应商, 而不是把整个列表炸掉)。
|
|
397
|
+
*/
|
|
398
|
+
async function customProviderRows() {
|
|
399
|
+
try {
|
|
400
|
+
const reg = await import('./provider-registry.js');
|
|
401
|
+
const store = await import('./custom-provider-store.js');
|
|
402
|
+
const specs = await store.readCustomProviders();
|
|
403
|
+
return Object.values(specs || {})
|
|
404
|
+
.map((spec) => ({ entry: reg.getProviderRegistryEntry(String(spec?.providerId || '')), spec }))
|
|
405
|
+
.filter((r) => r.entry && r.entry.kind === 'custom');
|
|
406
|
+
}
|
|
407
|
+
catch {
|
|
408
|
+
return [];
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
/**
|
|
412
|
+
* 注册表里**非内置**的那一条记录 (自定义或目录; 内置 / 不在册 → `null`)。
|
|
413
|
+
* 动态 import 的理由与 `customProviderRows` 相同 (provider-registry ↔ model-catalog 循环)。
|
|
414
|
+
*/
|
|
415
|
+
async function customRegistryEntryOf(providerId) {
|
|
416
|
+
const id = String(providerId || '').trim();
|
|
417
|
+
if (!id)
|
|
418
|
+
return null;
|
|
419
|
+
try {
|
|
420
|
+
const reg = await import('./provider-registry.js');
|
|
421
|
+
const entry = reg.getProviderRegistryEntry(id);
|
|
422
|
+
if (!entry || entry.kind === 'builtin')
|
|
423
|
+
return null;
|
|
424
|
+
// 目录家走目录层: 先保证目录文件被读过 (全新进程里 status/list 也要看得到盘上的最新目录)
|
|
425
|
+
if (entry.kind === 'catalog') {
|
|
426
|
+
const cat = await import('./provider-catalog.js');
|
|
427
|
+
await cat.initializeProviderCatalog();
|
|
428
|
+
const fresh = reg.getProviderRegistryEntry(id);
|
|
429
|
+
if (fresh && fresh.kind === 'catalog')
|
|
430
|
+
return fresh;
|
|
431
|
+
}
|
|
432
|
+
return entry;
|
|
433
|
+
}
|
|
434
|
+
catch {
|
|
435
|
+
return null;
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
/**
|
|
439
|
+
* 一家供应商的模型条目。
|
|
440
|
+
* 当前生效的那一条**置顶** (不在内置清单里也要出现 —— 用户配了自定义 model 时必须看得到它)。
|
|
441
|
+
*/
|
|
442
|
+
export async function listModelsFor(providerId, opts = {}) {
|
|
443
|
+
const providers = await providerConfigMap();
|
|
444
|
+
const def = DEFAULT_PROVIDER_CONFIGS[providerId];
|
|
445
|
+
// 2026-09-26 (P6): 自定义供应商也要能列模型 —— 此前这里在内置表里查不到就直接返回空数组,
|
|
446
|
+
// 于是"从列表里点自定义供应商"看到的是一张空清单。事实全部来自注册表 (声明), 不在这里编。
|
|
447
|
+
const customEntry = def ? null : await customRegistryEntryOf(providerId);
|
|
448
|
+
if (!def && !customEntry)
|
|
449
|
+
return [];
|
|
450
|
+
const cfg = providers[providerId];
|
|
451
|
+
const eff = await effectiveModelConfig({ sessionKey: opts.sessionKey }).catch(() => null);
|
|
452
|
+
const needsKey = def
|
|
453
|
+
? ((PROVIDER_INFO[providerId]?.requiresApiKey ?? def.requiresApiKey) !== false)
|
|
454
|
+
: (customEntry.requiresApiKey !== false);
|
|
455
|
+
const baseUrl = normalizeBaseUrl(cfg?.baseUrl || def?.baseUrl || customEntry?.defaultBaseUrl || '');
|
|
456
|
+
const keyState = keyStateOf(cfg, providerId);
|
|
457
|
+
const isCatalog = !!customEntry && customEntry.kind === 'catalog';
|
|
458
|
+
// ★ 2026-09-27 (实测改判): **优先用上游/发现到的真目录**。
|
|
459
|
+
// 实测事实: deepseek 的 `/models` 只列 `deepseek-flash`/`deepseek-v4-pro`, 而
|
|
460
|
+
// `deepseek-v4-flash` 发 chat 请求**回 200 并给出 choices** ⇒ `/models` 是"上游愿意列出来的",
|
|
461
|
+
// **不是**"可用模型的全集"。于是这里的规矩是:
|
|
462
|
+
// · 上游目录里有的名字 → 排前面 (真目录优先, 顺序也按上游的);
|
|
463
|
+
// · 内建 curated 表里有、上游没列的 → **保留**并标 `upstreamSeen: false` (界面写"上游未见"),
|
|
464
|
+
// 因为实测这种名字常常真能用 —— 隐藏它才是另一种撒谎;
|
|
465
|
+
// · 一个字节都不**删**。
|
|
466
|
+
// 只读缓存, 不发请求 (列表路径不许偷偷打上游)。
|
|
467
|
+
let upstream;
|
|
468
|
+
try {
|
|
469
|
+
const md = await import('./model-discovery.js');
|
|
470
|
+
upstream = await md.cachedUpstreamModels(providerId, { baseUrl });
|
|
471
|
+
}
|
|
472
|
+
catch {
|
|
473
|
+
upstream = undefined;
|
|
474
|
+
}
|
|
475
|
+
const upstreamModels = upstream?.models?.length ? new Set(upstream.models) : undefined;
|
|
476
|
+
const ctx = {
|
|
477
|
+
provider: providerId,
|
|
478
|
+
requiresApiKey: needsKey,
|
|
479
|
+
credentialReady: keyState === 'configured' || keyState === 'env',
|
|
480
|
+
baseUrl,
|
|
481
|
+
currentProvider: eff?.provider,
|
|
482
|
+
currentModel: eff?.model,
|
|
483
|
+
reachability: opts.probe ? (opts.probe.ok ? 'ok' : 'failed') : 'unknown',
|
|
484
|
+
failureReason: opts.probe && !opts.probe.ok ? opts.probe.detail : undefined,
|
|
485
|
+
// 目录家的清单来自目录快照: 脚注要说"目录里没声明", 不许说成"内置目录只有模型 ID"
|
|
486
|
+
...(isCatalog ? {
|
|
487
|
+
originDefault: 'catalog',
|
|
488
|
+
unknownReason: `供应商目录 (models.dev 快照) 里这一条没有字面声明该能力 → 未知 (不许编)`,
|
|
489
|
+
} : {}),
|
|
490
|
+
...(upstreamModels ? { upstreamModels, upstreamOrigin: upstream.origin } : {}),
|
|
491
|
+
};
|
|
492
|
+
const declared = def ? curatedModelIds(providerId) : (customEntry.declaredModelIds || []);
|
|
493
|
+
const seen = new Set();
|
|
494
|
+
const order = [];
|
|
495
|
+
// ① 上游真目录优先 (顺序也用上游的)
|
|
496
|
+
for (const m of upstream?.models ?? []) {
|
|
497
|
+
if (m && !seen.has(m)) {
|
|
498
|
+
seen.add(m);
|
|
499
|
+
order.push(m);
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
// ② 配置里写着的 model 与当前生效的 model 都必须出现在清单里 (可能不在任何目录中)
|
|
503
|
+
for (const extra of [eff?.provider === providerId ? eff.model : '', cfg?.model || '', ...(opts.extra || [])]) {
|
|
504
|
+
const m = String(extra || '').trim();
|
|
505
|
+
if (m && !seen.has(m)) {
|
|
506
|
+
seen.add(m);
|
|
507
|
+
order.push(m);
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
// ③ 内建/声明的清单 (上游没列的会被标成"上游未见", 但一条都不删)
|
|
511
|
+
for (const m of declared) {
|
|
512
|
+
if (!seen.has(m)) {
|
|
513
|
+
seen.add(m);
|
|
514
|
+
order.push(m);
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
const entries = order.map((m) => buildModelEntry(m, ctx));
|
|
518
|
+
// 当前生效的置顶 (稳定: 其余保持原顺序)
|
|
519
|
+
const cur = entries.filter((e) => e.current);
|
|
520
|
+
const rest = entries.filter((e) => !e.current);
|
|
521
|
+
return [...cur, ...rest];
|
|
522
|
+
}
|
|
523
|
+
// ============================================================
|
|
524
|
+
// 模糊搜索 (纯函数)
|
|
525
|
+
// ============================================================
|
|
526
|
+
/**
|
|
527
|
+
* 模糊匹配打分 (纯函数)。`null` = 不匹配。
|
|
528
|
+
*
|
|
529
|
+
* 规则刻意简单可解释: 全部字符按顺序出现在目标里就算匹配 (子序列),
|
|
530
|
+
* 连续命中给更高分; 完整子串命中直接给高权重。大小写不敏感, 空格与 `-`/`_`/`.` 视为同级分隔符。
|
|
531
|
+
*/
|
|
532
|
+
export function fuzzyScore(haystack, needle) {
|
|
533
|
+
const h = String(haystack || '').toLowerCase();
|
|
534
|
+
const raw = String(needle || '').trim().toLowerCase();
|
|
535
|
+
if (!raw)
|
|
536
|
+
return 0;
|
|
537
|
+
if (h.includes(raw))
|
|
538
|
+
return 1000 - h.indexOf(raw);
|
|
539
|
+
const n = raw.replace(/[\s]+/g, '');
|
|
540
|
+
const target = h.replace(/[\s]+/g, '');
|
|
541
|
+
let i = 0, score = 0, streak = 0;
|
|
542
|
+
for (const ch of target) {
|
|
543
|
+
if (ch === n[i]) {
|
|
544
|
+
i++;
|
|
545
|
+
streak++;
|
|
546
|
+
score += 1 + streak;
|
|
547
|
+
}
|
|
548
|
+
else
|
|
549
|
+
streak = 0;
|
|
550
|
+
if (i >= n.length)
|
|
551
|
+
break;
|
|
552
|
+
}
|
|
553
|
+
return i >= n.length ? score : null;
|
|
554
|
+
}
|
|
555
|
+
/** 按模糊分排序 (平分保持原顺序); `query` 为空 = 原样返回 */
|
|
556
|
+
export function searchModelEntries(entries, query) {
|
|
557
|
+
const q = String(query || '').trim();
|
|
558
|
+
if (!q)
|
|
559
|
+
return entries;
|
|
560
|
+
const scored = [];
|
|
561
|
+
entries.forEach((e, i) => {
|
|
562
|
+
const a = fuzzyScore(e.id, q);
|
|
563
|
+
const b = fuzzyScore(e.displayName, q);
|
|
564
|
+
const s = a === null && b === null ? null : Math.max(a ?? -1, b ?? -1);
|
|
565
|
+
if (s !== null)
|
|
566
|
+
scored.push({ e, s, i });
|
|
567
|
+
});
|
|
568
|
+
scored.sort((x, y) => (y.s - x.s) || (x.i - y.i));
|
|
569
|
+
return scored.map((x) => x.e);
|
|
570
|
+
}
|
|
571
|
+
// ============================================================
|
|
572
|
+
// 展示
|
|
573
|
+
// ============================================================
|
|
574
|
+
/** 供应商列表行: `● <供应商> · N models` / `○ <供应商> · N models · 未配置 key` / `● <供应商> · 本地` */
|
|
575
|
+
export function formatProviderLine(s) {
|
|
576
|
+
const isCatalog = s.origin === 'catalog';
|
|
577
|
+
const mark = s.configured ? '●' : '○';
|
|
578
|
+
const bits = [];
|
|
579
|
+
if (isCatalog)
|
|
580
|
+
bits.push(`目录 · 族 ${s.catalogFamily || '未标'}`);
|
|
581
|
+
if (s.isLocal)
|
|
582
|
+
bits.push('本地');
|
|
583
|
+
bits.push(s.modelCountOrigin === 'unavailable'
|
|
584
|
+
? '无内置目录'
|
|
585
|
+
: `${s.modelCount} models${s.modelCountOrigin === 'catalog' ? ' (目录)' : ''}`);
|
|
586
|
+
if (!s.configured && s.requiresApiKey) {
|
|
587
|
+
const envName = isCatalog
|
|
588
|
+
? (s.catalogEnvVar || '环境变量名缺失')
|
|
589
|
+
: (envKeyNamesOf(s.id).join('/') || '环境变量');
|
|
590
|
+
bits.push(`未配置 key (${envName})`);
|
|
591
|
+
}
|
|
592
|
+
// 目录家不能用时**如实标**: 需专用鉴权 (未支持) / 目录里没给基址 —— 不许假装可用
|
|
593
|
+
if (isCatalog && s.catalogNote)
|
|
594
|
+
bits.push(`⚠ ${s.catalogNote}`);
|
|
595
|
+
if (s.current)
|
|
596
|
+
bits.push('← 当前');
|
|
597
|
+
// 两处说法不一致就如实标 (不挑一个装作不知道)
|
|
598
|
+
if (s.requiresKeyConflict)
|
|
599
|
+
bits.push(`⚠ key 要求不一致 (注册表说${s.requiresApiKey ? '要' : '不要'} / 配置里说${s.configRequiresKey ? '要' : '不要'})`);
|
|
600
|
+
return `${mark} ${s.id} · ${bits.join(' · ')}`;
|
|
601
|
+
}
|
|
602
|
+
/**
|
|
603
|
+
* 菜单行 (全屏选择器用): 比 `formatProviderLine` **更紧凑** —— 名字 · 模型数 · 凭证 · 族,
|
|
604
|
+
* 状态全用**符号/文字**表达 (颜色只是第二通道):
|
|
605
|
+
*
|
|
606
|
+
* · `●` 可用 (有凭证或免 key) / `○` 未配置凭据;
|
|
607
|
+
* · `← 当前` 这一家就是当前生效的;
|
|
608
|
+
* · `special (需专用鉴权, 未支持)` —— 目录里这一家的鉴权形状本运行时不支持;
|
|
609
|
+
* · `无基址 (需自定义 baseUrl)` —— 目录里没给 api 基址;
|
|
610
|
+
* · `内置|自定义|目录` + `族 <family>` —— 这一行从哪来。
|
|
611
|
+
*
|
|
612
|
+
* 只做排版, 一个字段都不产生 (真事实全来自 `ProviderSummary`)。
|
|
613
|
+
*/
|
|
614
|
+
/**
|
|
615
|
+
* 供应商菜单行 (全屏选择器用): 一行的**截断存活优先级**就是它的字段顺序 ——
|
|
616
|
+
* 窄终端/长行会把尾巴切掉, 所以"为什么这家不能用"必须排在最前面, 计数排在后面。
|
|
617
|
+
*
|
|
618
|
+
* ① 不可用/需额外条件的原因 (`special (需专用鉴权, 未支持)` / `无基址 (需自定义 baseUrl)` / key 要求不一致)
|
|
619
|
+
* ② 凭证状态 (`免 key` / `key 已配` / `key 来自 env` / `缺 key (VAR)`)
|
|
620
|
+
* ③ 计数与来源 (模型数 / 内置·自定义·目录 / 族 / 本地)
|
|
621
|
+
* ④ `← 当前`
|
|
622
|
+
*/
|
|
623
|
+
export function formatProviderMenuRow(s) {
|
|
624
|
+
const mark = s.configured ? '●' : '○';
|
|
625
|
+
const bits = [];
|
|
626
|
+
// ① 原因类 (最不经得起截断)
|
|
627
|
+
if (s.catalogAuthUnsupported)
|
|
628
|
+
bits.push('special (需专用鉴权, 未支持)');
|
|
629
|
+
if (s.catalogBaseUrlMissing)
|
|
630
|
+
bits.push('无基址 (需自定义 baseUrl)');
|
|
631
|
+
if (s.requiresKeyConflict)
|
|
632
|
+
bits.push('⚠ key 要求不一致');
|
|
633
|
+
// ② 凭证状态
|
|
634
|
+
bits.push(!s.requiresApiKey
|
|
635
|
+
? '免 key'
|
|
636
|
+
: s.keyState === 'configured' ? 'key 已配'
|
|
637
|
+
: s.keyState === 'env' ? 'key 来自 env'
|
|
638
|
+
: `缺 key${s.catalogEnvVar ? ` (${s.catalogEnvVar})` : ''}`);
|
|
639
|
+
// ③ 计数与来源
|
|
640
|
+
bits.push(s.modelCountOrigin === 'unavailable' ? '模型数未知' : `${s.modelCount} models`);
|
|
641
|
+
bits.push(s.origin === 'catalog' ? '目录' : s.origin === 'custom' ? '自定义' : '内置');
|
|
642
|
+
if (s.origin === 'catalog' && s.catalogFamily)
|
|
643
|
+
bits.push(`族 ${s.catalogFamily}`);
|
|
644
|
+
if (s.isLocal)
|
|
645
|
+
bits.push('本地');
|
|
646
|
+
// ④ 当前
|
|
647
|
+
if (s.current)
|
|
648
|
+
bits.push('← 当前');
|
|
649
|
+
return `${mark} ${s.id} · ${bits.join(' · ')}`;
|
|
650
|
+
}
|
|
651
|
+
/** 供应商行的**语义 tone** (只给颜色用; 语义本体在符号/文字里) */
|
|
652
|
+
export function providerRowTone(s) {
|
|
653
|
+
// "不能用/需额外条件"两类都用 warn (leo 2026-09-27: special 与 无基址 显眼)
|
|
654
|
+
if (s.catalogAuthUnsupported || s.catalogBaseUrlMissing)
|
|
655
|
+
return 'warn';
|
|
656
|
+
if (s.current)
|
|
657
|
+
return 'accent';
|
|
658
|
+
return s.configured ? 'ok' : 'dim';
|
|
659
|
+
}
|
|
660
|
+
/**
|
|
661
|
+
* 模型菜单行 (全屏选择器用): **一行只放这一行独有的事实**。
|
|
662
|
+
*
|
|
663
|
+
* 版面纪律 (2026-09-27 简化后):
|
|
664
|
+
* · 未知字段**一律不显示** —— 不逐行复读 `工具调用=未知 · reasoning=未知 · 上下文=未知`,
|
|
665
|
+
* 那是每行都一样的话, 由页脚统一说一次 (或什么都不说);
|
|
666
|
+
* · 来源要标出来: `上游` / `上游(缓存)` / `上游未见` (测过: 上游未列 ≠ 不能用) / `手输`;
|
|
667
|
+
* · 能力 (上下文/reasoning/tools) 只在本条**有真数据**时才出现。
|
|
668
|
+
*/
|
|
669
|
+
export function formatModelMenuRow(e) {
|
|
670
|
+
const bits = [];
|
|
671
|
+
if (e.upstreamSeen === true)
|
|
672
|
+
bits.push(e.upstreamOrigin === 'live' ? '上游' : '上游(缓存)');
|
|
673
|
+
else if (e.upstreamSeen === false)
|
|
674
|
+
bits.push('上游未见');
|
|
675
|
+
if (e.origin === 'custom')
|
|
676
|
+
bits.push('手输');
|
|
677
|
+
if (typeof e.contextLength === 'number' && e.contextLength > 0) {
|
|
678
|
+
bits.push(e.contextLength >= 1000 ? `${Math.round(e.contextLength / 1000)}k` : String(e.contextLength));
|
|
679
|
+
}
|
|
680
|
+
if (e.reasoning === 'yes')
|
|
681
|
+
bits.push('reasoning');
|
|
682
|
+
if (e.toolCalling === 'yes')
|
|
683
|
+
bits.push('tools');
|
|
684
|
+
if (e.isLocal)
|
|
685
|
+
bits.push('本地');
|
|
686
|
+
if (e.reachability === 'failed')
|
|
687
|
+
bits.push(`连不上 (${e.failureReason || '原因未知'})`);
|
|
688
|
+
if (e.current)
|
|
689
|
+
bits.push('← 当前');
|
|
690
|
+
return bits.length ? `${e.id} · ${bits.join(' · ')}` : e.id;
|
|
691
|
+
}
|
|
692
|
+
/** 模型条目行: 当前置顶标记 + 原始 ID + 能力三态 + 凭证状态 + 本地/远端 + 失败原因 */
|
|
693
|
+
export function formatModelLine(e) {
|
|
694
|
+
// 凭证一栏由两个**真事实**合成: 注册表(这家要不要 key) + 手上现在有没有
|
|
695
|
+
const cred = !e.requiresApiKey ? '免 key' : (e.credentialReady ? 'key 已配' : '缺 key');
|
|
696
|
+
const bits = [
|
|
697
|
+
`工具调用=${capabilityZh(e.toolCalling)}`,
|
|
698
|
+
`reasoning=${capabilityZh(e.reasoning)}`,
|
|
699
|
+
`上下文=${contextZh(e.contextLength)}`,
|
|
700
|
+
cred,
|
|
701
|
+
e.isLocal ? '本地端点' : '远端端点',
|
|
702
|
+
];
|
|
703
|
+
let line = `${e.current ? '▸' : ' '} ${e.id.padEnd(36)} ${bits.join(' · ')}`;
|
|
704
|
+
if (e.reachability === 'failed')
|
|
705
|
+
line += ` · ⚠ 连接失败: ${String(e.failureReason || '原因未明').slice(0, 80)}`;
|
|
706
|
+
return line;
|
|
707
|
+
}
|
|
708
|
+
/** 能力未知的说明 (列表脚注, 只打不重复的原因) */
|
|
709
|
+
export function unknownFootnote(entries) {
|
|
710
|
+
const reasons = new Set();
|
|
711
|
+
for (const e of entries)
|
|
712
|
+
for (const u of e.unknowns)
|
|
713
|
+
reasons.add(u.reason);
|
|
714
|
+
if (!reasons.size)
|
|
715
|
+
return [];
|
|
716
|
+
return [...reasons].map((r) => `未知原因: ${r}`);
|
|
717
|
+
}
|