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
|
@@ -31,6 +31,7 @@ import { normalizeAgentEngine, isExternalEngine, engineLabel } from './agentEngi
|
|
|
31
31
|
import { saveAgentAttachments, MAX_ATTACHMENTS } from '../../utils/agentAttachments.js';
|
|
32
32
|
import { registerAgentMarketplaceRoutes } from './agentMarketplace.js';
|
|
33
33
|
import { createProjectListProvider } from './projectTool.js';
|
|
34
|
+
import { askPeerByRef, listPeerStatus } from '../../../../peer/client.js';
|
|
34
35
|
import { createDispatcher } from './dispatchInstruction.js';
|
|
35
36
|
import { createWorkspaceSnapshotter } from '../aiContext/wiring.js';
|
|
36
37
|
// 用户固定提示词(g ai 默认提示词 + 派发提示词)的解析 —— 两条引擎链路共用同一份
|
|
@@ -205,6 +206,12 @@ export function registerAgentRoutes({ app, getCurrentProjectPath, configManager,
|
|
|
205
206
|
// 不给它加缓存:agent 一轮里最多问一两次,而"项目状态"本来就要现读才准。
|
|
206
207
|
const listProjects = createProjectListProvider({ configManager, getCurrentProjectPath });
|
|
207
208
|
|
|
209
|
+
// ask_peer 的两半:对端清单(实时探活)与"问一句"。数据源是 <DATA_DIR>/peers.json,
|
|
210
|
+
// 与本机项目无关,所以直接复用 src/peer/ 的实现 —— 这里不做二次包装。
|
|
211
|
+
// 工具在 CLI(g ai)下没有 peers 上下文,见 tools.js 的 toolAskPeer 兜底说明。
|
|
212
|
+
const askPeer = askPeerByRef;
|
|
213
|
+
const listPeers = () => listPeerStatus({ probe: true });
|
|
214
|
+
|
|
208
215
|
// 把目录登记进「常用目录」(config.json 的 recentDirectories)。register_project 工具
|
|
209
216
|
// 与 run_command 里"建了仓库就顺手登记"那一步共用它 —— 智能体建/克隆完新项目,
|
|
210
217
|
// 用户下次在「切换工作目录」弹窗里就能直接看到,不必重启 GUI 或自己翻路径。
|
|
@@ -812,6 +819,8 @@ export function registerAgentRoutes({ app, getCurrentProjectPath, configManager,
|
|
|
812
819
|
dispatchTask,
|
|
813
820
|
listProjects,
|
|
814
821
|
registerProject,
|
|
822
|
+
askPeer,
|
|
823
|
+
listPeers,
|
|
815
824
|
getContextBlock,
|
|
816
825
|
// 每轮请求的上下文预算(解析结果)。agentChat 拿它调 prepareRequestMessages,
|
|
817
826
|
// 与 CLI turn.js 同一条口径。
|
|
@@ -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,101 @@
|
|
|
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
|
+
// 「开机就有 hub」:项目窗口起来时,发现这台机器上没有活着的 hub,就顺手拉一个。
|
|
16
|
+
//
|
|
17
|
+
// 为什么不要求用户手动先起 hub:拆成两个角色之后,工作台 / 项目管理 / 系统监控 /
|
|
18
|
+
// 思维导图只存在于 hub 进程里。如果每次都得先记得「先开 hub 再开项目窗口」,
|
|
19
|
+
// 这套拆分就是个负担而不是改进。
|
|
20
|
+
//
|
|
21
|
+
// 三段式(顺序很重要):
|
|
22
|
+
// 1. 查实例注册表 —— 有 role=hub 且心跳新鲜的,直接复用(不重复拉进程);
|
|
23
|
+
// 2. 没有才 spawn 一个 detached 的 `g ui --role=hub --port=<固定端口>`;
|
|
24
|
+
// 3. 无论哪种结果都把「hub 在哪个端口」返回给调用方打日志。
|
|
25
|
+
//
|
|
26
|
+
// 为什么端口固定:它要长期被别的窗口/脚本按固定地址访问。hub 那边 maxTries=1,
|
|
27
|
+
// 被占就直接失败 —— 所以两个窗口同时启动时,后起那个只是抢不到端口并退出,
|
|
28
|
+
// 不会出现"hub 悄悄跑到 5601 而没人知道"。
|
|
29
|
+
|
|
30
|
+
import { spawn } from 'node:child_process';
|
|
31
|
+
import { openSync, closeSync, promises as fsPromises } from 'node:fs';
|
|
32
|
+
import { fileURLToPath } from 'node:url';
|
|
33
|
+
import path from 'node:path';
|
|
34
|
+
import os from 'node:os';
|
|
35
|
+
|
|
36
|
+
import { createInstanceRegistry, getRegistryPath } from './instanceRegistry.js';
|
|
37
|
+
import { DATA_DIR } from '../../../paths.js';
|
|
38
|
+
|
|
39
|
+
/** hub 的默认固定端口。peer 默认用 5600(见 src/peer/store.js),两者不重叠。 */
|
|
40
|
+
export const HUB_DEFAULT_PORT = 5599;
|
|
41
|
+
|
|
42
|
+
/** 自动拉起的 hub 的日志(stdio 走 ignore 的话,端口冲突这类失败会彻底静默)。 */
|
|
43
|
+
export const HUB_LOG_FILE = path.join(DATA_DIR, 'hub.log');
|
|
44
|
+
|
|
45
|
+
/** 从模块位置定位 CLI 入口,不依赖调用方怎么被启动(g ui / node server.js / nodemon)。 */
|
|
46
|
+
function resolveCliEntry() {
|
|
47
|
+
return fileURLToPath(new URL('../../../gitCommit.js', import.meta.url));
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* 确保本机有活着的 hub。
|
|
52
|
+
*
|
|
53
|
+
* @param {object} [opts]
|
|
54
|
+
* @param {number} [opts.port] 期望的 hub 端口(默认 HUB_DEFAULT_PORT)
|
|
55
|
+
* @param {string} [opts.cwd] hub 进程的工作目录(默认继承当前进程)
|
|
56
|
+
* @returns {Promise<{reused:boolean, port:number, pid:number|null}>}
|
|
57
|
+
*/
|
|
58
|
+
export async function ensureHubRunning({ port = HUB_DEFAULT_PORT, cwd } = {}) {
|
|
59
|
+
const registry = createInstanceRegistry({
|
|
60
|
+
fs: fsPromises,
|
|
61
|
+
path,
|
|
62
|
+
os,
|
|
63
|
+
registryPath: getRegistryPath()
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
// ① 已经有一个活着的 hub(心跳新鲜 + pid 存活由 list() 统一裁剪)
|
|
67
|
+
try {
|
|
68
|
+
const instances = await registry.list();
|
|
69
|
+
const live = instances.find((i) => i.role === 'hub');
|
|
70
|
+
if (live) return { reused: true, port: live.port, pid: live.pid };
|
|
71
|
+
} catch {
|
|
72
|
+
// 注册表读挂了不该挡住主流程,继续往下走去 spawn
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// ② 拉一个新的
|
|
76
|
+
let fd = null;
|
|
77
|
+
try {
|
|
78
|
+
fd = openSync(HUB_LOG_FILE, 'a');
|
|
79
|
+
} catch {
|
|
80
|
+
// 日志都开不了就退化成 ignore —— hub 照起,只是失败信息看不到
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// 浏览器要开:hub 是"另一扇窗",只在**新拉起**时开一次(复用已有 hub 时不开 ——
|
|
84
|
+
// 否则每开一个项目窗口都多蹦一个标签页,而那个页面本来就在)。
|
|
85
|
+
// ZEN_NO_OPEN=1 可以关掉(CI / 无头环境)。
|
|
86
|
+
const args = [resolveCliEntry(), 'ui', '--role=hub', `--port=${port}`];
|
|
87
|
+
if (process.env.ZEN_NO_OPEN === '1') args.push('--no-open');
|
|
88
|
+
|
|
89
|
+
const child = spawn(process.execPath, args, {
|
|
90
|
+
detached: true,
|
|
91
|
+
// 关键:不继承 stdout,否则父窗口退出时管道断开会把 hub 一起带走
|
|
92
|
+
stdio: fd === null ? 'ignore' : ['ignore', fd, fd],
|
|
93
|
+
cwd: cwd || process.cwd(),
|
|
94
|
+
windowsHide: true,
|
|
95
|
+
env: { ...process.env, ZEN_HUB_AUTOSTART: '1' }
|
|
96
|
+
});
|
|
97
|
+
child.unref();
|
|
98
|
+
if (fd !== null) { try { closeSync(fd); } catch {} }
|
|
99
|
+
|
|
100
|
+
return { reused: false, port, pid: child.pid ?? null };
|
|
101
|
+
}
|
|
@@ -317,7 +317,7 @@ export function createInstanceRegistry({
|
|
|
317
317
|
//
|
|
318
318
|
// 关键点:register / heartbeat / unregister 都只读写 *自己* 的 entry 文件,
|
|
319
319
|
// 跨进程并发时不同 PID 的写入互不干扰,从根本上消除 read-modify-write 覆盖。
|
|
320
|
-
async function register({ pid, port, projectPath, projectName, hostname } = {}) {
|
|
320
|
+
async function register({ pid, port, projectPath, projectName, hostname, role } = {}) {
|
|
321
321
|
if (!Number.isInteger(pid) || pid <= 0) throw new Error('register: pid 必填');
|
|
322
322
|
if (!Number.isInteger(port) || port <= 0) throw new Error('register: port 必填');
|
|
323
323
|
if (!projectPath) throw new Error('register: projectPath 必填');
|
|
@@ -333,7 +333,10 @@ export function createInstanceRegistry({
|
|
|
333
333
|
projectPath,
|
|
334
334
|
startedAt: Date.now(),
|
|
335
335
|
lastHeartbeat: Date.now(),
|
|
336
|
-
hostname: hostname || osMod.hostname()
|
|
336
|
+
hostname: hostname || osMod.hostname(),
|
|
337
|
+
// 运行角色(project | hub | full)。用来回答"这台机器上有没有活着的 hub"——
|
|
338
|
+
// 没有就由新起的项目窗口拉一个(见 utils/ensureHub.js)。
|
|
339
|
+
role: role === 'hub' ? 'hub' : (role === 'full' ? 'full' : 'project')
|
|
337
340
|
};
|
|
338
341
|
|
|
339
342
|
await enqueueWrite(async () => {
|
|
@@ -383,7 +386,8 @@ export function createInstanceRegistry({
|
|
|
383
386
|
projectPath: updates.projectPath,
|
|
384
387
|
startedAt: Date.now(),
|
|
385
388
|
lastHeartbeat: Date.now(),
|
|
386
|
-
hostname: updates.hostname || osMod.hostname()
|
|
389
|
+
hostname: updates.hostname || osMod.hostname(),
|
|
390
|
+
role: updates.role === 'hub' ? 'hub' : (updates.role === 'full' ? 'full' : 'project')
|
|
387
391
|
};
|
|
388
392
|
logger.info(
|
|
389
393
|
`[instanceRegistry] 心跳检测到本实例条目丢失,已自愈重建 pid=${pid} port=${updates.port}`
|
|
@@ -394,6 +398,7 @@ export function createInstanceRegistry({
|
|
|
394
398
|
...(updates.projectPath ? { projectPath: updates.projectPath } : {}),
|
|
395
399
|
...(updates.projectName ? { projectName: updates.projectName } : {}),
|
|
396
400
|
...(Number.isInteger(updates.port) && updates.port > 0 ? { port: updates.port } : {}),
|
|
401
|
+
...(updates.role ? { role: updates.role } : {}),
|
|
397
402
|
lastHeartbeat: Date.now()
|
|
398
403
|
};
|
|
399
404
|
}
|
|
@@ -0,0 +1,77 @@
|
|
|
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
|
+
// peer 监听的生命周期(进程内单例)。
|
|
16
|
+
//
|
|
17
|
+
// 为什么要有这一层:设置面板上「允许其他机器提问」是个开关,如果它只在启动时读一次,
|
|
18
|
+
// 用户改完还得重启 g ui —— 那这个开关就等于摆设。这里把"读配置 → 起/停监听"收成
|
|
19
|
+
// 一个函数,启动时调一次,开关一改再调一次,行为完全一致。
|
|
20
|
+
//
|
|
21
|
+
// 单例的必要性:同一进程只能绑同一个端口。重复 apply 必须先停旧的再起新的,
|
|
22
|
+
// 否则改端口会留下一个"还在旧端口上回答"的幽灵实例。
|
|
23
|
+
|
|
24
|
+
import { startPeerServer } from '../../../peer/server.js';
|
|
25
|
+
import logger from './logger.js';
|
|
26
|
+
|
|
27
|
+
/** @type {import('node:http').Server|null} */
|
|
28
|
+
let handle = null;
|
|
29
|
+
/** 本机 g ui 的端口(peer 服务端收到提问后要环回调用它)。 */
|
|
30
|
+
let localPort = 0;
|
|
31
|
+
|
|
32
|
+
/** g ui 真正就绪后由 index.js 注入。 */
|
|
33
|
+
export function setPeerLocalPort(port) {
|
|
34
|
+
localPort = Number(port) || 0;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** 当前是否在监听。 */
|
|
38
|
+
export function isPeerRunning() {
|
|
39
|
+
return !!handle;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** 停掉监听(幂等)。 */
|
|
43
|
+
export async function stopPeerRuntime() {
|
|
44
|
+
if (!handle) return;
|
|
45
|
+
const h = handle;
|
|
46
|
+
handle = null;
|
|
47
|
+
await new Promise((resolve) => {
|
|
48
|
+
try {
|
|
49
|
+
h.close(() => resolve());
|
|
50
|
+
} catch {
|
|
51
|
+
resolve();
|
|
52
|
+
}
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* 按当前身份配置应用 peer 监听:enabled=true 就起,false 就停。
|
|
58
|
+
*
|
|
59
|
+
* @param {object} self peers.json 里的 self
|
|
60
|
+
* @param {{getVersion?: () => string}} [opts]
|
|
61
|
+
* @returns {Promise<{running:boolean, port:number|null}>}
|
|
62
|
+
*/
|
|
63
|
+
export async function applyPeerRuntime(self, { getVersion } = {}) {
|
|
64
|
+
await stopPeerRuntime();
|
|
65
|
+
if (!self?.enabled || !self.token) return { running: false, port: null };
|
|
66
|
+
if (!localPort) {
|
|
67
|
+
logger.warn('[peer] 本机端口未知,暂时无法开启(主服务还没 listening)');
|
|
68
|
+
return { running: false, port: null };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
handle = await startPeerServer({
|
|
72
|
+
self,
|
|
73
|
+
getLocalPort: () => localPort,
|
|
74
|
+
getVersion
|
|
75
|
+
});
|
|
76
|
+
return { running: true, port: self.port };
|
|
77
|
+
}
|
|
@@ -252,6 +252,12 @@ export async function startServerOnAvailablePort({
|
|
|
252
252
|
}
|
|
253
253
|
}
|
|
254
254
|
|
|
255
|
+
// 固定端口模式(maxTries=1):把"被谁占 / 为什么不让绑"说清楚,别让用户去猜
|
|
256
|
+
if (maxTries <= 1) {
|
|
257
|
+
logger.error(`固定端口 ${startPort} 不可用(${lastErrorCode || '未知原因'})。固定端口模式不会顺延。`);
|
|
258
|
+
logger.error('处理办法:换一个 --port,或先关掉占用该端口的进程(netstat -ano | findstr :' + startPort + ')。');
|
|
259
|
+
process.exit(1);
|
|
260
|
+
}
|
|
255
261
|
logger.error(`无法找到可用端口 (尝试范围: ${startPort}-${maxPort - 1})`);
|
|
256
262
|
if (process.platform === 'win32' && getExcludedPortRanges().length) {
|
|
257
263
|
logger.error(
|
|
@@ -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
|
+
};
|