@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,220 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* tool-name.ts — 工具名「出网前的唯一净化边界」(2026-09-26)
|
|
3
|
+
*
|
|
4
|
+
* 为什么要这个文件:
|
|
5
|
+
* OpenAI 兼容 API 对 `tools[i].function.name` 有硬约束 —— 必须匹配
|
|
6
|
+
* `^[a-zA-Z0-9_-]{1,64}$`. 只要**第 121 个**工具的名字里有一个非法字符 (`.` `:` `/`
|
|
7
|
+
* 空格 / 中文 ...), 整个请求就被 400 拒掉:
|
|
8
|
+
*
|
|
9
|
+
* Invalid 'tools[120].function.name': string does not match pattern.
|
|
10
|
+
* Expected a string that matches the pattern '^[a-zA-Z0-9_-]+$'.
|
|
11
|
+
*
|
|
12
|
+
* 表现是「网页端明明配好了 API, 一跑就失败」—— 根因在**发包形状**, 不在凭据。
|
|
13
|
+
* 本仓曾注册 6 个带点的工具名 (`contact.list_authorized` 一族), 命中这个坑。
|
|
14
|
+
*
|
|
15
|
+
* 设计铁律:
|
|
16
|
+
* 1. **唯一边界**: 净化只在 `pi-ai.ts` 的 `generateText()` 里做一次 (那里是全仓唯一
|
|
17
|
+
* 产出 wire 形状 tools 数组的地方). 业务代码一个都不许自己做净化 —— 否则同一次改动
|
|
18
|
+
* 要有 N 个调用点跟着改, 迟早漏一个.
|
|
19
|
+
* 2. **确定性, 不是随机**: 非法字符 → `_`; 超长 → 前 55 字符 + `_` + 原名 sha256 前 8 位
|
|
20
|
+
* (同一次运行里同一原名永远得到同一个 API 名).
|
|
21
|
+
* 3. **派发不断**: 净化时把 `原名 → API 名` 同时反向登记 (原名 ↔ API 名双射), 回程
|
|
22
|
+
* (`resolveApiToolName`) 把 LLM 回吐的 API 名还原成原名, 再交给 `this.tools.get()`.
|
|
23
|
+
* LLM 也可能照 system prompt 里的原名回吐 —— 原名在表里就直接穿透.
|
|
24
|
+
* 4. **碰撞不许静默**: 两个**不同**原名净化后同名 → 抛 `ToolNameCollisionError`, 消息里
|
|
25
|
+
* 把两个原名都点名. 绝不悄悄合并或悄悄改名 (那会让 LLM 调到的工具和它以为的不是一个).
|
|
26
|
+
* 5. **不猜**: 没有名字 / 空名字的工具也是非法形状 → 抛错点名, 不静默丢掉.
|
|
27
|
+
*
|
|
28
|
+
* 真正「覆盖所有入口」的依据: CLI / Web / MCP / 技能 / 子 Agent / 长期任务全都走
|
|
29
|
+
* `getMinimax().chat(..., tools)` → `PiAIModel.generateText()` → `callOpenAI()`,
|
|
30
|
+
* 全仓只有这一条产出 tools 的路径 (见 `scripts/verify-tool-names.ts` 的源码级断言).
|
|
31
|
+
*/
|
|
32
|
+
import * as crypto from 'crypto';
|
|
33
|
+
/** OpenAI 兼容 API 对 function.name 的硬约束 */
|
|
34
|
+
export const TOOL_NAME_MAX_LENGTH = 64;
|
|
35
|
+
/** 允许的字符集: 字母 / 数字 / 下划线 / 连字符 */
|
|
36
|
+
export const TOOL_NAME_CHAR_CLASS = 'a-zA-Z0-9_-';
|
|
37
|
+
/** 完整形状约束 (1..64, 只含安全字符) */
|
|
38
|
+
export const TOOL_NAME_PATTERN = /^[a-zA-Z0-9_-]{1,64}$/;
|
|
39
|
+
/** 太长时的截断长度: 55 + '_' + 8 位 hash = 64, 正好贴着上限 */
|
|
40
|
+
const TRUNCATE_KEEP = 55;
|
|
41
|
+
/** 严格形状校验 (不做任何修补) */
|
|
42
|
+
export function isValidToolName(name) {
|
|
43
|
+
return typeof name === 'string' && TOOL_NAME_PATTERN.test(name);
|
|
44
|
+
}
|
|
45
|
+
/** 名字里的非法字符 (去重, 按出现顺序) —— 给探针/门报「违规字符是什么」用 */
|
|
46
|
+
export function illegalCharsOf(name) {
|
|
47
|
+
const out = [];
|
|
48
|
+
for (const ch of String(name ?? '')) {
|
|
49
|
+
if (!/[a-zA-Z0-9_-]/.test(ch) && !out.includes(ch))
|
|
50
|
+
out.push(ch);
|
|
51
|
+
}
|
|
52
|
+
return out;
|
|
53
|
+
}
|
|
54
|
+
/** 原名指纹 (sha256 前 8 位 hex) —— 超长截断的去碰撞后缀 */
|
|
55
|
+
export function toolNameFingerprint(name) {
|
|
56
|
+
return crypto.createHash('sha256').update(String(name), 'utf8').digest('hex').slice(0, 8);
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* 纯函数净化: 非法字符 → `_`; 超长 → 截断 + 稳定 hash 后缀.
|
|
60
|
+
* 已经是合法名的原样返回 (幂等, 再跑一次不变).
|
|
61
|
+
*/
|
|
62
|
+
export function sanitizeToolName(name) {
|
|
63
|
+
const raw = String(name ?? '');
|
|
64
|
+
let s = raw.replace(new RegExp(`[^${TOOL_NAME_CHAR_CLASS}]`, 'g'), '_');
|
|
65
|
+
if (s.length > TOOL_NAME_MAX_LENGTH) {
|
|
66
|
+
s = `${s.slice(0, TRUNCATE_KEEP)}_${toolNameFingerprint(raw)}`;
|
|
67
|
+
}
|
|
68
|
+
// 空名 / 全非法字符且被清空 (理论上不会, 上面都是替换成 '_') —— 兜底成可派发的稳定名
|
|
69
|
+
if (s.length === 0)
|
|
70
|
+
s = `tool_${toolNameFingerprint(raw)}`;
|
|
71
|
+
return s;
|
|
72
|
+
}
|
|
73
|
+
/** 两个不同原名净化后撞名 —— 点名两方, 拒绝发包 */
|
|
74
|
+
export class ToolNameCollisionError extends Error {
|
|
75
|
+
apiName;
|
|
76
|
+
names;
|
|
77
|
+
constructor(apiName, names) {
|
|
78
|
+
super(`工具名净化后撞名: ${names.map((n) => `"${n}"`).join(' 与 ')} 都会净化为 "${apiName}" —— ` +
|
|
79
|
+
`拒绝发包 (两个不同工具共用一个 API 名会让 LLM 调到的实现不可知). ` +
|
|
80
|
+
`请给其中一个改名, 只剩安全字符 [${TOOL_NAME_CHAR_CLASS}] 且 ≤64 字符.`);
|
|
81
|
+
this.name = 'ToolNameCollisionError';
|
|
82
|
+
this.apiName = apiName;
|
|
83
|
+
this.names = names;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* 原名 ↔ API 名 双射表.
|
|
88
|
+
* 正向: 出网时把原名登记进来拿 API 名 (碰撞 → 抛错, 不静默).
|
|
89
|
+
* 反向: 回程把 LLM 回吐的 API 名还原成原名 (未知名原样穿透, 由业务侧报「未知工具」).
|
|
90
|
+
*/
|
|
91
|
+
export class ToolNameRouteTable {
|
|
92
|
+
byApi = new Map();
|
|
93
|
+
byOriginal = new Map();
|
|
94
|
+
/** 登记一个原名, 返回它的 API 名. 撞名 → ToolNameCollisionError. */
|
|
95
|
+
register(original) {
|
|
96
|
+
const orig = String(original ?? '');
|
|
97
|
+
if (isValidToolName(orig)) {
|
|
98
|
+
// 合法名不动: 但也要防「另一个原名净化成它」的撞名 (下面 byApi 会处理)
|
|
99
|
+
const api = orig;
|
|
100
|
+
const holder = this.byApi.get(api);
|
|
101
|
+
if (holder !== undefined && holder !== orig) {
|
|
102
|
+
throw new ToolNameCollisionError(api, [holder, orig]);
|
|
103
|
+
}
|
|
104
|
+
this.byApi.set(api, orig);
|
|
105
|
+
this.byOriginal.set(orig, api);
|
|
106
|
+
return api;
|
|
107
|
+
}
|
|
108
|
+
const api = sanitizeToolName(orig);
|
|
109
|
+
const holder = this.byApi.get(api);
|
|
110
|
+
if (holder !== undefined && holder !== orig) {
|
|
111
|
+
throw new ToolNameCollisionError(api, [holder, orig]);
|
|
112
|
+
}
|
|
113
|
+
this.byApi.set(api, orig);
|
|
114
|
+
this.byOriginal.set(orig, api);
|
|
115
|
+
return api;
|
|
116
|
+
}
|
|
117
|
+
/** 批量登记 */
|
|
118
|
+
registerAll(originals) {
|
|
119
|
+
return originals.map((n) => this.register(n));
|
|
120
|
+
}
|
|
121
|
+
/** 原名 → API 名 (没登记过就现算, 不登记 —— 给纯查询用) */
|
|
122
|
+
apiNameOf(original) {
|
|
123
|
+
const known = this.byOriginal.get(String(original ?? ''));
|
|
124
|
+
if (known !== undefined)
|
|
125
|
+
return known;
|
|
126
|
+
return isValidToolName(original) ? original : sanitizeToolName(original);
|
|
127
|
+
}
|
|
128
|
+
/**
|
|
129
|
+
* API 名 → 原名 (回程派发用).
|
|
130
|
+
* 不在表里 (LLM 照 system prompt 里的原名回吐 / 编了个名字) → 原样返回,
|
|
131
|
+
* 交给业务侧 `tools.get()` 走「未知工具」分支 — 不在这里吞掉.
|
|
132
|
+
*/
|
|
133
|
+
resolveToOriginal(apiName) {
|
|
134
|
+
const hit = this.byApi.get(String(apiName ?? ''));
|
|
135
|
+
return hit !== undefined ? hit : String(apiName ?? '');
|
|
136
|
+
}
|
|
137
|
+
/** 是否知道这个 API 名 */
|
|
138
|
+
knows(apiName) {
|
|
139
|
+
return this.byApi.has(String(apiName ?? ''));
|
|
140
|
+
}
|
|
141
|
+
/** 全部映射 (原名 → API 名), 按登记顺序 */
|
|
142
|
+
get mappings() {
|
|
143
|
+
const seen = new Set();
|
|
144
|
+
const out = [];
|
|
145
|
+
for (const [api, original] of this.byApi.entries()) {
|
|
146
|
+
if (seen.has(original))
|
|
147
|
+
continue;
|
|
148
|
+
seen.add(original);
|
|
149
|
+
out.push({ original, api, changed: original !== api });
|
|
150
|
+
}
|
|
151
|
+
return out;
|
|
152
|
+
}
|
|
153
|
+
/** 被改写过的部分 (改名清单) */
|
|
154
|
+
get changed() {
|
|
155
|
+
return this.mappings.filter((m) => m.changed);
|
|
156
|
+
}
|
|
157
|
+
/** 表里已知的原名个数 */
|
|
158
|
+
get size() {
|
|
159
|
+
return this.byApi.size;
|
|
160
|
+
}
|
|
161
|
+
/** 清空 (测试用; 业务路径不该调) */
|
|
162
|
+
clear() {
|
|
163
|
+
this.byApi.clear();
|
|
164
|
+
this.byOriginal.clear();
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
/**
|
|
168
|
+
* 进程级单例: 出网净化与回程派发共用同一张表.
|
|
169
|
+
* (出网在 pi-ai.ts, 派发在 pi-sdk.ts / workflow-pivot-loop.ts —— 只有共享一份才保证还原得到真名)
|
|
170
|
+
*/
|
|
171
|
+
export const globalToolNameRoutes = new ToolNameRouteTable();
|
|
172
|
+
/**
|
|
173
|
+
* **唯一**的出网净化入口: 把 wire 形状的 tools 数组里的 `function.name` 逐个净化,
|
|
174
|
+
* 顺便把映射登记进 `globalToolNameRoutes` 供回程派发还原.
|
|
175
|
+
*
|
|
176
|
+
* - 不改入参 (返回新数组 / 新对象), 调用方拿去发包即可;
|
|
177
|
+
* - 没名字 / 空名字 → 抛错 (说不出名字的工具本来就是坏形状, 不许静默丢);
|
|
178
|
+
* - 撞名 → ToolNameCollisionError (点名两个原名).
|
|
179
|
+
*/
|
|
180
|
+
export function sanitizeToolsForApi(tools, table = globalToolNameRoutes) {
|
|
181
|
+
if (!Array.isArray(tools))
|
|
182
|
+
return tools;
|
|
183
|
+
const out = new Array(tools.length);
|
|
184
|
+
for (let i = 0; i < tools.length; i++) {
|
|
185
|
+
const tool = tools[i];
|
|
186
|
+
const original = tool?.function?.name;
|
|
187
|
+
if (typeof original !== 'string' || original.length === 0) {
|
|
188
|
+
throw new Error(`工具 #${i} 没有可用的 function.name (拿到 ${JSON.stringify(original)}) —— 拒绝发包: ` +
|
|
189
|
+
`OpenAI 兼容 API 要求每个工具都有 name 且匹配 /^[a-zA-Z0-9_-]{1,64}$/`);
|
|
190
|
+
}
|
|
191
|
+
const api = table.register(original);
|
|
192
|
+
out[i] = api === original ? tool : { ...tool, function: { ...tool.function, name: api } };
|
|
193
|
+
}
|
|
194
|
+
return out;
|
|
195
|
+
}
|
|
196
|
+
/** 回程: LLM 回吐的名字 → 注册表真名 (未知名原样穿透) */
|
|
197
|
+
export function resolveApiToolName(apiName, table = globalToolNameRoutes) {
|
|
198
|
+
return table.resolveToOriginal(apiName);
|
|
199
|
+
}
|
|
200
|
+
/**
|
|
201
|
+
* 给「文本解析」路径用的已知名集合: 原名 + 它们的 API 名.
|
|
202
|
+
* LLM 在文本里回吐的可能是净化后的名字 (prompt/工具面里给的是 API 名),
|
|
203
|
+
* 只用原名集合过滤会把这类调用直接丢掉.
|
|
204
|
+
*/
|
|
205
|
+
export function expandKnownToolNames(names, table = globalToolNameRoutes) {
|
|
206
|
+
const out = new Set();
|
|
207
|
+
for (const n of names) {
|
|
208
|
+
out.add(n);
|
|
209
|
+
const api = table.apiNameOf(n);
|
|
210
|
+
out.add(api);
|
|
211
|
+
// 预登记, 保证回程还原得到
|
|
212
|
+
try {
|
|
213
|
+
table.register(n);
|
|
214
|
+
}
|
|
215
|
+
catch {
|
|
216
|
+
// 撞名: 交给出网时的 sanitizeToolsForApi 抛 (那里能给出「拒绝发包」的上下文)
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
return out;
|
|
220
|
+
}
|
package/dist/setup/onboard.js
CHANGED
|
@@ -104,9 +104,9 @@ const stepProvider = {
|
|
|
104
104
|
}
|
|
105
105
|
try {
|
|
106
106
|
await store.updateProvider(picked, { enabled: true });
|
|
107
|
-
//
|
|
108
|
-
|
|
109
|
-
|
|
107
|
+
// 2026-09-26 (P6): 这里**不再**直接 `setActiveProvider` —— 激活是"切换", 只能走统一入口,
|
|
108
|
+
// 而且必须等 key/模型齐了、连通实测过了才切 (见 stepConnectivity 的成功分支)。
|
|
109
|
+
// 早切的问题: 那时还没有 key, 切过去等于把 effective 配置指到一个用不了的供应商。
|
|
110
110
|
}
|
|
111
111
|
catch (err) {
|
|
112
112
|
return { ok: false, errorClass: 'io', message: `写配置失败: ${String(err?.message || err).slice(0, 120)}` };
|
|
@@ -114,7 +114,7 @@ const stepProvider = {
|
|
|
114
114
|
return {
|
|
115
115
|
ok: true,
|
|
116
116
|
patch: { inputs: { provider: picked }, checks: { providerSelected: true } },
|
|
117
|
-
note: `${picked} 已 ${c.mode === 'reconfigure' ? '标记为候选 (测试通过后切换)' : '
|
|
117
|
+
note: `${picked} 已 ${c.mode === 'reconfigure' ? '标记为候选 (测试通过后切换)' : '标记为待启用 (连通实测通过后由统一入口切换)'}`,
|
|
118
118
|
};
|
|
119
119
|
},
|
|
120
120
|
};
|
|
@@ -202,7 +202,30 @@ const stepConnectivity = {
|
|
|
202
202
|
c.io.print(`用最终保存的 provider/key/baseUrl/model 真实测试 ${provider} …`);
|
|
203
203
|
const res = await store.testProvider(provider);
|
|
204
204
|
if (res?.success) {
|
|
205
|
-
|
|
205
|
+
// 2026-09-26 (P6): 测试通过之后**才**真正切换 —— 而且只有一处实现: 统一入口 `selectModel()`。
|
|
206
|
+
// 此前 setup 模式在 stepProvider 里直接 `setActiveProvider` (那时还没有 key, 也没探测过),
|
|
207
|
+
// reconfigure 模式则根本不切换 —— 两个都不算"同一语义"。
|
|
208
|
+
// 这里 `verify:false`: 刚在上面真测过一次, 入口不用再打一次上游 (重复探测是浪费, 不是严谨)。
|
|
209
|
+
try {
|
|
210
|
+
const { selectModel } = await import('../llm/model-selection.js');
|
|
211
|
+
const sw = await selectModel({ provider, model: c.state.inputs.model, scope: 'global', verify: false });
|
|
212
|
+
if (!sw.ok) {
|
|
213
|
+
return {
|
|
214
|
+
ok: false,
|
|
215
|
+
errorClass: sw.failureClass === 'missing_api_key' ? 'auth' : 'config',
|
|
216
|
+
message: `连接是通的, 但切换没有落盘 [${sw.failureClass}]: ${sw.message}`,
|
|
217
|
+
retryable: false,
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
return {
|
|
221
|
+
ok: true,
|
|
222
|
+
patch: { checks: { connectivityOk: true, connectivityAt: new Date().toISOString(), connectivityErrorClass: undefined } },
|
|
223
|
+
note: `通过 (${res.latency ?? '?'}ms) · 已由统一入口切到 ${sw.effective.provider}/${sw.effective.model}`,
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
catch (err) {
|
|
227
|
+
return { ok: false, errorClass: 'runtime', message: `连通但切换失败: ${String(err?.message || err).slice(0, 160)}` };
|
|
228
|
+
}
|
|
206
229
|
}
|
|
207
230
|
const raw = String(res?.error || '未知错误');
|
|
208
231
|
const cls = /401|403|key/i.test(raw) ? 'auth' : /429|限流/i.test(raw) ? 'network' : /404|端点|baseUrl/i.test(raw) ? 'config' : /timeout|超时/i.test(raw) ? 'timeout' : /fetch|network|ECONN/i.test(raw) ? 'network' : 'unknown';
|
|
@@ -220,11 +243,14 @@ const stepRuntime = {
|
|
|
220
243
|
const details = [];
|
|
221
244
|
try {
|
|
222
245
|
const mod = await import('../llm/pi-ai.js');
|
|
223
|
-
|
|
246
|
+
// 2026-09-26 (P6): 运行时**只由统一入口装配** —— `installRuntime` 是唯一那个 initMinimax 调用点,
|
|
247
|
+
// 这里不再自己 `initMinimax()` (那会让"按配置文件装配"和"按有效配置装配"分叉成两条)。
|
|
248
|
+
const { applyEffectiveToRuntime } = await import('../llm/model-selection.js');
|
|
249
|
+
const eff = await applyEffectiveToRuntime();
|
|
224
250
|
const model = mod.getMinimax?.() || mod.getModel?.();
|
|
225
251
|
if (!model)
|
|
226
|
-
return { ok: false, errorClass: 'runtime', message: '
|
|
227
|
-
details.push(
|
|
252
|
+
return { ok: false, errorClass: 'runtime', message: '统一入口装配之后仍拿不到模型对象' };
|
|
253
|
+
details.push(`统一入口装配 ✓ (${eff.provider}/${eff.model})`);
|
|
228
254
|
const chat = model.chat || model.generate || model.complete || model.call;
|
|
229
255
|
if (typeof chat !== 'function') {
|
|
230
256
|
return { ok: false, errorClass: 'runtime', message: '运行时对象没有可调用的 chat/generate 接口 —— 只有 singleton 不算 ready' };
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dual-source-facts.ts — 双源的**语义核心** (2026-09-26, update-protocol §12)
|
|
3
|
+
*
|
|
4
|
+
* 为什么从 `dual-source.ts` 里抽出这一半: 手机端 WebView **没有 node:**, 但手机端双源更新
|
|
5
|
+
* 必须用**和桌面同一份**交叉校验 / 错误分类 / dev 快照比较 —— 否则同一件事在电脑上和手机上
|
|
6
|
+
* 会长出两套说法 (而"两个源指向不同版本就拒绝"这种事, 两套说法迟早有一处是错的)。
|
|
7
|
+
*
|
|
8
|
+
* 这里只放**纯函数与类型** (不 import 任何 `node:*`, 不在顶层读 `process.env`):
|
|
9
|
+
* · GitHub 侧错误分类 `classifyGithubError` (§12.3, 与 registry 侧并列不合并)
|
|
10
|
+
* · stable 的交叉校验 `crossCheckStable` (§12.1)
|
|
11
|
+
* · dev 的按 sha 比较 `compareDevSnapshots` (§12.1)
|
|
12
|
+
* · 事实/报告的紧凑视图 `toGithubReport` / `renderGithubReportLine`
|
|
13
|
+
*
|
|
14
|
+
* 需要网络与子进程的那一半 (真调 api.github.com / codeload 下载 / 现场构建 dev 快照)
|
|
15
|
+
* 仍在 `dual-source.ts` —— 它在这份纯模块之上, 且把这些名字原样再导出。
|
|
16
|
+
*
|
|
17
|
+
* 硬口径 (§12.5, 验收核心, 两边共用):
|
|
18
|
+
* - 源不可达 / 版本不存在 → **拒绝并说清** (带具体分类), 绝不静默装回旧版
|
|
19
|
+
* - 不编造不存在的路径: 本仓 GitHub 上**真的没有 Release** (0 个), 所以 "Release 缺" 时
|
|
20
|
+
* 如实报 "发布记录缺 Tag / Release" (提醒级), **不是** fake 一个 Release 出来
|
|
21
|
+
*/
|
|
22
|
+
import { GITHUB_UPSTREAM_SLUG, DEV_BRANCH, DEV_CHANNEL_WARNING } from './version-identity.js';
|
|
23
|
+
export { DEV_CHANNEL_WARNING };
|
|
24
|
+
// ── 类型 ────────────────────────────────────────────────────────────────────
|
|
25
|
+
/**
|
|
26
|
+
* GitHub 侧错误的分类 (§12.3) —— 与 registry 的分类**并列且不合并**:
|
|
27
|
+
* offline 网络不可达 (ENOTFOUND/EAI_AGAIN/ECONNREFUSED/超时)
|
|
28
|
+
* rate_limited HTTP 403/429 (限流; 带重试时间)
|
|
29
|
+
* not_found HTTP 404 (没有 Release / 没有 master 分支 / 仓库不存在)
|
|
30
|
+
* http_error 其它 HTTP 状态 (5xx 等) —— 不塞进上面三类, 免得把"服务有问题"说成"网不通"
|
|
31
|
+
* parse_error 拿到了内容但解析不了
|
|
32
|
+
*/
|
|
33
|
+
export const GITHUB_REASONS = ['offline', 'rate_limited', 'not_found', 'http_error', 'parse_error'];
|
|
34
|
+
export function toGithubReport(r) {
|
|
35
|
+
if (!r.ok) {
|
|
36
|
+
return {
|
|
37
|
+
reachable: false, reason: r.reason, detail: r.detail, retryAt: r.retryAt,
|
|
38
|
+
headSha: r.facts?.headSha ?? null, ref: r.facts?.ref ?? `refs/heads/${DEV_BRANCH}`,
|
|
39
|
+
releases: r.facts?.releases.length, tags: r.facts?.tags.length,
|
|
40
|
+
newestVersion: r.facts ? newestGithubVersion(r.facts) : null,
|
|
41
|
+
partial: r.facts?.partial, fetchedAt: r.facts?.fetchedAt,
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
return {
|
|
45
|
+
reachable: true, headSha: r.facts.headSha, ref: r.facts.ref,
|
|
46
|
+
releases: r.facts.releases.length, tags: r.facts.tags.length,
|
|
47
|
+
newestVersion: newestGithubVersion(r.facts), partial: r.facts.partial, fetchedAt: r.facts.fetchedAt,
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
export function renderGithubReportLine(g) {
|
|
51
|
+
if (!g)
|
|
52
|
+
return 'GitHub 源: 未查询';
|
|
53
|
+
if (!g.reachable) {
|
|
54
|
+
const retry = g.retryAt ? `, 限流重试时间 ${g.retryAt}` : '';
|
|
55
|
+
return `GitHub 源: 不可达 — github_unavailable(${g.reason}) ${g.detail || ''}${retry}`;
|
|
56
|
+
}
|
|
57
|
+
return `GitHub 源: 可达 · master HEAD ${g.headSha ? g.headSha.slice(0, 7) : '未知'} · Release ${g.releases ?? 0} 个 · Tag ${g.tags ?? 0} 个 · 最新版本标签 ${g.newestVersion || '无'}${g.partial ? ' (部分请求失败)' : ''}`;
|
|
58
|
+
}
|
|
59
|
+
// ── 错误分类 ────────────────────────────────────────────────────────────────
|
|
60
|
+
export const OFFLINE_CODES = new Set(['ENOTFOUND', 'EAI_AGAIN', 'ECONNREFUSED', 'ECONNRESET', 'ETIMEDOUT', 'EHOSTUNREACH', 'ENETUNREACH', 'ENETDOWN', 'EAI_FAIL', 'UND_ERR_CONNECT_TIMEOUT', 'CERT_HAS_EXPIRED']);
|
|
61
|
+
/**
|
|
62
|
+
* 从**错误链**里挖网络类证据。
|
|
63
|
+
*
|
|
64
|
+
* 为什么不能只看 `err.code`: `fetch()` 抛出来的是 `TypeError: fetch failed`, 真正的 socket 错误挂在
|
|
65
|
+
* `err.cause.code` (undici)。只看 `err.code` ⇒ 连不上的端口会被判成 http_error / registry_unavailable,
|
|
66
|
+
* 于是"源不可达"这一条在真跑里变成**分类错误**(真跑抓到过: 死端口 → registry_unavailable)。
|
|
67
|
+
* 浏览器侧则是 `message: 'Failed to fetch'` / `'Load failed'`。
|
|
68
|
+
*/
|
|
69
|
+
export function collectNetEvidence(err) {
|
|
70
|
+
const codes = [];
|
|
71
|
+
const msgs = [];
|
|
72
|
+
let cur = err;
|
|
73
|
+
for (let depth = 0; cur && depth < 6; depth++) {
|
|
74
|
+
// AggregateError (多地址并发失败) 的成员各带一个 code
|
|
75
|
+
const inner = Array.isArray(cur.errors) ? cur.errors : [];
|
|
76
|
+
for (const e of inner) {
|
|
77
|
+
if (e?.code)
|
|
78
|
+
codes.push(String(e.code));
|
|
79
|
+
if (e?.message)
|
|
80
|
+
msgs.push(String(e.message));
|
|
81
|
+
}
|
|
82
|
+
if (cur.code)
|
|
83
|
+
codes.push(String(cur.code));
|
|
84
|
+
if (cur.errno)
|
|
85
|
+
codes.push(String(cur.errno));
|
|
86
|
+
if (cur.message)
|
|
87
|
+
msgs.push(String(cur.message));
|
|
88
|
+
cur = cur.cause;
|
|
89
|
+
}
|
|
90
|
+
const code = codes.find((c) => OFFLINE_CODES.has(c)) || codes[0] || '';
|
|
91
|
+
const message = msgs.join(' · ');
|
|
92
|
+
const offline = codes.some((c) => OFFLINE_CODES.has(c))
|
|
93
|
+
|| /timeout|timed out|ENOTFOUND|getaddrinfo|network|socket hang up|超时|Failed to fetch|fetch failed|NetworkError|Load failed|ECONNREFUSED|ECONNRESET/i.test(message);
|
|
94
|
+
return { code, message, offline };
|
|
95
|
+
}
|
|
96
|
+
/** 网络类 → offline; 403/429 → rate_limited; 404 → not_found; 其它 → http_error。 */
|
|
97
|
+
export function classifyGithubError(err, httpStatus, headers = {}) {
|
|
98
|
+
const retryAt = headers['x-ratelimit-reset']
|
|
99
|
+
? new Date(Number(headers['x-ratelimit-reset']) * 1000).toISOString()
|
|
100
|
+
: null;
|
|
101
|
+
const net = collectNetEvidence(err);
|
|
102
|
+
if (net.offline) {
|
|
103
|
+
return { kind: 'github_unavailable', reason: 'offline', detail: `${net.code || net.message || '网络不可达'}`, retryAt: null };
|
|
104
|
+
}
|
|
105
|
+
if (httpStatus === 403 || httpStatus === 429) {
|
|
106
|
+
return { kind: 'github_unavailable', reason: 'rate_limited', detail: `GitHub 返回 HTTP ${httpStatus} (限流/权限)`, retryAt };
|
|
107
|
+
}
|
|
108
|
+
if (httpStatus === 404) {
|
|
109
|
+
return { kind: 'github_unavailable', reason: 'not_found', detail: `GitHub 上没有 ${GITHUB_UPSTREAM_SLUG} 的该项记录 (HTTP 404)`, retryAt: null };
|
|
110
|
+
}
|
|
111
|
+
if (httpStatus && httpStatus >= 400) {
|
|
112
|
+
return { kind: 'github_unavailable', reason: 'http_error', detail: `GitHub 返回 HTTP ${httpStatus}`, retryAt };
|
|
113
|
+
}
|
|
114
|
+
return { kind: 'github_unavailable', reason: 'http_error', detail: net.code || net.message || '未知错误', retryAt: null };
|
|
115
|
+
}
|
|
116
|
+
/** registry 侧错误的分类 (§3) —— 与 `classifyGithubError` **并列, 不合并**。 */
|
|
117
|
+
export function classifyRegistryError(err, httpStatus) {
|
|
118
|
+
const net = collectNetEvidence(err);
|
|
119
|
+
if (net.offline) {
|
|
120
|
+
return { kind: 'offline', detail: `${net.code || net.message || '网络不可达'}` };
|
|
121
|
+
}
|
|
122
|
+
if (httpStatus && httpStatus >= 500)
|
|
123
|
+
return { kind: 'registry_unavailable', detail: `registry 返回 HTTP ${httpStatus}` };
|
|
124
|
+
if (httpStatus === 404)
|
|
125
|
+
return { kind: 'registry_unavailable', detail: `registry 上没有该包 (HTTP 404)` };
|
|
126
|
+
if (httpStatus && httpStatus >= 400)
|
|
127
|
+
return { kind: 'registry_unavailable', detail: `registry 返回 HTTP ${httpStatus}` };
|
|
128
|
+
return { kind: 'registry_unavailable', detail: net.code || net.message || '未知错误' };
|
|
129
|
+
}
|
|
130
|
+
// ── 版本标签比较 ────────────────────────────────────────────────────────────
|
|
131
|
+
/** GitHub 上出现过的最高版本号 (Release ∪ Tag, 去掉 v 前缀)。没有 → null。 */
|
|
132
|
+
export function newestGithubVersion(facts) {
|
|
133
|
+
const all = [...facts.releases, ...facts.tags.map((t) => t.name)]
|
|
134
|
+
.map((n) => String(n).trim().replace(/^v/, ''))
|
|
135
|
+
.filter((n) => /^\d+\.\d+\.\d+/.test(n));
|
|
136
|
+
if (all.length === 0)
|
|
137
|
+
return null;
|
|
138
|
+
return all.sort((a, b) => (numericCompare(a, b) < 0 ? 1 : -1))[0];
|
|
139
|
+
}
|
|
140
|
+
/** 只比数值段 (与 version-identity 的 compareVersions 同语义, 这里不再反向依赖它)。 */
|
|
141
|
+
export function numericCompare(a, b) {
|
|
142
|
+
const p = (v) => v.split('-')[0].split('.').map((x) => parseInt(x.replace(/\D.*$/, ''), 10) || 0);
|
|
143
|
+
const pa = p(a);
|
|
144
|
+
const pb = p(b);
|
|
145
|
+
for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
|
|
146
|
+
const x = pa[i] ?? 0;
|
|
147
|
+
const y = pb[i] ?? 0;
|
|
148
|
+
if (x < y)
|
|
149
|
+
return -1;
|
|
150
|
+
if (x > y)
|
|
151
|
+
return 1;
|
|
152
|
+
}
|
|
153
|
+
return 0;
|
|
154
|
+
}
|
|
155
|
+
function hasName(names, v) {
|
|
156
|
+
const set = new Set(names.map((n) => String(n).trim().replace(/^v/, '')));
|
|
157
|
+
return set.has(v.replace(/^v/, ''));
|
|
158
|
+
}
|
|
159
|
+
/**
|
|
160
|
+
* stable 的交叉校验 (§12.1/§12.5): npm 仍是权威, GitHub 只回答"这次发布的记录在不在"。
|
|
161
|
+
*
|
|
162
|
+
* agree npm 的 latest 在 GitHub 上有同名 tag/Release → 两条记录指向同一版
|
|
163
|
+
* mismatch GitHub 记录了**更新的**版本而 registry 上没有 → 拒绝 (不许按 tag 去装)
|
|
164
|
+
* missing_record registry 有该版本但 GitHub 缺这条记录 → **提醒级, 不阻塞** (§12.5 原文:
|
|
165
|
+
* "报发布记录缺 Tag (提醒级, 不阻塞 stable 更新)")
|
|
166
|
+
* no_record_at_all GitHub 上一条版本记录都没有 → 如实报"暂无 tag 可交叉校验"(不编造路径)
|
|
167
|
+
*/
|
|
168
|
+
export function crossCheckStable(registryLatest, facts) {
|
|
169
|
+
const common = {
|
|
170
|
+
registryLatest, githubVersion: newestGithubVersion(facts),
|
|
171
|
+
releases: facts.releases.length, tags: facts.tags.length,
|
|
172
|
+
};
|
|
173
|
+
if (!registryLatest) {
|
|
174
|
+
return { ...common, kind: 'no_record_at_all', hasTag: false, hasRelease: false, blocking: false, detail: 'npm 没有给出 latest, 无从交叉校验' };
|
|
175
|
+
}
|
|
176
|
+
const allNames = [...facts.releases, ...facts.tags.map((t) => t.name)];
|
|
177
|
+
const hasTag = hasName(facts.tags.map((t) => t.name), registryLatest);
|
|
178
|
+
const hasRelease = hasName(facts.releases, registryLatest);
|
|
179
|
+
const ghNewest = common.githubVersion;
|
|
180
|
+
/**
|
|
181
|
+
* **先判"GitHub 记录了比 registry 更新的发布"** —— 这是 §12.5 的 "Release 存在但 registry 上没有该版本 → cross_check_mismatch"。
|
|
182
|
+
* 放在 agree 之前: 即使 registry 的 latest 自己也有 tag, 只要 GitHub 上还多出一条更新的发布记录, 两个源对
|
|
183
|
+
* "当前最新是哪一版"就是不一致的 —— 这种不一致必须拦住, 不能因为"latest 有 tag"就宣布一致。
|
|
184
|
+
*/
|
|
185
|
+
if (ghNewest && numericCompare(ghNewest, registryLatest) > 0) {
|
|
186
|
+
return {
|
|
187
|
+
...common, kind: 'mismatch', hasTag, hasRelease, blocking: true,
|
|
188
|
+
detail: `GitHub 上有 ${ghNewest} 的发布记录, 但 npm registry 的 latest 还是 ${registryLatest} — 拒绝按 GitHub 的记录安装`,
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
if (hasTag || hasRelease) {
|
|
192
|
+
return {
|
|
193
|
+
...common, kind: 'agree', hasTag, hasRelease, blocking: false,
|
|
194
|
+
detail: `npm latest=${registryLatest} 在 GitHub 上有同名记录 (${hasRelease ? `Release v${registryLatest}` : `Tag v${registryLatest}`}) — 两个源指向同一版`,
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
if (allNames.length === 0) {
|
|
198
|
+
return {
|
|
199
|
+
...common, kind: 'no_record_at_all', hasTag: false, hasRelease: false, blocking: false,
|
|
200
|
+
detail: `GitHub 上暂无任何 tag / Release (0 个) — 暂无可交叉校验的记录, npm 仍是权威 (latest=${registryLatest})`,
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
return {
|
|
204
|
+
...common, kind: 'missing_record', hasTag, hasRelease, blocking: false,
|
|
205
|
+
detail: `npm latest=${registryLatest} 在 GitHub 上没有对应记录 (GitHub 最新版本标签 ${ghNewest || '无'}; Release ${facts.releases.length} 个 / Tag ${facts.tags.length} 个) — 发布记录缺 Tag, 不阻塞 stable 更新`,
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
export function compareDevSnapshots(installedSha, headSha, ref = `refs/heads/${DEV_BRANCH}`) {
|
|
209
|
+
const short = (s) => (s ? s.slice(0, 7) : '未知');
|
|
210
|
+
if (!headSha) {
|
|
211
|
+
return { ref, headSha, installedSha, same: false, behind: false, detail: `${ref} 的 HEAD 读不到 — 无法判定 dev 快照新旧` };
|
|
212
|
+
}
|
|
213
|
+
if (!installedSha) {
|
|
214
|
+
return { ref, headSha, installedSha, same: false, behind: false, detail: `当前不是 dev 快照 (stable 安装) — 切到 dev 将得到 ${ref} @ ${short(headSha)}` };
|
|
215
|
+
}
|
|
216
|
+
const same = installedSha.slice(0, 7) === headSha.slice(0, 7);
|
|
217
|
+
return {
|
|
218
|
+
ref, headSha, installedSha, same, behind: !same,
|
|
219
|
+
detail: same
|
|
220
|
+
? `已是最新的 dev 快照 (${ref} @ ${short(headSha)}) — 同一个 commit`
|
|
221
|
+
: `dev 快照落后: 已装 ${short(installedSha)} → master HEAD ${short(headSha)} (按 commit sha 判定, 不看版本号)`,
|
|
222
|
+
};
|
|
223
|
+
}
|