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.
- package/package.json +6 -4
- package/src/aiCommit.js +61 -54
- package/src/cli/ai/tools.js +71 -8
- package/src/cli/ai/webSearch.js +108 -21
- package/src/cli/peer.js +272 -0
- package/src/config.js +14 -4
- package/src/gitCommit.js +34 -1
- package/src/paths.js +6 -0
- package/src/peer/client.js +165 -0
- package/src/peer/localAsk.js +130 -0
- package/src/peer/server.js +183 -0
- package/src/peer/store.js +292 -0
- package/src/ui/public/assets/AgentView-BtnAYtte.js +42 -0
- package/src/ui/public/assets/{AgentView-Bslr4Bc8.css → AgentView-ByQ0iJFU.css} +1 -1
- package/src/ui/public/assets/AppVersionBadge-BcXo-yuu.js +2 -0
- package/src/ui/public/assets/{BranchSelector-D4Igm7sR.js → BranchSelector-5t-T3FB6.js} +1 -1
- package/src/ui/public/assets/{CommitForm-CXBA-E-6.js → CommitForm-BMfp3Eqh.js} +3 -3
- package/src/ui/public/assets/{CommonDialog-DrbQC1fm.js → CommonDialog-BiF2fMgr.js} +1 -1
- package/src/ui/public/assets/{EditorView-CXj7-8DC.js → EditorView-CtmZ7RZp.js} +1 -1
- package/src/ui/public/assets/{FlowExecutionViewer-CjL1DfmI.js → FlowExecutionViewer-D2lchSo7.js} +1 -1
- package/src/ui/public/assets/{FlowOrchestrationWorkspace-p7_NcaY-.js → FlowOrchestrationWorkspace-BY2zLwWB.js} +1 -1
- package/src/ui/public/assets/{LogList-SDV2f7R6.js → LogList-DTfAGnlt.js} +4 -4
- package/src/ui/public/assets/MindmapView-DArneR4u.js +2 -0
- package/src/ui/public/assets/MindmapView-PoMd2cab.css +1 -0
- package/src/ui/public/assets/{MonitorView-G1MQ7M0F.js → MonitorView-BQvMyjEj.js} +1 -1
- package/src/ui/public/assets/ProjectManagerView-BPnAWNBJ.css +1 -0
- package/src/ui/public/assets/{ProjectManagerView-Cg3GLLxQ.js → ProjectManagerView-BRUyrHfg.js} +1 -1
- package/src/ui/public/assets/{ProjectStartupButton-DMlDI4Xn.js → ProjectStartupButton-D0f8OAb-.js} +1 -1
- package/src/ui/public/assets/{RemoteManagerDialog-BZLYsW8E.js → RemoteManagerDialog-DXDXCfyD.js} +1 -1
- package/src/ui/public/assets/{RemoteRepoCard-BqHQho7O.js → RemoteRepoCard-BRnjC6vm.js} +1 -1
- package/src/ui/public/assets/{SourceMapView-CHl1yfAe.js → SourceMapView-B6xgG5mi.js} +2 -2
- package/src/ui/public/assets/{SvgIcon-EoI0db7X.js → SvgIcon-GLj_3KcS.js} +1 -1
- package/src/ui/public/assets/{UserInputNode-CbwrGTgh.js → UserInputNode-C6Z6Kys_.js} +1 -1
- package/src/ui/public/assets/{WorkbenchView-hSDyngLo.js → WorkbenchView-B2EUXleS.js} +98 -97
- package/src/ui/public/assets/WorkbenchView-DCL6zHAP.css +1 -0
- package/src/ui/public/assets/_plugin-vue_export-helper-DaO48JOV.js +19 -0
- package/src/ui/public/assets/configStore-B_2ifbl_.js +1 -0
- package/src/ui/public/assets/{index-CUUfiQYd.js → index-DUzH5036.js} +100 -98
- package/src/ui/public/assets/index-zdGFM_7W.css +1 -0
- package/src/ui/public/assets/{relativeTime-257j87Fm.js → relativeTime-DzYQUHQo.js} +1 -1
- package/src/ui/public/assets/taskExecutionExport-C3vFixdJ.js +15 -0
- package/src/ui/public/assets/{useWorkbenchProjects-Cj3XIq94.js → useWorkbenchProjects-0fWZsDRX.js} +1 -1
- package/src/ui/public/index.html +6 -7
- package/src/ui/server/index.js +130 -10
- package/src/ui/server/routes/codeAnalysis.js +39 -16
- package/src/ui/server/routes/config.js +1396 -1375
- package/src/ui/server/routes/git/aiDiffSummary.js +26 -11
- package/src/ui/server/routes/peerSettings.js +164 -0
- package/src/ui/server/routes/projectStartupAi.js +21 -11
- package/src/ui/server/routes/recentDirectoriesAiSummary.js +26 -13
- package/src/ui/server/routes/workbench/agentChat.js +811 -811
- package/src/ui/server/routes/workbench/agentRoutes.js +9 -0
- package/src/ui/server/routes/workbench/imageDescribe.js +52 -16
- package/src/ui/server/utils/ensureHub.js +101 -0
- package/src/ui/server/utils/instanceRegistry.js +8 -3
- package/src/ui/server/utils/peerRuntime.js +77 -0
- package/src/ui/server/utils/startServerOnAvailablePort.js +6 -0
- 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/AppVersionBadge-vKeEhoxI.js +0 -2
- 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/configStore-DPzIt2AS.js +0 -1
- 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
|
@@ -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
|
|
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
|
-
|
|
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 (
|
|
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
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
(
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
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
|
-
//
|
|
548
|
-
|
|
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
|
-
|
|
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 (
|
|
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
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
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} 个目录,模型 ${
|
|
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:
|
|
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
|
-
//
|
|
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 事件
|