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.
Files changed (54) hide show
  1. package/package.json +1 -3
  2. package/src/aiCommit.js +61 -54
  3. package/src/ui/public/assets/{AgentView-Bslr4Bc8.css → AgentView-ByQ0iJFU.css} +1 -1
  4. package/src/ui/public/assets/AgentView-DunbYHMn.js +42 -0
  5. package/src/ui/public/assets/{AppVersionBadge-vKeEhoxI.js → AppVersionBadge-9B4b0hbP.js} +2 -2
  6. package/src/ui/public/assets/{BranchSelector-D4Igm7sR.js → BranchSelector-BKA3fL9E.js} +1 -1
  7. package/src/ui/public/assets/{CommitForm-CXBA-E-6.js → CommitForm-CVPGpci8.js} +3 -3
  8. package/src/ui/public/assets/{CommonDialog-DrbQC1fm.js → CommonDialog-ZwJlTF5A.js} +1 -1
  9. package/src/ui/public/assets/{EditorView-CXj7-8DC.js → EditorView-DDzACKq1.js} +1 -1
  10. package/src/ui/public/assets/{FlowExecutionViewer-CjL1DfmI.js → FlowExecutionViewer-BJOiCa6S.js} +1 -1
  11. package/src/ui/public/assets/{FlowOrchestrationWorkspace-p7_NcaY-.js → FlowOrchestrationWorkspace-BDnAdRr9.js} +1 -1
  12. package/src/ui/public/assets/{LogList-SDV2f7R6.js → LogList-DbGz7_dy.js} +4 -4
  13. package/src/ui/public/assets/MindmapView-PoMd2cab.css +1 -0
  14. package/src/ui/public/assets/MindmapView-mbTFeVj8.js +2 -0
  15. package/src/ui/public/assets/{MonitorView-G1MQ7M0F.js → MonitorView-O7I40RiB.js} +1 -1
  16. package/src/ui/public/assets/ProjectManagerView-BPnAWNBJ.css +1 -0
  17. package/src/ui/public/assets/{ProjectManagerView-Cg3GLLxQ.js → ProjectManagerView-D1NVbIOW.js} +1 -1
  18. package/src/ui/public/assets/{ProjectStartupButton-DMlDI4Xn.js → ProjectStartupButton-C21zy3Bg.js} +1 -1
  19. package/src/ui/public/assets/{RemoteManagerDialog-BZLYsW8E.js → RemoteManagerDialog-CpSrItn2.js} +1 -1
  20. package/src/ui/public/assets/{RemoteRepoCard-BqHQho7O.js → RemoteRepoCard-CXDXPJK2.js} +1 -1
  21. package/src/ui/public/assets/{SourceMapView-CHl1yfAe.js → SourceMapView-C9qTeaOC.js} +2 -2
  22. package/src/ui/public/assets/{SvgIcon-EoI0db7X.js → SvgIcon-BmXhx-vA.js} +1 -1
  23. package/src/ui/public/assets/{UserInputNode-CbwrGTgh.js → UserInputNode-i7ER8ZCn.js} +1 -1
  24. package/src/ui/public/assets/WorkbenchView-DCL6zHAP.css +1 -0
  25. package/src/ui/public/assets/{WorkbenchView-hSDyngLo.js → WorkbenchView-pZz9ykZT.js} +98 -97
  26. package/src/ui/public/assets/_plugin-vue_export-helper-D6ACRIgd.js +19 -0
  27. package/src/ui/public/assets/{configStore-DPzIt2AS.js → configStore-BQoByr2v.js} +1 -1
  28. package/src/ui/public/assets/index-B9mqQJGC.css +1 -0
  29. package/src/ui/public/assets/{index-CUUfiQYd.js → index-DW-2cjNW.js} +86 -87
  30. package/src/ui/public/assets/{relativeTime-257j87Fm.js → relativeTime-DsG6UERK.js} +1 -1
  31. package/src/ui/public/assets/taskExecutionExport-C3dV5cuE.js +15 -0
  32. package/src/ui/public/assets/{useWorkbenchProjects-Cj3XIq94.js → useWorkbenchProjects-B6k5mmVC.js} +1 -1
  33. package/src/ui/public/index.html +6 -7
  34. package/src/ui/server/routes/codeAnalysis.js +39 -16
  35. package/src/ui/server/routes/config.js +84 -64
  36. package/src/ui/server/routes/git/aiDiffSummary.js +26 -11
  37. package/src/ui/server/routes/projectStartupAi.js +21 -11
  38. package/src/ui/server/routes/recentDirectoriesAiSummary.js +26 -13
  39. package/src/ui/server/routes/workbench/imageDescribe.js +52 -16
  40. package/src/utils/aiModelFallback.js +180 -0
  41. package/src/utils/aiModelFallback.test.js +166 -0
  42. package/src/ui/public/assets/AgentView-BgsIfH1V.js +0 -27
  43. package/src/ui/public/assets/ChatSilenceBadge-D-lbhnwH.js +0 -16
  44. package/src/ui/public/assets/ChatSilenceBadge-aok2usEh.css +0 -1
  45. package/src/ui/public/assets/MindmapView-BWXQHyV7.js +0 -35
  46. package/src/ui/public/assets/MindmapView-CoUbyadF.css +0 -1
  47. package/src/ui/public/assets/ProjectManagerView-DhC-Y5PR.css +0 -1
  48. package/src/ui/public/assets/TaskExecutorPicker-cEgHnI-W.js +0 -1
  49. package/src/ui/public/assets/WorkbenchView-BVTi_0Mn.css +0 -1
  50. package/src/ui/public/assets/_plugin-vue_export-helper-Birg7-Pq.js +0 -19
  51. package/src/ui/public/assets/index-BJJSr6Sb.css +0 -1
  52. package/src/ui/public/assets/taskExecutionExport-CplN8eIn.js +0 -15
  53. package/src/ui/public/assets/toolsStore-BjG-9k17.js +0 -1
  54. /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
- // 取默认模型(isDefault 优先,否则第一个),与提交信息生成同一口径
280
- let model;
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
- model = models.find(m => m.isDefault) || models[0];
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 (!model) {
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
- const { aborted } = await callLlmStream(
306
- model,
307
- prompt,
308
- (delta) => {
309
- if (delta.content) contentStream.feed(delta.content);
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
- maxTokens: MAX_SUMMARY_TOKENS,
313
- systemPrompt: zh ? SYSTEM_PROMPT_ZH : SYSTEM_PROMPT_EN,
314
- signal: abortController.signal,
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 config.json 里的模型条目
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 { content, aborted } = await callLlmStream(
125
- model,
126
- `这张图片的文件名是 ${name},请按系统提示把它转成文字描述。`,
127
- (delta) => { if (delta?.content) text += delta.content; },
128
- {
129
- images: [dataUrl],
130
- systemPrompt: SYSTEM_PROMPT,
131
- maxTokens: MAX_DESCRIBE_CHARS * 2,
132
- timeoutMs: DESCRIBE_TIMEOUT_MS,
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 model = opts.model || await resolveDescribeModel();
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 || !model) return null;
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, model, { fileName: att.originalName || '' });
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
+ })