zen-gitsync 2.18.26 → 2.19.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (74) hide show
  1. package/package.json +6 -4
  2. package/src/aiCommit.js +61 -54
  3. package/src/cli/ai/tools.js +71 -8
  4. package/src/cli/ai/webSearch.js +108 -21
  5. package/src/cli/peer.js +272 -0
  6. package/src/config.js +14 -4
  7. package/src/gitCommit.js +34 -1
  8. package/src/paths.js +6 -0
  9. package/src/peer/client.js +165 -0
  10. package/src/peer/localAsk.js +130 -0
  11. package/src/peer/server.js +183 -0
  12. package/src/peer/store.js +292 -0
  13. package/src/ui/public/assets/AgentView-BtnAYtte.js +42 -0
  14. package/src/ui/public/assets/{AgentView-Bslr4Bc8.css → AgentView-ByQ0iJFU.css} +1 -1
  15. package/src/ui/public/assets/AppVersionBadge-BcXo-yuu.js +2 -0
  16. package/src/ui/public/assets/{BranchSelector-D4Igm7sR.js → BranchSelector-5t-T3FB6.js} +1 -1
  17. package/src/ui/public/assets/{CommitForm-CXBA-E-6.js → CommitForm-BMfp3Eqh.js} +3 -3
  18. package/src/ui/public/assets/{CommonDialog-DrbQC1fm.js → CommonDialog-BiF2fMgr.js} +1 -1
  19. package/src/ui/public/assets/{EditorView-CXj7-8DC.js → EditorView-CtmZ7RZp.js} +1 -1
  20. package/src/ui/public/assets/{FlowExecutionViewer-CjL1DfmI.js → FlowExecutionViewer-D2lchSo7.js} +1 -1
  21. package/src/ui/public/assets/{FlowOrchestrationWorkspace-p7_NcaY-.js → FlowOrchestrationWorkspace-BY2zLwWB.js} +1 -1
  22. package/src/ui/public/assets/{LogList-SDV2f7R6.js → LogList-DTfAGnlt.js} +4 -4
  23. package/src/ui/public/assets/MindmapView-DArneR4u.js +2 -0
  24. package/src/ui/public/assets/MindmapView-PoMd2cab.css +1 -0
  25. package/src/ui/public/assets/{MonitorView-G1MQ7M0F.js → MonitorView-BQvMyjEj.js} +1 -1
  26. package/src/ui/public/assets/ProjectManagerView-BPnAWNBJ.css +1 -0
  27. package/src/ui/public/assets/{ProjectManagerView-Cg3GLLxQ.js → ProjectManagerView-BRUyrHfg.js} +1 -1
  28. package/src/ui/public/assets/{ProjectStartupButton-DMlDI4Xn.js → ProjectStartupButton-D0f8OAb-.js} +1 -1
  29. package/src/ui/public/assets/{RemoteManagerDialog-BZLYsW8E.js → RemoteManagerDialog-DXDXCfyD.js} +1 -1
  30. package/src/ui/public/assets/{RemoteRepoCard-BqHQho7O.js → RemoteRepoCard-BRnjC6vm.js} +1 -1
  31. package/src/ui/public/assets/{SourceMapView-CHl1yfAe.js → SourceMapView-B6xgG5mi.js} +2 -2
  32. package/src/ui/public/assets/{SvgIcon-EoI0db7X.js → SvgIcon-GLj_3KcS.js} +1 -1
  33. package/src/ui/public/assets/{UserInputNode-CbwrGTgh.js → UserInputNode-C6Z6Kys_.js} +1 -1
  34. package/src/ui/public/assets/{WorkbenchView-hSDyngLo.js → WorkbenchView-B2EUXleS.js} +98 -97
  35. package/src/ui/public/assets/WorkbenchView-DCL6zHAP.css +1 -0
  36. package/src/ui/public/assets/_plugin-vue_export-helper-DaO48JOV.js +19 -0
  37. package/src/ui/public/assets/configStore-B_2ifbl_.js +1 -0
  38. package/src/ui/public/assets/{index-CUUfiQYd.js → index-DUzH5036.js} +100 -98
  39. package/src/ui/public/assets/index-zdGFM_7W.css +1 -0
  40. package/src/ui/public/assets/{relativeTime-257j87Fm.js → relativeTime-DzYQUHQo.js} +1 -1
  41. package/src/ui/public/assets/taskExecutionExport-C3vFixdJ.js +15 -0
  42. package/src/ui/public/assets/{useWorkbenchProjects-Cj3XIq94.js → useWorkbenchProjects-0fWZsDRX.js} +1 -1
  43. package/src/ui/public/index.html +6 -7
  44. package/src/ui/server/index.js +130 -10
  45. package/src/ui/server/routes/codeAnalysis.js +39 -16
  46. package/src/ui/server/routes/config.js +1396 -1375
  47. package/src/ui/server/routes/git/aiDiffSummary.js +26 -11
  48. package/src/ui/server/routes/peerSettings.js +164 -0
  49. package/src/ui/server/routes/projectStartupAi.js +21 -11
  50. package/src/ui/server/routes/recentDirectoriesAiSummary.js +26 -13
  51. package/src/ui/server/routes/workbench/agentChat.js +811 -811
  52. package/src/ui/server/routes/workbench/agentRoutes.js +9 -0
  53. package/src/ui/server/routes/workbench/imageDescribe.js +52 -16
  54. package/src/ui/server/utils/ensureHub.js +101 -0
  55. package/src/ui/server/utils/instanceRegistry.js +8 -3
  56. package/src/ui/server/utils/peerRuntime.js +77 -0
  57. package/src/ui/server/utils/startServerOnAvailablePort.js +6 -0
  58. package/src/utils/aiModelFallback.js +180 -0
  59. package/src/utils/aiModelFallback.test.js +166 -0
  60. package/src/ui/public/assets/AgentView-BgsIfH1V.js +0 -27
  61. package/src/ui/public/assets/AppVersionBadge-vKeEhoxI.js +0 -2
  62. package/src/ui/public/assets/ChatSilenceBadge-D-lbhnwH.js +0 -16
  63. package/src/ui/public/assets/ChatSilenceBadge-aok2usEh.css +0 -1
  64. package/src/ui/public/assets/MindmapView-BWXQHyV7.js +0 -35
  65. package/src/ui/public/assets/MindmapView-CoUbyadF.css +0 -1
  66. package/src/ui/public/assets/ProjectManagerView-DhC-Y5PR.css +0 -1
  67. package/src/ui/public/assets/TaskExecutorPicker-cEgHnI-W.js +0 -1
  68. package/src/ui/public/assets/WorkbenchView-BVTi_0Mn.css +0 -1
  69. package/src/ui/public/assets/_plugin-vue_export-helper-Birg7-Pq.js +0 -19
  70. package/src/ui/public/assets/configStore-DPzIt2AS.js +0 -1
  71. package/src/ui/public/assets/index-BJJSr6Sb.css +0 -1
  72. package/src/ui/public/assets/taskExecutionExport-CplN8eIn.js +0 -15
  73. package/src/ui/public/assets/toolsStore-BjG-9k17.js +0 -1
  74. /package/src/ui/public/assets/{TaskExecutorPicker-CdRhuBxH.css → taskExecutionExport-CdRhuBxH.css} +0 -0
