zen-gitsync 2.18.26 → 2.18.27
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/package.json +1 -3
- package/src/aiCommit.js +61 -54
- package/src/ui/public/assets/{AgentView-Bslr4Bc8.css → AgentView-ByQ0iJFU.css} +1 -1
- package/src/ui/public/assets/AgentView-DunbYHMn.js +42 -0
- package/src/ui/public/assets/{AppVersionBadge-vKeEhoxI.js → AppVersionBadge-9B4b0hbP.js} +2 -2
- package/src/ui/public/assets/{BranchSelector-D4Igm7sR.js → BranchSelector-BKA3fL9E.js} +1 -1
- package/src/ui/public/assets/{CommitForm-CXBA-E-6.js → CommitForm-CVPGpci8.js} +3 -3
- package/src/ui/public/assets/{CommonDialog-DrbQC1fm.js → CommonDialog-ZwJlTF5A.js} +1 -1
- package/src/ui/public/assets/{EditorView-CXj7-8DC.js → EditorView-DDzACKq1.js} +1 -1
- package/src/ui/public/assets/{FlowExecutionViewer-CjL1DfmI.js → FlowExecutionViewer-BJOiCa6S.js} +1 -1
- package/src/ui/public/assets/{FlowOrchestrationWorkspace-p7_NcaY-.js → FlowOrchestrationWorkspace-BDnAdRr9.js} +1 -1
- package/src/ui/public/assets/{LogList-SDV2f7R6.js → LogList-DbGz7_dy.js} +4 -4
- package/src/ui/public/assets/MindmapView-PoMd2cab.css +1 -0
- package/src/ui/public/assets/MindmapView-mbTFeVj8.js +2 -0
- package/src/ui/public/assets/{MonitorView-G1MQ7M0F.js → MonitorView-O7I40RiB.js} +1 -1
- package/src/ui/public/assets/ProjectManagerView-BPnAWNBJ.css +1 -0
- package/src/ui/public/assets/{ProjectManagerView-Cg3GLLxQ.js → ProjectManagerView-D1NVbIOW.js} +1 -1
- package/src/ui/public/assets/{ProjectStartupButton-DMlDI4Xn.js → ProjectStartupButton-C21zy3Bg.js} +1 -1
- package/src/ui/public/assets/{RemoteManagerDialog-BZLYsW8E.js → RemoteManagerDialog-CpSrItn2.js} +1 -1
- package/src/ui/public/assets/{RemoteRepoCard-BqHQho7O.js → RemoteRepoCard-CXDXPJK2.js} +1 -1
- package/src/ui/public/assets/{SourceMapView-CHl1yfAe.js → SourceMapView-C9qTeaOC.js} +2 -2
- package/src/ui/public/assets/{SvgIcon-EoI0db7X.js → SvgIcon-BmXhx-vA.js} +1 -1
- package/src/ui/public/assets/{UserInputNode-CbwrGTgh.js → UserInputNode-i7ER8ZCn.js} +1 -1
- package/src/ui/public/assets/WorkbenchView-DCL6zHAP.css +1 -0
- package/src/ui/public/assets/{WorkbenchView-hSDyngLo.js → WorkbenchView-pZz9ykZT.js} +98 -97
- package/src/ui/public/assets/_plugin-vue_export-helper-D6ACRIgd.js +19 -0
- package/src/ui/public/assets/{configStore-DPzIt2AS.js → configStore-BQoByr2v.js} +1 -1
- package/src/ui/public/assets/index-B9mqQJGC.css +1 -0
- package/src/ui/public/assets/{index-CUUfiQYd.js → index-DW-2cjNW.js} +86 -87
- package/src/ui/public/assets/{relativeTime-257j87Fm.js → relativeTime-DsG6UERK.js} +1 -1
- package/src/ui/public/assets/taskExecutionExport-C3dV5cuE.js +15 -0
- package/src/ui/public/assets/{useWorkbenchProjects-Cj3XIq94.js → useWorkbenchProjects-B6k5mmVC.js} +1 -1
- package/src/ui/public/index.html +6 -7
- package/src/ui/server/routes/codeAnalysis.js +39 -16
- package/src/ui/server/routes/config.js +84 -64
- package/src/ui/server/routes/git/aiDiffSummary.js +26 -11
- package/src/ui/server/routes/projectStartupAi.js +21 -11
- package/src/ui/server/routes/recentDirectoriesAiSummary.js +26 -13
- package/src/ui/server/routes/workbench/imageDescribe.js +52 -16
- package/src/utils/aiModelFallback.js +180 -0
- package/src/utils/aiModelFallback.test.js +166 -0
- package/src/ui/public/assets/AgentView-BgsIfH1V.js +0 -27
- package/src/ui/public/assets/ChatSilenceBadge-D-lbhnwH.js +0 -16
- package/src/ui/public/assets/ChatSilenceBadge-aok2usEh.css +0 -1
- package/src/ui/public/assets/MindmapView-BWXQHyV7.js +0 -35
- package/src/ui/public/assets/MindmapView-CoUbyadF.css +0 -1
- package/src/ui/public/assets/ProjectManagerView-DhC-Y5PR.css +0 -1
- package/src/ui/public/assets/TaskExecutorPicker-cEgHnI-W.js +0 -1
- package/src/ui/public/assets/WorkbenchView-BVTi_0Mn.css +0 -1
- package/src/ui/public/assets/_plugin-vue_export-helper-Birg7-Pq.js +0 -19
- package/src/ui/public/assets/index-BJJSr6Sb.css +0 -1
- package/src/ui/public/assets/taskExecutionExport-CplN8eIn.js +0 -15
- package/src/ui/public/assets/toolsStore-BjG-9k17.js +0 -1
- /package/src/ui/public/assets/{TaskExecutorPicker-CdRhuBxH.css → taskExecutionExport-CdRhuBxH.css} +0 -0
|
@@ -37,6 +37,7 @@ import express from 'express';
|
|
|
37
37
|
import { callLlmStream } from './workbench/llmClient.js';
|
|
38
38
|
import { createThinkFilter } from '../../../cli/ai/streamFilter.js';
|
|
39
39
|
import { normalizeDirKey } from '../utils/directoryGitState.js';
|
|
40
|
+
import { buildModelFallbackChain, modelLabel, runWithModelFallback } from '../../../utils/aiModelFallback.js';
|
|
40
41
|
import logger from '../utils/logger.js';
|
|
41
42
|
|
|
42
43
|
/** 一次最多解读多少个目录,与 /api/recent_directories/git-state 的上限保持一致 */
|
|
@@ -276,18 +277,18 @@ export function registerRecentDirectoriesSummaryRoutes({ app, configManager }) {
|
|
|
276
277
|
return res.end();
|
|
277
278
|
}
|
|
278
279
|
|
|
279
|
-
//
|
|
280
|
-
let
|
|
280
|
+
// 候选链 = 默认模型打头,其余已配置模型跟上(默认模型额度用完也不会整块不可用)
|
|
281
|
+
let chain;
|
|
281
282
|
try {
|
|
282
283
|
const rawConfig = await configManager.readRawConfigFile();
|
|
283
284
|
const models = Array.isArray(rawConfig.models) ? rawConfig.models : [];
|
|
284
|
-
|
|
285
|
+
chain = buildModelFallbackChain(models);
|
|
285
286
|
} catch (err) {
|
|
286
287
|
send({ type: 'error', error: '读取 AI 配置失败: ' + err.message, code: 'CONFIG_ERR' });
|
|
287
288
|
finished = true;
|
|
288
289
|
return res.end();
|
|
289
290
|
}
|
|
290
|
-
if (
|
|
291
|
+
if (chain.length === 0) {
|
|
291
292
|
send({ type: 'error', error: '未配置 AI 模型', code: 'NO_MODEL' });
|
|
292
293
|
finished = true;
|
|
293
294
|
return res.end();
|
|
@@ -302,18 +303,30 @@ export function registerRecentDirectoriesSummaryRoutes({ app, configManager }) {
|
|
|
302
303
|
send({ type: 'delta', content });
|
|
303
304
|
});
|
|
304
305
|
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
306
|
+
// 已经往 SSE 里推过内容就不能换模型(会重复输出半截说明),只有「一个字都还没吐」
|
|
307
|
+
// 时才允许重试下一个
|
|
308
|
+
const { result } = await runWithModelFallback(
|
|
309
|
+
chain,
|
|
310
|
+
(candidate) => callLlmStream(
|
|
311
|
+
candidate,
|
|
312
|
+
prompt,
|
|
313
|
+
(delta) => {
|
|
314
|
+
if (delta.content) contentStream.feed(delta.content);
|
|
315
|
+
},
|
|
316
|
+
{
|
|
317
|
+
maxTokens: MAX_SUMMARY_TOKENS,
|
|
318
|
+
systemPrompt: zh ? SYSTEM_PROMPT_ZH : SYSTEM_PROMPT_EN,
|
|
319
|
+
signal: abortController.signal,
|
|
320
|
+
}
|
|
321
|
+
),
|
|
311
322
|
{
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
323
|
+
isFatal: () => summaryText.length > 0,
|
|
324
|
+
onFallback: ({ from, to, error }) => logger.warn(
|
|
325
|
+
`[recent_directories/summary] 模型 ${modelLabel(from)} 调用失败(${error?.message || error}),改用 ${modelLabel(to)}`
|
|
326
|
+
),
|
|
315
327
|
}
|
|
316
328
|
);
|
|
329
|
+
const aborted = Boolean(result?.aborted);
|
|
317
330
|
|
|
318
331
|
if (aborted) {
|
|
319
332
|
// 前端主动断开(关弹窗/切目录):不是错误,也不必补一条 error 事件
|
|
@@ -44,6 +44,7 @@ import path from 'node:path';
|
|
|
44
44
|
import { logger, readJson, CONFIG_FILE } from './shared.js';
|
|
45
45
|
import { isImageExt } from './attachmentUtils.js';
|
|
46
46
|
import { callLlmStream } from './llmClient.js';
|
|
47
|
+
import { buildModelFallbackChain, modelLabel, runWithModelFallback } from '../../../../utils/aiModelFallback.js';
|
|
47
48
|
|
|
48
49
|
/** 单张图片大小上限:超过就不送去描述(base64 之后还要再涨 1/3) */
|
|
49
50
|
export const MAX_DESCRIBE_IMAGE_BYTES = 6 * 1024 * 1024;
|
|
@@ -84,6 +85,22 @@ export async function resolveDescribeModel() {
|
|
|
84
85
|
}
|
|
85
86
|
}
|
|
86
87
|
|
|
88
|
+
/**
|
|
89
|
+
* 图片描述用的候选模型链:默认模型打头,其余已配置模型兜底(默认模型没额度时
|
|
90
|
+
* 还能用别的模型把图转成文字)。
|
|
91
|
+
*
|
|
92
|
+
* @returns {Promise<Array<object>>}
|
|
93
|
+
*/
|
|
94
|
+
export async function resolveDescribeModelChain() {
|
|
95
|
+
try {
|
|
96
|
+
const raw = await readJson(CONFIG_FILE, {});
|
|
97
|
+
return buildModelFallbackChain(Array.isArray(raw?.models) ? raw.models : []);
|
|
98
|
+
} catch (err) {
|
|
99
|
+
logger.warn('[imageDescribe] 读取 AI 配置失败,跳过图片转文字:', err?.message || err);
|
|
100
|
+
return [];
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
87
104
|
/** 附件里的图片(按 isImageExt 判,与附件白名单同一份口径) */
|
|
88
105
|
export function pickImageAttachments(attachments) {
|
|
89
106
|
if (!Array.isArray(attachments)) return [];
|
|
@@ -94,7 +111,7 @@ export function pickImageAttachments(attachments) {
|
|
|
94
111
|
* 描述一张图片。
|
|
95
112
|
*
|
|
96
113
|
* @param {string} filePath 本机绝对路径
|
|
97
|
-
* @param {object} model
|
|
114
|
+
* @param {object|object[]} model config.json 里的模型条目(数组 = 候选链,前一个失败换下一个)
|
|
98
115
|
* @param {object} [opts]
|
|
99
116
|
* @param {string} [opts.fileName] 给模型的文件名提示(不含目录,别泄漏本地路径)
|
|
100
117
|
* @returns {Promise<string|null>} 描述文本;失败/超限/空回答一律 null
|
|
@@ -121,19 +138,34 @@ export async function describeImage(filePath, model, opts = {}) {
|
|
|
121
138
|
|
|
122
139
|
let text = '';
|
|
123
140
|
try {
|
|
124
|
-
const
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
141
|
+
const chain = Array.isArray(model) ? model : [model];
|
|
142
|
+
// 已经吐过文字就不能换模型(会把两段描述拼在一起);一张图重试几次都失败就
|
|
143
|
+
// 由外层 anyFailed 熔断,不继续烧额度
|
|
144
|
+
const { result: streamResult } = await runWithModelFallback(
|
|
145
|
+
chain,
|
|
146
|
+
(candidate) => {
|
|
147
|
+
text = '';
|
|
148
|
+
return callLlmStream(
|
|
149
|
+
candidate,
|
|
150
|
+
`这张图片的文件名是 ${name},请按系统提示把它转成文字描述。`,
|
|
151
|
+
(delta) => { if (delta?.content) text += delta.content; },
|
|
152
|
+
{
|
|
153
|
+
images: [dataUrl],
|
|
154
|
+
systemPrompt: SYSTEM_PROMPT,
|
|
155
|
+
maxTokens: MAX_DESCRIBE_CHARS * 2,
|
|
156
|
+
timeoutMs: DESCRIBE_TIMEOUT_MS,
|
|
157
|
+
},
|
|
158
|
+
);
|
|
133
159
|
},
|
|
160
|
+
{
|
|
161
|
+
isFatal: () => text.length > 0,
|
|
162
|
+
onFallback: ({ from, to, error }) => logger.warn(
|
|
163
|
+
`[imageDescribe] 模型 ${modelLabel(from)} 描述图片失败(${error?.message || error}),改用 ${modelLabel(to)}`
|
|
164
|
+
),
|
|
165
|
+
}
|
|
134
166
|
);
|
|
135
|
-
if (aborted) return null;
|
|
136
|
-
const out = String(content || text || '').trim();
|
|
167
|
+
if (streamResult?.aborted) return null;
|
|
168
|
+
const out = String(streamResult?.content || text || '').trim();
|
|
137
169
|
if (!out) return null;
|
|
138
170
|
return out.length > MAX_DESCRIBE_CHARS
|
|
139
171
|
? out.slice(0, MAX_DESCRIBE_CHARS) + '…'
|
|
@@ -161,7 +193,9 @@ export async function describeImage(filePath, model, opts = {}) {
|
|
|
161
193
|
*/
|
|
162
194
|
export async function createImageDescriber(opts = {}) {
|
|
163
195
|
const maxImages = Number.isFinite(opts.maxImages) ? opts.maxImages : MAX_DESCRIBE_IMAGES;
|
|
164
|
-
const
|
|
196
|
+
const chain = opts.model
|
|
197
|
+
? (Array.isArray(opts.model) ? opts.model : [opts.model])
|
|
198
|
+
: await resolveDescribeModelChain();
|
|
165
199
|
const runDescribe = typeof opts.describe === 'function' ? opts.describe : describeImage;
|
|
166
200
|
const cache = new Map();
|
|
167
201
|
let done = 0;
|
|
@@ -169,15 +203,17 @@ export async function createImageDescriber(opts = {}) {
|
|
|
169
203
|
|
|
170
204
|
return async function describe(att) {
|
|
171
205
|
const filePath = att && att.absolutePath;
|
|
172
|
-
if (!filePath ||
|
|
206
|
+
if (!filePath || chain.length === 0) return null;
|
|
173
207
|
if (cache.has(filePath)) return cache.get(filePath);
|
|
174
208
|
if (done >= maxImages || anyFailed) return null;
|
|
175
209
|
done++;
|
|
176
|
-
const text = await runDescribe(filePath,
|
|
210
|
+
const text = await runDescribe(filePath, chain.length === 1 ? chain[0] : chain, {
|
|
211
|
+
fileName: att.originalName || '',
|
|
212
|
+
});
|
|
177
213
|
if (!text) anyFailed = true;
|
|
178
214
|
cache.set(filePath, text);
|
|
179
215
|
return text;
|
|
180
216
|
};
|
|
181
217
|
}
|
|
182
218
|
|
|
183
|
-
export default { describeImage, createImageDescriber, resolveDescribeModel, pickImageAttachments };
|
|
219
|
+
export default { describeImage, createImageDescriber, resolveDescribeModel, resolveDescribeModelChain, pickImageAttachments };
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
// Copyright 2026 xz333221
|
|
2
|
+
//
|
|
3
|
+
// Licensed under the Apache License, Version 2.0 (the "License");
|
|
4
|
+
// you may not use this file except in compliance with the License.
|
|
5
|
+
// You may obtain a copy of the License at
|
|
6
|
+
//
|
|
7
|
+
// http://www.apache.org/licenses/LICENSE-2.0
|
|
8
|
+
//
|
|
9
|
+
// Unless required by applicable law or agreed to in writing, software
|
|
10
|
+
// distributed under the License is distributed on an "AS IS" BASIS,
|
|
11
|
+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
12
|
+
// See the License for the specific language governing permissions and
|
|
13
|
+
// limitations under the License.
|
|
14
|
+
//
|
|
15
|
+
// ─────────────────────────────────────────────────────────────
|
|
16
|
+
// 默认模型失败时的「换模型重试」层
|
|
17
|
+
//
|
|
18
|
+
// 背景:本项目里所有 AI 驱动的内容(AI 启动建议 / 差异说明 / 目录解读 /
|
|
19
|
+
// 代码分析 / 提交信息)以前都写死「取 isDefault 的那一个,失败就报错」。
|
|
20
|
+
// 默认模型这个月的额度用完之后,这些功能会**整块不可用** —— 明明设置里还配着
|
|
21
|
+
// 好几个能用的模型。
|
|
22
|
+
//
|
|
23
|
+
// 这一层的口径:
|
|
24
|
+
// 1) 候选来源就是 config.json 的 models[],默认模型排第一,其余按配置顺序跟在后面;
|
|
25
|
+
// 2) 同一个 baseURL+model 只算一个(重复添加的条目不该被重试两次);
|
|
26
|
+
// 3) 最多试 MAX_FALLBACK_MODELS 个 —— 候选多了会让「全都失败」变成 3×超时的等待;
|
|
27
|
+
// 4) 用户主动取消(AbortError)不算失败,不该被下一个模型悄悄续上;
|
|
28
|
+
// 5) 已经往外吐过内容的流式调用不能换模型(会重复输出半截内容),由调用方通过
|
|
29
|
+
// isFatal 判定,本层只负责「能不能继续」这件事的分发。
|
|
30
|
+
// ─────────────────────────────────────────────────────────────
|
|
31
|
+
|
|
32
|
+
/** 一次请求最多尝试几个模型(默认 + 备用) */
|
|
33
|
+
export const MAX_FALLBACK_MODELS = 3;
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* 归一化 baseURL:去尾部斜杠 + 小写,只用于去重。
|
|
37
|
+
* 用户可能把 `.../v1/` 和 `.../v1` 各配一遍,那是同一个端点。
|
|
38
|
+
* @param {unknown} value
|
|
39
|
+
* @returns {string}
|
|
40
|
+
*/
|
|
41
|
+
function normalizedBase(value) {
|
|
42
|
+
return String(value || '').trim().replace(/\/+$/, '').toLowerCase();
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** 只认非空字符串 */
|
|
46
|
+
function text(value) {
|
|
47
|
+
return typeof value === 'string' ? value.trim() : '';
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* 模型的展示名:优先用户起的名字,其次模型名。
|
|
52
|
+
* @param {{name?: string, model?: string}} model
|
|
53
|
+
* @returns {string}
|
|
54
|
+
*/
|
|
55
|
+
export function modelLabel(model) {
|
|
56
|
+
return text(model?.name) || text(model?.model) || '未命名模型';
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* 判断一个错误是不是「用户取消」。
|
|
61
|
+
* node-fetch 抛 AbortError,axios 风格是 CanceledError / ERR_CANCELED。
|
|
62
|
+
* @param {any} err
|
|
63
|
+
* @returns {boolean}
|
|
64
|
+
*/
|
|
65
|
+
export function isAbortLikeError(err) {
|
|
66
|
+
if (!err) return false;
|
|
67
|
+
const name = String(err.name || '');
|
|
68
|
+
const code = String(err.code || '');
|
|
69
|
+
return name === 'AbortError' || name === 'CanceledError'
|
|
70
|
+
|| code === 'ABORT_ERR' || code === 'ERR_CANCELED';
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** 错误文案:空错误也得有个能看的说法,否则界面上就是一行空白 */
|
|
74
|
+
function errorMessage(err) {
|
|
75
|
+
return String(err?.message || err || '').trim() || '未知错误';
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* 把 config.json 的 models[] 整理成「默认模型优先」的候选链。
|
|
80
|
+
*
|
|
81
|
+
* @param {Array<object>} models
|
|
82
|
+
* @param {object} [opts]
|
|
83
|
+
* @param {number} [opts.limit=MAX_FALLBACK_MODELS] 最多返回几个(<=0 表示不限)
|
|
84
|
+
* @param {boolean} [opts.defaultFirst=true] 是否把 isDefault 提到第一位。
|
|
85
|
+
* 调用方已经排好序(例如用户显式指定的模型要打头)时传 false。
|
|
86
|
+
* @returns {Array<object>}
|
|
87
|
+
*/
|
|
88
|
+
export function buildModelFallbackChain(models, { limit = MAX_FALLBACK_MODELS, defaultFirst = true } = {}) {
|
|
89
|
+
const list = Array.isArray(models) ? models : [];
|
|
90
|
+
const seen = new Set();
|
|
91
|
+
const usable = [];
|
|
92
|
+
|
|
93
|
+
for (const model of list) {
|
|
94
|
+
if (!model || typeof model !== 'object') continue;
|
|
95
|
+
// 缺 endpoint / 缺模型名的条目发出去也只能报错,直接跳过
|
|
96
|
+
if (!text(model.baseURL) || !text(model.model)) continue;
|
|
97
|
+
const key = `${normalizedBase(model.baseURL)}\u0000${text(model.model).toLowerCase()}`;
|
|
98
|
+
if (seen.has(key)) continue;
|
|
99
|
+
seen.add(key);
|
|
100
|
+
usable.push(model);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const defaultIndex = defaultFirst ? usable.findIndex(m => m.isDefault) : -1;
|
|
104
|
+
const ordered = defaultIndex > 0
|
|
105
|
+
? [usable[defaultIndex], ...usable.slice(0, defaultIndex), ...usable.slice(defaultIndex + 1)]
|
|
106
|
+
: usable;
|
|
107
|
+
|
|
108
|
+
const cap = Number.isFinite(limit) && limit > 0 ? Math.floor(limit) : ordered.length;
|
|
109
|
+
return ordered.slice(0, cap);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* 给「全都失败」的错误补上每个模型各自的报错,否则界面上只剩最后那个模型的文案,
|
|
114
|
+
* 用户看不出到底试过谁、前一个为什么不行。
|
|
115
|
+
*/
|
|
116
|
+
function decorateError(err, attempts) {
|
|
117
|
+
// 只试了一个(没发生切换)→ 原样抛,保持各调用点此前的错误文案与错误类型
|
|
118
|
+
if (attempts.length <= 1) return err;
|
|
119
|
+
const detail = attempts.map(a => `${a.label}(${a.message})`).join(';');
|
|
120
|
+
const wrapped = new Error(`所有 AI 模型都调用失败:${detail}`);
|
|
121
|
+
wrapped.attempts = attempts;
|
|
122
|
+
wrapped.cause = err;
|
|
123
|
+
// 保留最后一次的错误码:路由层靠 code 区分 TIMEOUT / NO_JSON 等分支
|
|
124
|
+
if (err && typeof err === 'object' && err.code !== undefined) wrapped.code = err.code;
|
|
125
|
+
return wrapped;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* 依次用候选模型执行 run(model),前一个失败才换下一个。
|
|
130
|
+
*
|
|
131
|
+
* @param {Array<object>} chain - buildModelFallbackChain 的结果
|
|
132
|
+
* @param {(model: object, index: number) => Promise<any>} run
|
|
133
|
+
* @param {object} [opts]
|
|
134
|
+
* @param {(err: any, model: object, index: number) => boolean} [opts.isFatal]
|
|
135
|
+
* 返回 true = 这个错误不该换模型(用户取消 / 流里已经吐过内容)
|
|
136
|
+
* @param {(info: {error: any, from: object, to: object, index: number}) => void} [opts.onFallback]
|
|
137
|
+
* 发生切换时的回调(调用方拿去打日志 / 提示)
|
|
138
|
+
* @returns {Promise<{result: any, model: object, attempts: Array<object>}>}
|
|
139
|
+
* result 是 run 的返回值,model 是**真正成功**的那个模型
|
|
140
|
+
* @throws {Error} 全部失败时抛出(带 attempts),或首个致命错误原样抛出
|
|
141
|
+
*/
|
|
142
|
+
export async function runWithModelFallback(chain, run, opts = {}) {
|
|
143
|
+
const list = Array.isArray(chain) ? chain.filter(Boolean) : [];
|
|
144
|
+
const { isFatal, onFallback } = opts;
|
|
145
|
+
if (list.length === 0) throw new Error('未配置 AI 模型');
|
|
146
|
+
|
|
147
|
+
const attempts = [];
|
|
148
|
+
for (let i = 0; i < list.length; i += 1) {
|
|
149
|
+
const model = list[i];
|
|
150
|
+
try {
|
|
151
|
+
const result = await run(model, i);
|
|
152
|
+
return { result, model, attempts };
|
|
153
|
+
} catch (err) {
|
|
154
|
+
attempts.push({ model, label: modelLabel(model), message: errorMessage(err), error: err });
|
|
155
|
+
let fatal = isAbortLikeError(err);
|
|
156
|
+
if (!fatal && typeof isFatal === 'function') {
|
|
157
|
+
try {
|
|
158
|
+
fatal = Boolean(isFatal(err, model, i));
|
|
159
|
+
} catch {
|
|
160
|
+
fatal = false;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
if (fatal || i === list.length - 1) throw decorateError(err, attempts);
|
|
164
|
+
try {
|
|
165
|
+
onFallback?.({ error: err, from: model, to: list[i + 1], index: i });
|
|
166
|
+
} catch { /* 日志回调自己出错不该影响重试 */ }
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// 循环正常结束只可能是 list 为空(已在上面拦掉),这里只是给 TS / 读者一个明确出口
|
|
171
|
+
throw decorateError(attempts[attempts.length - 1]?.error || new Error('AI 调用失败'), attempts);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
export default {
|
|
175
|
+
MAX_FALLBACK_MODELS,
|
|
176
|
+
buildModelFallbackChain,
|
|
177
|
+
isAbortLikeError,
|
|
178
|
+
modelLabel,
|
|
179
|
+
runWithModelFallback,
|
|
180
|
+
};
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
// Copyright 2026 xz333221
|
|
2
|
+
//
|
|
3
|
+
// Licensed under the Apache License, Version 2.0 (the "License");
|
|
4
|
+
// you may not use this file except in compliance with the License.
|
|
5
|
+
// You may obtain a copy of the License at
|
|
6
|
+
//
|
|
7
|
+
// http://www.apache.org/licenses/LICENSE-2.0
|
|
8
|
+
//
|
|
9
|
+
// Unless required by applicable law or agreed to in writing, software
|
|
10
|
+
// distributed under the License is distributed on an "AS IS" BASIS,
|
|
11
|
+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
12
|
+
// See the License for the specific language governing permissions and
|
|
13
|
+
// limitations under the License.
|
|
14
|
+
//
|
|
15
|
+
// src/utils/aiModelFallback.js 单元测试(node:test 内置)。
|
|
16
|
+
//
|
|
17
|
+
// 这一层是为了修「默认模型额度用完,AI 启动建议 / 差异说明 / 提交信息全不可用」
|
|
18
|
+
// 而抽出来的,所以把口径钉死:
|
|
19
|
+
// 1) 候选链默认模型排第一,其余按配置顺序;同一 baseURL+model 只算一个
|
|
20
|
+
// 2) 前一个失败才换下一个;用户取消(Abort)和 isFatal 判定的失败不换
|
|
21
|
+
// 3) 全都失败时错误里要能看到每个模型各自的报错,而不是只剩最后一条
|
|
22
|
+
import { test } from 'node:test'
|
|
23
|
+
import assert from 'node:assert/strict'
|
|
24
|
+
import {
|
|
25
|
+
MAX_FALLBACK_MODELS,
|
|
26
|
+
buildModelFallbackChain,
|
|
27
|
+
isAbortLikeError,
|
|
28
|
+
modelLabel,
|
|
29
|
+
runWithModelFallback,
|
|
30
|
+
} from './aiModelFallback.js'
|
|
31
|
+
|
|
32
|
+
const A = { id: 'a', name: '模型 A', model: 'a-model', baseURL: 'https://a.test/v1', apiKey: 'k' }
|
|
33
|
+
const B = { id: 'b', name: '模型 B', model: 'b-model', baseURL: 'https://b.test/v1', apiKey: 'k' }
|
|
34
|
+
const C = { id: 'c', name: '模型 C', model: 'c-model', baseURL: 'https://c.test/v1', apiKey: 'k' }
|
|
35
|
+
const D = { id: 'd', name: '模型 D', model: 'd-model', baseURL: 'https://d.test/v1', apiKey: 'k' }
|
|
36
|
+
const DEFAULT = { ...B, isDefault: true }
|
|
37
|
+
|
|
38
|
+
test('buildModelFallbackChain: 默认模型排第一,其余按配置顺序', () => {
|
|
39
|
+
const chain = buildModelFallbackChain([A, DEFAULT, C])
|
|
40
|
+
assert.deepEqual(chain.map(m => m.id), ['b', 'a', 'c'])
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
test('buildModelFallbackChain: 没有默认模型时保持原顺序', () => {
|
|
44
|
+
const chain = buildModelFallbackChain([A, B])
|
|
45
|
+
assert.deepEqual(chain.map(m => m.id), ['a', 'b'])
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
test('buildModelFallbackChain: defaultFirst=false 时不重排(用户显式指定打头)', () => {
|
|
49
|
+
const chain = buildModelFallbackChain([A, DEFAULT], { defaultFirst: false })
|
|
50
|
+
assert.deepEqual(chain.map(m => m.id), ['a', 'b'])
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
test('buildModelFallbackChain: 同一端点+模型只保留一条,缺字段的条目被丢掉', () => {
|
|
54
|
+
const dup = { id: 'a2', name: 'A 重复', model: 'a-model', baseURL: 'https://a.test/v1/' }
|
|
55
|
+
const noKey = { id: 'x', model: 'x-model' }
|
|
56
|
+
const noModel = { id: 'y', baseURL: 'https://y.test/v1' }
|
|
57
|
+
const chain = buildModelFallbackChain([A, dup, noKey, noModel, B])
|
|
58
|
+
assert.deepEqual(chain.map(m => m.id), ['a', 'b'])
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
test(`buildModelFallbackChain: 默认最多 ${MAX_FALLBACK_MODELS} 个候选`, () => {
|
|
62
|
+
const chain = buildModelFallbackChain([A, B, C, D])
|
|
63
|
+
assert.equal(chain.length, MAX_FALLBACK_MODELS)
|
|
64
|
+
// limit<=0 表示不限
|
|
65
|
+
assert.equal(buildModelFallbackChain([A, B, C, D], { limit: 0 }).length, 4)
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
test('runWithModelFallback: 第一个就成功时不调用后面的,也不触发 onFallback', async () => {
|
|
69
|
+
const calls = []
|
|
70
|
+
const fallbacks = []
|
|
71
|
+
const { result, model } = await runWithModelFallback(
|
|
72
|
+
[A, B],
|
|
73
|
+
async (m) => { calls.push(m.id); return `ok-${m.id}` },
|
|
74
|
+
{ onFallback: (info) => fallbacks.push(info) }
|
|
75
|
+
)
|
|
76
|
+
assert.equal(result, 'ok-a')
|
|
77
|
+
assert.equal(model.id, 'a')
|
|
78
|
+
assert.deepEqual(calls, ['a'])
|
|
79
|
+
assert.deepEqual(fallbacks, [])
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
test('runWithModelFallback: 默认模型失败后换下一个,并把失败原因传出去', async () => {
|
|
83
|
+
const calls = []
|
|
84
|
+
const fallbacks = []
|
|
85
|
+
const { result, model } = await runWithModelFallback(
|
|
86
|
+
[A, B],
|
|
87
|
+
async (m) => {
|
|
88
|
+
calls.push(m.id)
|
|
89
|
+
if (m.id === 'a') throw new Error('HTTP 429 额度不足')
|
|
90
|
+
return `ok-${m.id}`
|
|
91
|
+
},
|
|
92
|
+
{ onFallback: (info) => fallbacks.push(info) }
|
|
93
|
+
)
|
|
94
|
+
assert.equal(result, 'ok-b')
|
|
95
|
+
assert.equal(model.id, 'b')
|
|
96
|
+
assert.deepEqual(calls, ['a', 'b'])
|
|
97
|
+
assert.equal(fallbacks.length, 1)
|
|
98
|
+
assert.equal(fallbacks[0].from.id, 'a')
|
|
99
|
+
assert.equal(fallbacks[0].to.id, 'b')
|
|
100
|
+
assert.match(fallbacks[0].error.message, /429/)
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
test('runWithModelFallback: 全都失败时错误里带上每个模型的报错', async () => {
|
|
104
|
+
await assert.rejects(
|
|
105
|
+
() => runWithModelFallback(
|
|
106
|
+
[A, B],
|
|
107
|
+
async (m) => { throw new Error(`${m.id} 挂了`) }
|
|
108
|
+
),
|
|
109
|
+
(err) => {
|
|
110
|
+
assert.match(err.message, /模型 A/)
|
|
111
|
+
assert.match(err.message, /模型 B/)
|
|
112
|
+
assert.match(err.message, /a 挂了/)
|
|
113
|
+
assert.match(err.message, /b 挂了/)
|
|
114
|
+
assert.equal(err.attempts.length, 2)
|
|
115
|
+
return true
|
|
116
|
+
}
|
|
117
|
+
)
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
test('runWithModelFallback: 只试了一个模型时原样抛(保持各调用点原有文案)', async () => {
|
|
121
|
+
const boom = new Error('HTTP 401')
|
|
122
|
+
await assert.rejects(
|
|
123
|
+
() => runWithModelFallback([A], async () => { throw boom }),
|
|
124
|
+
(err) => err === boom
|
|
125
|
+
)
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
test('runWithModelFallback: 用户取消不换模型', async () => {
|
|
129
|
+
const abort = new Error('aborted')
|
|
130
|
+
abort.name = 'AbortError'
|
|
131
|
+
const calls = []
|
|
132
|
+
await assert.rejects(
|
|
133
|
+
() => runWithModelFallback(
|
|
134
|
+
[A, B],
|
|
135
|
+
async (m) => { calls.push(m.id); throw abort }
|
|
136
|
+
),
|
|
137
|
+
(err) => err === abort
|
|
138
|
+
)
|
|
139
|
+
assert.deepEqual(calls, ['a'], 'Abort 之后不该再用下一个模型续上')
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
test('runWithModelFallback: isFatal 返回 true 时立刻收手', async () => {
|
|
143
|
+
const calls = []
|
|
144
|
+
await assert.rejects(
|
|
145
|
+
() => runWithModelFallback(
|
|
146
|
+
[A, B],
|
|
147
|
+
async (m) => { calls.push(m.id); throw new Error('stream 中途断了') },
|
|
148
|
+
{ isFatal: () => true }
|
|
149
|
+
),
|
|
150
|
+
/stream 中途断了/
|
|
151
|
+
)
|
|
152
|
+
assert.deepEqual(calls, ['a'])
|
|
153
|
+
})
|
|
154
|
+
|
|
155
|
+
test('runWithModelFallback: 空候选链抛「未配置 AI 模型」', async () => {
|
|
156
|
+
await assert.rejects(() => runWithModelFallback([], async () => 'x'), /未配置 AI 模型/)
|
|
157
|
+
})
|
|
158
|
+
|
|
159
|
+
test('isAbortLikeError / modelLabel 的基本口径', () => {
|
|
160
|
+
assert.equal(isAbortLikeError({ name: 'AbortError' }), true)
|
|
161
|
+
assert.equal(isAbortLikeError({ code: 'ERR_CANCELED' }), true)
|
|
162
|
+
assert.equal(isAbortLikeError(new Error('HTTP 500')), false)
|
|
163
|
+
assert.equal(modelLabel({ name: '自动分类', model: 'x' }), '自动分类')
|
|
164
|
+
assert.equal(modelLabel({ model: 'x' }), 'x')
|
|
165
|
+
assert.equal(modelLabel({}), '未命名模型')
|
|
166
|
+
})
|