@@ -38,7 +38,9 @@ import fs from 'node:fs/promises';
38
38
  import path from 'node:path';
39
39
  import express from 'express';
40
40
  import { callLlmStream } from '../workbench/llmClient.js';
41
+ import { buildModelFallbackChain, modelLabel, runWithModelFallback } from '../../../../utils/aiModelFallback.js';
41
42
  import { ensureWithinCwd } from '../../utils/pathGuard.js';
43
+ import logger from '../../utils/logger.js';
42
44
  import { createThinkFilter } from '../../../../cli/ai/streamFilter.js';
43
45
  import { AI_DIFF_SUMMARIES_DIR } from '../../../../paths.js';
44
46
 
@@ -365,22 +367,24 @@ export function registerAiDiffSummaryRoutes({
365
367
  return res.end();
366
368
  }
367
369
 
368
- // 取默认模型
369
- let model;
370
+ // 候选链 = 默认模型打头 + 其余已配置模型;默认模型额度用完 / 限流时自动换下一个
371
+ let chain;
370
372
  try {
371
373
  const rawConfig = await configManager.readRawConfigFile();
372
374
  const models = Array.isArray(rawConfig.models) ? rawConfig.models : [];
373
- model = models.find(m => m.isDefault) || models[0];
375
+ chain = buildModelFallbackChain(models);
374
376
  } catch (err) {
375
377
  send({ type: 'error', error: '读取 AI 配置失败: ' + err.message, code: 'CONFIG_ERR' });
376
378
  finished = true;
377
379
  return res.end();
378
380
  }
379
- if (!model) {
381
+ if (chain.length === 0) {
380
382
  send({ type: 'error', error: '未配置 AI 模型', code: 'NO_MODEL' });
381
383
  finished = true;
382
384
  return res.end();
383
385
  }
386
+ // 缓存键只认「默认模型」:换到备用模型只是兜底,不该让同一份 diff 再缓存一条
387
+ const model = chain[0];
384
388
 
385
389
  const cwd = getCwd();
386
390
  let cacheKey = '';
@@ -437,14 +441,25 @@ export function registerAiDiffSummaryRoutes({
437
441
  summaryText += content;
438
442
  send({ type: 'delta', content });
439
443
  });
440
- const { aborted } = await callLlmStreamImpl(
441
- model,
442
- prompt,
443
- (delta) => {
444
- if (delta.content) contentStream.feed(delta.content);
445
- },
446
- { maxTokens: 1500, systemPrompt, signal: abortController.signal }
444
+ // 已经推过内容就不能换模型(会重复输出半截总结)
445
+ const { result: streamResult } = await runWithModelFallback(
446
+ chain,
447
+ (candidate) => callLlmStreamImpl(
448
+ candidate,
449
+ prompt,
450
+ (delta) => {
451
+ if (delta.content) contentStream.feed(delta.content);
452
+ },
453
+ { maxTokens: 1500, systemPrompt, signal: abortController.signal }
454
+ ),
455
+ {
456
+ isFatal: () => summaryText.length > 0,
457
+ onFallback: ({ from, to, error }) => logger.warn(
458
+ `[ai-diff-summary] 模型 ${modelLabel(from)} 调用失败(${error?.message || error}),改用 ${modelLabel(to)}`
459
+ ),
460
+ }
447
461
  );
462
+ const aborted = Boolean(streamResult?.aborted);
448
463
 
449
464
  if (aborted) {
450
465
  finished = true;
@@ -0,0 +1,164 @@
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
+ // 挂在**主服务**上(127.0.0.1 + Origin 守卫),不是 peer 端口 —— peer 端口是给
18
+ // 别的机器用的(要令牌),这组接口是给本机界面用的(不需要令牌,也拿不到对方机器的
19
+ // 东西)。两套路由同名会混淆,所以这里统一用 /api/peer-settings 前缀。
20
+ //
21
+ // 唯一会返回令牌明文的地方就是 GET /api/peer-settings:配对时用户必须能复制它到
22
+ // 另一台机器。它只在 127.0.0.1 上、只给本机界面看。
23
+
24
+ import { asyncRoute, HttpError } from '../utils/asyncRoute.js';
25
+ import logger from '../utils/logger.js';
26
+ import {
27
+ ensureSelf,
28
+ readState,
29
+ updateSelf,
30
+ upsertPeer,
31
+ removePeer,
32
+ rotateSelfToken,
33
+ PEER_DEFAULT_PORT
34
+ } from '../../../peer/store.js';
35
+ import { fetchPeerInfo } from '../../../peer/client.js';
36
+ import { applyPeerRuntime, isPeerRunning } from '../utils/peerRuntime.js';
37
+
38
+ const PROBE_TIMEOUT_MS = 2500;
39
+
40
+ /** 对端在线探测:失败不抛,只标记 offline(面板要能一眼看出"连不上")。 */
41
+ async function probe(peer) {
42
+ try {
43
+ const info = await fetchPeerInfo(peer, { timeoutMs: PROBE_TIMEOUT_MS });
44
+ return { online: true, remoteName: info.name || '', version: info.version || '', remoteId: info.id || '' };
45
+ } catch (err) {
46
+ return { online: false, error: String(err?.message || err) };
47
+ }
48
+ }
49
+
50
+ export function registerPeerSettingsRoutes({ app }) {
51
+ /**
52
+ * GET /api/peer-settings
53
+ * 本机身份 + 对端清单(附带一次并发探活)。
54
+ */
55
+ app.get('/api/peer-settings', asyncRoute(async (req, res) => {
56
+ const state = await ensureSelf();
57
+ const peers = state.peers || [];
58
+ const probed = await Promise.all(peers.map(async (p) => ({ ...p, ...(await probe(p)) })));
59
+
60
+ // 昵称**以对方自报为准**(对方在它自己的设置里改了就跟着变)。
61
+ // 变了顺手落盘:否则对方改名后、下次它离线时又显示回旧名字。
62
+ const rows = await Promise.all(probed.map(async (p) => {
63
+ const remoteName = p.online ? String(p.remoteName || '').trim() : '';
64
+ if (remoteName && remoteName !== p.name) {
65
+ await upsertPeer({ id: p.id, name: remoteName, url: p.url, token: p.token }).catch(() => {});
66
+ }
67
+ return {
68
+ id: p.id,
69
+ name: remoteName || p.name,
70
+ url: p.url,
71
+ online: p.online,
72
+ version: p.version || '',
73
+ error: p.error || ''
74
+ };
75
+ }));
76
+
77
+ res.json({ success: true, running: isPeerRunning(), self: state.self, peers: rows });
78
+ }));
79
+
80
+ /** POST /api/peer-settings/self —— 改昵称 / 头像 / 端口 / 开关。 */
81
+ app.post('/api/peer-settings/self', asyncRoute(async (req, res) => {
82
+ const body = req.body || {};
83
+ if (body.port !== undefined) {
84
+ const port = Number(body.port);
85
+ if (!Number.isInteger(port) || port <= 0 || port > 65535) {
86
+ throw new HttpError(400, '端口必须是 1-65535 的整数');
87
+ }
88
+ }
89
+ const state = await updateSelf({
90
+ name: typeof body.name === 'string' ? body.name : undefined,
91
+ avatar: typeof body.avatar === 'string' ? body.avatar.slice(0, 200) : undefined,
92
+ port: body.port !== undefined ? Number(body.port) : undefined,
93
+ enabled: typeof body.enabled === 'boolean' ? body.enabled : undefined
94
+ });
95
+ // 立刻生效:开/关/换端口都当场应用,设置面板里的开关不该要求重启进程。
96
+ try {
97
+ await applyPeerRuntime(state.self);
98
+ } catch (err) {
99
+ // 起不来就别假装开着:回滚开关,把真实原因交给用户
100
+ if (state.self.enabled) await updateSelf({ enabled: false });
101
+ throw new HttpError(400, `peer 端口 ${state.self.port} 起不来:${err?.message || err}`);
102
+ }
103
+ res.json({ success: true, running: isPeerRunning(), self: state.self });
104
+ }));
105
+
106
+ /** POST /api/peer-settings/token —— 重新生成令牌(旧的立即失效)。 */
107
+ app.post('/api/peer-settings/token', asyncRoute(async (req, res) => {
108
+ const state = await rotateSelfToken();
109
+ logger.info('[peer-settings] 令牌已重置');
110
+ // 必须重启监听:运行中的实例持有的是**旧令牌**的闭包,不换就不生效
111
+ try {
112
+ await applyPeerRuntime(state.self);
113
+ } catch (err) {
114
+ logger.warn(`[peer-settings] 令牌已换但监听未重启: ${err?.message || err}`);
115
+ }
116
+ res.json({ success: true, running: isPeerRunning(), self: state.self });
117
+ }));
118
+
119
+ /**
120
+ * POST /api/peer-settings/peers —— 添加(或覆盖)一台对端。
121
+ * 先真连一次:连不上/令牌错当场报错,别让用户以为加好了。
122
+ */
123
+ app.post('/api/peer-settings/peers', asyncRoute(async (req, res) => {
124
+ const rawUrl = String(req.body?.url || '').trim();
125
+ const token = String(req.body?.token || '').trim();
126
+ if (!rawUrl) throw new HttpError(400, '请填对端地址');
127
+ if (!token) throw new HttpError(400, '请填对端令牌');
128
+
129
+ const url = (rawUrl.startsWith('http') ? rawUrl : `http://${rawUrl}`).replace(/\/+$/, '');
130
+ let info;
131
+ try {
132
+ info = await fetchPeerInfo({ url, token }, { timeoutMs: 5000 });
133
+ } catch (err) {
134
+ throw new HttpError(400, `连不上对端:${err?.message || err}`);
135
+ }
136
+ // 昵称由**对方**决定(它在自己的设置里写什么就叫什么),本机只在对方没设时退回地址。
137
+ // 这里允许显式传 name,是给将来"本地备注"留的口子;界面不再问用户填。
138
+ const name = String(req.body?.name || '').trim() || String(info.name || '').trim() || url;
139
+ const state = await upsertPeer({ id: info.id || '', name, url, token });
140
+ res.json({ success: true, peer: { id: info.id || '', name, url, online: true, version: info.version || '' }, count: state.peers.length });
141
+ }));
142
+
143
+ /** DELETE /api/peer-settings/peers/:ref —— 移除(ref 可以是 id 或昵称)。 */
144
+ app.delete('/api/peer-settings/peers/:ref', asyncRoute(async (req, res) => {
145
+ const { removed, peer } = await removePeer(decodeURIComponent(req.params.ref));
146
+ if (!removed) throw new HttpError(404, '没有这台对端');
147
+ res.json({ success: true, peer: { id: peer.id, name: peer.name } });
148
+ }));
149
+
150
+ /** POST /api/peer-settings/peers/:ref/test —— 单独重测一台。 */
151
+ app.post('/api/peer-settings/peers/:ref/test', asyncRoute(async (req, res) => {
152
+ const ref = decodeURIComponent(req.params.ref).toLowerCase();
153
+ const state = await readState();
154
+ const peer = (state.peers || []).find((p) => p.id.toLowerCase() === ref || p.name.toLowerCase() === ref || p.url.toLowerCase() === ref);
155
+ if (!peer) throw new HttpError(404, '没有这台对端');
156
+ const result = await probe(peer);
157
+ res.json({ success: true, ...result });
158
+ }));
159
+
160
+ /** GET /api/peer-settings/defaults —— 面板第一次打开时给默认值(端口号)。 */
161
+ app.get('/api/peer-settings/defaults', (req, res) => {
162
+ res.json({ success: true, port: PEER_DEFAULT_PORT });
163
+ });
164
+ }
@@ -43,6 +43,7 @@ import { callLlmJson } from './workbench/llmClient.js';
43
43
  import { stripThinkingBlocks } from './workbench/jsonParse.js';
44
44
  import { buildProjectEntries, canonicalProjectPath } from './workbench/projectRegistry.js';
45
45
  import { TASKS_FILE, readJson } from './workbench/shared.js';
46
+ import { buildModelFallbackChain, modelLabel, runWithModelFallback } from '../../../utils/aiModelFallback.js';
46
47
  import logger from '../utils/logger.js';
47
48
 
48
49
  /** 扫描深度与 npm 面板保持一致,否则模型会推荐面板里根本点不到的包 */
@@ -544,16 +545,17 @@ export function registerProjectStartupAiRoutes({ app, configManager, getCurrentP
544
545
  return res.status(400).json({ success: false, code: 'NO_PROJECT', error: '无法确定当前项目目录' });
545
546
  }
546
547
 
547
- // 取默认模型(isDefault 优先,否则第一个),与提交信息生成 / 目录解读同一口径
548
- let model;
548
+ // 候选链 = 默认模型打头 + 其余已配置模型。默认模型额度用完 / 被限流时换下一个,
549
+ // 而不是让整个「AI 启动建议」面板直接不可用
550
+ let chain;
549
551
  try {
550
552
  const rawConfig = await configManager.readRawConfigFile();
551
553
  const models = Array.isArray(rawConfig.models) ? rawConfig.models : [];
552
- model = models.find(m => m.isDefault) || models[0];
554
+ chain = buildModelFallbackChain(models);
553
555
  } catch (err) {
554
556
  return res.status(500).json({ success: false, code: 'CONFIG_ERR', error: '读取 AI 配置失败: ' + err.message });
555
557
  }
556
- if (!model) {
558
+ if (chain.length === 0) {
557
559
  return res.status(200).json({ success: false, code: 'NO_MODEL', error: '未配置 AI 模型' });
558
560
  }
559
561
 
@@ -569,18 +571,26 @@ export function registerProjectStartupAiRoutes({ app, configManager, getCurrentP
569
571
 
570
572
  const zh = !locale.startsWith('en');
571
573
  const prompt = buildStartupPrompt(facts, locale);
572
- const raw = await callLlmJson(model, prompt, {
573
- timeoutMs: 120000,
574
- maxTokens: 2000,
575
- systemPrompt: zh ? SYSTEM_PROMPT_ZH : SYSTEM_PROMPT_EN,
576
- });
574
+ const { result: raw, model: usedModel } = await runWithModelFallback(
575
+ chain,
576
+ (candidate) => callLlmJson(candidate, prompt, {
577
+ timeoutMs: 120000,
578
+ maxTokens: 2000,
579
+ systemPrompt: zh ? SYSTEM_PROMPT_ZH : SYSTEM_PROMPT_EN,
580
+ }),
581
+ {
582
+ onFallback: ({ from, to, error }) => logger.warn(
583
+ `[project-startup] 模型 ${modelLabel(from)} 调用失败(${error?.message || error}),改用 ${modelLabel(to)}`
584
+ ),
585
+ }
586
+ );
577
587
 
578
588
  const suggestions = validateSuggestions(raw?.suggestions, facts, projectRoot, locale);
579
- logger.info(`[project-startup] ${suggestions.length} 条建议(扫描 ${facts.dirs.length} 个目录,模型 ${model.model})`);
589
+ logger.info(`[project-startup] ${suggestions.length} 条建议(扫描 ${facts.dirs.length} 个目录,模型 ${usedModel.model})`);
580
590
  res.json({
581
591
  success: true,
582
592
  suggestions,
583
- model: model.model,
593
+ model: usedModel.model,
584
594
  analyzedAt: Date.now(),
585
595
  scannedDirs: facts.dirs.length,
586
596
  });
@@ -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 事件