zen-gitsync 2.18.27 → 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 (51) hide show
  1. package/package.json +6 -2
  2. package/src/cli/ai/tools.js +71 -8
  3. package/src/cli/ai/webSearch.js +108 -21
  4. package/src/cli/peer.js +272 -0
  5. package/src/config.js +14 -4
  6. package/src/gitCommit.js +34 -1
  7. package/src/paths.js +6 -0
  8. package/src/peer/client.js +165 -0
  9. package/src/peer/localAsk.js +130 -0
  10. package/src/peer/server.js +183 -0
  11. package/src/peer/store.js +292 -0
  12. package/src/ui/public/assets/{AgentView-DunbYHMn.js → AgentView-BtnAYtte.js} +1 -1
  13. package/src/ui/public/assets/AppVersionBadge-BcXo-yuu.js +2 -0
  14. package/src/ui/public/assets/{BranchSelector-BKA3fL9E.js → BranchSelector-5t-T3FB6.js} +1 -1
  15. package/src/ui/public/assets/{CommitForm-CVPGpci8.js → CommitForm-BMfp3Eqh.js} +1 -1
  16. package/src/ui/public/assets/{CommonDialog-ZwJlTF5A.js → CommonDialog-BiF2fMgr.js} +1 -1
  17. package/src/ui/public/assets/{EditorView-DDzACKq1.js → EditorView-CtmZ7RZp.js} +1 -1
  18. package/src/ui/public/assets/{FlowExecutionViewer-BJOiCa6S.js → FlowExecutionViewer-D2lchSo7.js} +1 -1
  19. package/src/ui/public/assets/{FlowOrchestrationWorkspace-BDnAdRr9.js → FlowOrchestrationWorkspace-BY2zLwWB.js} +1 -1
  20. package/src/ui/public/assets/{LogList-DbGz7_dy.js → LogList-DTfAGnlt.js} +1 -1
  21. package/src/ui/public/assets/{MindmapView-mbTFeVj8.js → MindmapView-DArneR4u.js} +1 -1
  22. package/src/ui/public/assets/{MonitorView-O7I40RiB.js → MonitorView-BQvMyjEj.js} +1 -1
  23. package/src/ui/public/assets/{ProjectManagerView-D1NVbIOW.js → ProjectManagerView-BRUyrHfg.js} +1 -1
  24. package/src/ui/public/assets/{ProjectStartupButton-C21zy3Bg.js → ProjectStartupButton-D0f8OAb-.js} +1 -1
  25. package/src/ui/public/assets/{RemoteManagerDialog-CpSrItn2.js → RemoteManagerDialog-DXDXCfyD.js} +1 -1
  26. package/src/ui/public/assets/{RemoteRepoCard-CXDXPJK2.js → RemoteRepoCard-BRnjC6vm.js} +1 -1
  27. package/src/ui/public/assets/{SourceMapView-C9qTeaOC.js → SourceMapView-B6xgG5mi.js} +1 -1
  28. package/src/ui/public/assets/{SvgIcon-BmXhx-vA.js → SvgIcon-GLj_3KcS.js} +1 -1
  29. package/src/ui/public/assets/{UserInputNode-i7ER8ZCn.js → UserInputNode-C6Z6Kys_.js} +1 -1
  30. package/src/ui/public/assets/{WorkbenchView-pZz9ykZT.js → WorkbenchView-B2EUXleS.js} +1 -1
  31. package/src/ui/public/assets/_plugin-vue_export-helper-DaO48JOV.js +19 -0
  32. package/src/ui/public/assets/configStore-B_2ifbl_.js +1 -0
  33. package/src/ui/public/assets/{index-DW-2cjNW.js → index-DUzH5036.js} +63 -60
  34. package/src/ui/public/assets/index-zdGFM_7W.css +1 -0
  35. package/src/ui/public/assets/{relativeTime-DsG6UERK.js → relativeTime-DzYQUHQo.js} +1 -1
  36. package/src/ui/public/assets/{taskExecutionExport-C3dV5cuE.js → taskExecutionExport-C3vFixdJ.js} +1 -1
  37. package/src/ui/public/assets/{useWorkbenchProjects-B6k5mmVC.js → useWorkbenchProjects-0fWZsDRX.js} +1 -1
  38. package/src/ui/public/index.html +6 -6
  39. package/src/ui/server/index.js +130 -10
  40. package/src/ui/server/routes/config.js +1317 -1316
  41. package/src/ui/server/routes/peerSettings.js +164 -0
  42. package/src/ui/server/routes/workbench/agentChat.js +811 -811
  43. package/src/ui/server/routes/workbench/agentRoutes.js +9 -0
  44. package/src/ui/server/utils/ensureHub.js +101 -0
  45. package/src/ui/server/utils/instanceRegistry.js +8 -3
  46. package/src/ui/server/utils/peerRuntime.js +77 -0
  47. package/src/ui/server/utils/startServerOnAvailablePort.js +6 -0
  48. package/src/ui/public/assets/AppVersionBadge-9B4b0hbP.js +0 -2
  49. package/src/ui/public/assets/_plugin-vue_export-helper-D6ACRIgd.js +0 -19
  50. package/src/ui/public/assets/configStore-BQoByr2v.js +0 -1
  51. package/src/ui/public/assets/index-B9mqQJGC.css +0 -1
@@ -0,0 +1,272 @@
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
+ // `g peer` —— 跨机器通信的配置入口(g ai 与另一台机器上的 g ai 对话)。
16
+ //
17
+ // 三条命令构成最小闭环:
18
+ // A 上 `g peer on` → 打印本机地址与令牌
19
+ // B 上 `g peer add 服务器 <地址> --token <令牌>` → 校验一次 /info 并把对端 id 记下来
20
+ // B 上 `g peer ask 服务器 "你那边有哪些项目"` → 直接跑通一轮跨机器问答
21
+ //
22
+ // 为什么不把这些塞进 config.json / 界面:peer 身份只跟机器有关、且是"一次配好长期用",
23
+ // 命令行更快,也便于在两台机器上照抄同一套步骤。
24
+
25
+ import os from 'node:os';
26
+ import chalk from 'chalk';
27
+
28
+ import {
29
+ ensureSelf,
30
+ updateSelf,
31
+ readState,
32
+ listPeers,
33
+ removePeer,
34
+ upsertPeer,
35
+ PEER_DEFAULT_PORT,
36
+ maskToken
37
+ } from '../peer/store.js';
38
+ import { fetchPeerInfo, askPeer } from '../peer/client.js';
39
+
40
+ const USAGE = `
41
+ 用法:
42
+ g peer 查看本机节点身份与已登记的对端
43
+ g peer on [--name 昵称] [--port 5600] [--host 0.0.0.0]
44
+ 开启本机的 peer 端口(重启 g ui 后生效)
45
+ g peer off 关闭本机的 peer 端口
46
+ g peer add <昵称> <地址> --token <令牌>
47
+ 登记一台对端,例如:
48
+ g peer add 服务器 http://192.168.1.20:5600 --token <令牌>
49
+ g peer rm <昵称|id> 移除一台对端
50
+ g peer ask <昵称|id> <问题> 向对端提一个问题(端到端自检)
51
+ g peer help 显示本帮助
52
+
53
+ 说明:
54
+ · peer 端口是**独立端口**,与 g ui 的随机端口无关;只有 g ui 在跑时才监听。
55
+ · 令牌在「g peer on」时自动生成,对端要填的就是这串;它等价于"允许对方问我"的钥匙,
56
+ 不要贴到聊天/仓库里。
57
+ · 地址用对端在局域网内的 IP(下面会列出本机可作为地址的网卡)。
58
+ `.trim();
59
+
60
+ /** 列出本机可能对外的 IPv4 地址(给用户抄地址用,跳过回环与虚拟网卡)。 */
61
+ function localAddresses() {
62
+ const out = [];
63
+ for (const [name, list] of Object.entries(os.networkInterfaces())) {
64
+ for (const item of list || []) {
65
+ if (item.family !== 'IPv4' && item.family !== 4) continue;
66
+ if (item.internal) continue;
67
+ out.push({ name, address: item.address });
68
+ }
69
+ }
70
+ return out;
71
+ }
72
+
73
+ /** 从 argv 里取 `--key value` / `--flag`。 */
74
+ function parseFlags(argv) {
75
+ const flags = {};
76
+ const rest = [];
77
+ for (let i = 0; i < argv.length; i += 1) {
78
+ const a = argv[i];
79
+ if (a.startsWith('--')) {
80
+ const key = a.slice(2);
81
+ const next = argv[i + 1];
82
+ if (next && !next.startsWith('--')) { flags[key] = next; i += 1; } else { flags[key] = true; }
83
+ } else {
84
+ rest.push(a);
85
+ }
86
+ }
87
+ return { flags, rest };
88
+ }
89
+
90
+ async function cmdStatus() {
91
+ const state = await ensureSelf();
92
+ const self = state.self;
93
+ console.log(chalk.bold('本机节点'));
94
+ console.log(` 昵称 ${self.name}`);
95
+ console.log(` 节点 id ${self.id}`);
96
+ console.log(` 端口 ${self.host}:${self.port} ${self.enabled ? chalk.green('已开启') : chalk.gray('未开启(g peer on 开启)')}`);
97
+ console.log(` 令牌 ${self.enabled ? self.token : maskToken(self.token)}`);
98
+
99
+ const peers = await listPeers();
100
+ console.log('');
101
+ console.log(chalk.bold(`对端(${peers.length})`));
102
+ if (!peers.length) {
103
+ console.log(chalk.gray(' 还没有。在另一台机器上 `g peer on`,然后 `g peer add <昵称> <地址> --token <令牌>`。'));
104
+ return;
105
+ }
106
+ for (const p of peers) {
107
+ let tag = chalk.gray('[离线]');
108
+ try {
109
+ const info = await fetchPeerInfo(p, { timeoutMs: 2000 });
110
+ tag = chalk.green('[在线]') + chalk.gray(` ${info.name} ${info.version || ''}`.trimEnd());
111
+ } catch (e) {
112
+ tag = chalk.gray(`[离线] ${e.message}`);
113
+ }
114
+ console.log(` ${p.name.padEnd(12)} ${p.url} ${tag}`);
115
+ }
116
+ }
117
+
118
+ async function cmdOn(flags) {
119
+ const port = flags.port ? Number(flags.port) : PEER_DEFAULT_PORT;
120
+ if (!Number.isInteger(port) || port <= 0 || port > 65535) {
121
+ console.error(chalk.red(`端口不合法: ${flags.port}`));
122
+ process.exitCode = 1;
123
+ return;
124
+ }
125
+ const before = await ensureSelf();
126
+ const after = await updateSelf({
127
+ enabled: true,
128
+ port,
129
+ name: typeof flags.name === 'string' ? flags.name : undefined,
130
+ host: typeof flags.host === 'string' ? flags.host : undefined
131
+ });
132
+ const self = after.self;
133
+
134
+ console.log(chalk.green(`已开启 peer:${self.host}:${self.port}(节点 ${self.id}「${self.name}」)`));
135
+ if (before.self.enabled !== true) {
136
+ console.log(chalk.yellow('重启 g ui 后才会真正开始监听(端口在服务启动时绑定)。'));
137
+ }
138
+ console.log('');
139
+ console.log(chalk.bold('给另一台机器用的信息'));
140
+ console.log(` 地址: http://<本机局域网 IP>:${self.port}`);
141
+ const addrs = localAddresses();
142
+ if (addrs.length) {
143
+ console.log(chalk.gray(' 本机可用的局域网地址:'));
144
+ for (const a of addrs) console.log(chalk.gray(` ${a.address} (${a.name})`));
145
+ } else {
146
+ console.log(chalk.gray(' 没探到局域网地址(可能没连网络)。'));
147
+ }
148
+ console.log(` 令牌: ${self.token}`);
149
+ console.log('');
150
+ console.log(chalk.gray('在另一台机器上执行:'));
151
+ console.log(chalk.gray(` g peer add ${self.name} http://${addrs[0]?.address || '<本机IP>'}:${self.port} --token ${self.token}`));
152
+ }
153
+
154
+ async function cmdOff() {
155
+ await ensureSelf();
156
+ await updateSelf({ enabled: false });
157
+ console.log(chalk.green('已关闭本机的 peer 端口(重启 g ui 后不再监听)。'));
158
+ }
159
+
160
+ async function cmdAdd(rest, flags) {
161
+ const [name, url] = rest;
162
+ const token = typeof flags.token === 'string' ? flags.token.trim() : '';
163
+ if (!name || !url) {
164
+ console.error(chalk.red('用法: g peer add <昵称> <地址> --token <令牌>'));
165
+ process.exitCode = 1;
166
+ return;
167
+ }
168
+ if (!token) {
169
+ console.error(chalk.red('缺少 --token。<令牌> 在对端机器执行 `g peer on` 时会打印。'));
170
+ process.exitCode = 1;
171
+ return;
172
+ }
173
+ const normalized = url.startsWith('http') ? url : `http://${url}`;
174
+
175
+ // 先探一次:能连上 + 令牌对 + 拿到对方的真实 id(之后按 id 寻址,改 IP 也不会认错人)
176
+ let info;
177
+ try {
178
+ info = await fetchPeerInfo({ url: normalized.replace(/\/+$/, ''), token }, { timeoutMs: 5000 });
179
+ } catch (e) {
180
+ console.error(chalk.red(`登记失败:${e.message}`));
181
+ console.error(chalk.gray('排查顺序:对端 g ui 是否在跑 → 对端是否 `g peer on` → 局域网是否互通(防火墙)→ 令牌是否正确。'));
182
+ process.exitCode = 1;
183
+ return;
184
+ }
185
+
186
+ await upsertPeer({ id: info.id || '', name, url: normalized, token });
187
+ console.log(chalk.green(`已登记对端「${name}」→ ${normalized}(对方节点 ${info.id || '未知'}「${info.name}」)`));
188
+ console.log(chalk.gray(`自检:g peer ask ${name} "你那边现在有哪些项目?"`));
189
+ }
190
+
191
+ async function cmdRm(rest) {
192
+ const ref = rest[0];
193
+ if (!ref) {
194
+ console.error(chalk.red('用法: g peer rm <昵称|id>'));
195
+ process.exitCode = 1;
196
+ return;
197
+ }
198
+ const { removed, peer } = await removePeer(ref);
199
+ console.log(removed ? chalk.green(`已移除对端「${peer.name}」`) : chalk.yellow(`没有找到「${ref}」`));
200
+ }
201
+
202
+ async function cmdAsk(rest) {
203
+ const ref = rest[0];
204
+ const question = rest.slice(1).join(' ').trim();
205
+ if (!ref || !question) {
206
+ console.error(chalk.red('用法: g peer ask <昵称|id> <问题>'));
207
+ process.exitCode = 1;
208
+ return;
209
+ }
210
+ const state = await ensureSelf();
211
+ const peers = await listPeers();
212
+ const peer = peers.find((p) => p.id.toLowerCase() === ref.toLowerCase() || p.name.toLowerCase() === ref.toLowerCase());
213
+ if (!peer) {
214
+ console.error(chalk.red(`没有找到对端「${ref}」。现有:${peers.map((p) => p.name).join(' / ') || '(空)'}`));
215
+ process.exitCode = 1;
216
+ return;
217
+ }
218
+ const full = (await readState()).peers.find((p) => p.url === peer.url);
219
+ console.log(chalk.gray(`→ 正在问「${peer.name}」(对方是另一个智能体,可能需要一会儿)…`));
220
+ try {
221
+ const result = await askPeer(full, state.self, question);
222
+ console.log('');
223
+ console.log(chalk.bold(`【${peer.name}】`));
224
+ console.log(result.answer || chalk.yellow('(对方没有给出正文)'));
225
+ console.log('');
226
+ console.log(chalk.gray(`用时 ${(result.ms / 1000).toFixed(1)}s,会话 ${result.sessionId || '新会话'}`));
227
+ } catch (e) {
228
+ console.error(chalk.red(`提问失败:${e.message}`));
229
+ process.exitCode = 1;
230
+ }
231
+ }
232
+
233
+ /**
234
+ * `g peer` 入口。
235
+ * @param {string[]} argv 已经去掉 `peer` 之后的参数
236
+ */
237
+ export async function runPeerCli(argv = []) {
238
+ const sub = argv[0] || 'status';
239
+ const { flags, rest } = parseFlags(argv.slice(1));
240
+
241
+ switch (sub) {
242
+ case 'status':
243
+ case 'ls':
244
+ await cmdStatus();
245
+ return;
246
+ case 'on':
247
+ await cmdOn(flags);
248
+ return;
249
+ case 'off':
250
+ await cmdOff();
251
+ return;
252
+ case 'add':
253
+ await cmdAdd(rest, flags);
254
+ return;
255
+ case 'rm':
256
+ case 'remove':
257
+ await cmdRm(rest);
258
+ return;
259
+ case 'ask':
260
+ await cmdAsk(rest);
261
+ return;
262
+ case 'help':
263
+ case '-h':
264
+ case '--help':
265
+ console.log(USAGE);
266
+ return;
267
+ default:
268
+ console.error(chalk.red(`未知子命令: ${sub}`));
269
+ console.log(USAGE);
270
+ process.exitCode = 1;
271
+ }
272
+ }
package/src/config.js CHANGED
@@ -249,25 +249,28 @@ export function normalizeAgentDefaultPrompt(value) {
249
249
  * · firecrawl —— 无 key 也能用(按 IP 限流),注册免费 key 后每月 1,000 credits
250
250
  * · tavily —— 必须有 key(免费 1,000 credits/月)
251
251
  * · exa —— 必须有 key(免费 $10 credits/月)
252
+ * · searxng —— 自建实例,不需要 key;必须在 baseUrls.searxng 填实例地址
252
253
  */
253
- export const WEB_SEARCH_PROVIDERS = ['firecrawl', 'tavily', 'exa'];
254
+ export const WEB_SEARCH_PROVIDERS = ['firecrawl', 'tavily', 'exa', 'searxng'];
254
255
  // 聚合模式标识(与 src/cli/ai/webSearch.js 的 WEB_SEARCH_AGGREGATE_ID 一致)。
255
256
  // 作为「默认搜索引擎」的合法取值 = 每次搜索都并发多家、合并去重。
256
257
  export const WEB_SEARCH_AGGREGATE_ID = 'all';
257
258
  const WEB_SEARCH_DEFAULT_PROVIDER = 'firecrawl';
258
259
  const WEB_SEARCH_API_KEY_MAX = 300;
260
+ const WEB_SEARCH_BASE_URL_MAX = 300;
259
261
  const WEB_SEARCH_PROXY_MAX = 300;
260
262
 
261
263
  /**
262
264
  * 规范化「联网搜索」配置(全局配置,跨项目共享)。
263
265
  *
264
- * 形状:{ enabled, provider, apiKeys: {[provider]: key}, proxy }
266
+ * 形状:{ enabled, provider, apiKeys: {[provider]: key},
267
+ * baseUrls: {[provider]: url}, proxy }
265
268
  *
266
269
  * 语义约定:
267
270
  * · undefined / null → null(没配过,调用方回落 defaultConfig = 默认开启 + firecrawl)
268
271
  * · 非普通对象(含数组)→ null(手改坏了,整组回落默认,与 normalizeAgent* 同一口径)
269
272
  * · **单个字段坏只回落该字段**,不整组拒绝 —— 一个 key 写错不该把"开关/代理"也重置
270
- * · apiKeys 只保留白名单 provider 的键;非字符串值 → 该键置空串
273
+ * · apiKeys / baseUrls 只保留白名单 provider 的键;非字符串值 → 该键置空串
271
274
  * · proxy 空串是合法值(= 直连),照写;非字符串 → 空串
272
275
  */
273
276
  export function normalizeWebSearch(value) {
@@ -276,9 +279,14 @@ export function normalizeWebSearch(value) {
276
279
  const rawKeys = (value.apiKeys && typeof value.apiKeys === 'object' && !Array.isArray(value.apiKeys))
277
280
  ? value.apiKeys : {};
278
281
  const apiKeys = {};
282
+ const rawBaseUrls = (value.baseUrls && typeof value.baseUrls === 'object' && !Array.isArray(value.baseUrls))
283
+ ? value.baseUrls : {};
284
+ const baseUrls = {};
279
285
  for (const p of WEB_SEARCH_PROVIDERS) {
280
286
  const v = rawKeys[p];
281
287
  apiKeys[p] = typeof v === 'string' ? v.trim().slice(0, WEB_SEARCH_API_KEY_MAX) : '';
288
+ const u = rawBaseUrls[p];
289
+ baseUrls[p] = typeof u === 'string' ? u.trim().slice(0, WEB_SEARCH_BASE_URL_MAX) : '';
282
290
  }
283
291
  const providerRaw = typeof value.provider === 'string' ? value.provider.trim().toLowerCase() : '';
284
292
  // 'all' = 聚合模式,也是合法的默认值(见 src/cli/ai/webSearch.js 的 resolveMode)
@@ -287,6 +295,7 @@ export function normalizeWebSearch(value) {
287
295
  enabled: typeof value.enabled === 'boolean' ? value.enabled : true,
288
296
  provider: providerOk ? providerRaw : WEB_SEARCH_DEFAULT_PROVIDER,
289
297
  apiKeys,
298
+ baseUrls,
290
299
  proxy: typeof value.proxy === 'string' ? value.proxy.trim().slice(0, WEB_SEARCH_PROXY_MAX) : '',
291
300
  };
292
301
  }
@@ -400,7 +409,8 @@ const defaultConfig = {
400
409
  webSearch: {
401
410
  enabled: true,
402
411
  provider: 'firecrawl',
403
- apiKeys: { firecrawl: '', tavily: '', exa: '' },
412
+ apiKeys: { firecrawl: '', tavily: '', exa: '', searxng: '' },
413
+ baseUrls: { firecrawl: '', tavily: '', exa: '', searxng: '' },
404
414
  proxy: '',
405
415
  },
406
416
  // 工作台任务执行器(claude | opencode | codex | codebuddy)。全局配置,跨项目共享。
package/src/gitCommit.js CHANGED
@@ -276,7 +276,29 @@ async function main() {
276
276
  }
277
277
 
278
278
  const startUIServer = await loadStartUIServer();
279
- await startUIServer(false, false); // 传入noOpen=false, savePort=false
279
+
280
+ // `g ui --role=hub --port=5598`:跨机器的常驻节点。
281
+ // hub —— 只挂公共板块(工作台/项目管理/系统监控/思维导图),项目级路由一律不注册
282
+ // project —— 默认,7 个板块都在(保持原行为)
283
+ // 端口在 hub 下是**钉死**的(被占直接失败,不顺延):它会长期被别的进程按固定
284
+ // 地址访问,悄悄换端口比启动失败更难排查。
285
+ const readFlag = (name) => {
286
+ const withEq = process.argv.find((a) => a.startsWith(`--${name}=`));
287
+ if (withEq) return withEq.slice(name.length + 3).trim();
288
+ const idx = process.argv.indexOf(`--${name}`);
289
+ return idx >= 0 ? String(process.argv[idx + 1] || '').trim() : '';
290
+ };
291
+ const uiRole = readFlag('role').toLowerCase();
292
+ const uiPortRaw = Number(readFlag('port'));
293
+ const uiPort = Number.isInteger(uiPortRaw) && uiPortRaw > 0 && uiPortRaw < 65536 ? uiPortRaw : undefined;
294
+ // 自动拉起的 hub 带 --no-open:它是后台常驻进程,弹浏览器等于每次开项目窗口
295
+ // 都多蹦一个标签页。
296
+ const uiNoOpen = process.argv.includes('--no-open');
297
+
298
+ await startUIServer(uiNoOpen, false, {
299
+ role: uiRole === 'hub' ? 'hub' : (uiRole === 'full' ? 'full' : 'project'),
300
+ port: uiPort
301
+ }); // savePort=false
280
302
  return;
281
303
  }
282
304
 
@@ -312,6 +334,17 @@ async function main() {
312
334
  return;
313
335
  }
314
336
 
337
+ // 跨机器通信(g peer):让这台机器上的 g ai 与另一台机器的 g ai 互相提问。
338
+ //
339
+ // 配置写进 <DATA_DIR>/peers.json(不跟项目走),端口是**独立**的一个固定端口 ——
340
+ // 主服务没有认证层,绝不能把整张 /api 暴露到局域网,细节见 src/peer/server.js。
341
+ // 同样只认 argv[2],避免 `g -m peer` 被判成子命令。
342
+ if (process.argv[2] === 'peer') {
343
+ const { runPeerCli } = await import('./cli/peer.js');
344
+ await runPeerCli(process.argv.slice(3));
345
+ return;
346
+ }
347
+
315
348
  // 检查是否是添加脚本命令
316
349
  if (process.argv.includes('addScript')) {
317
350
  await addScriptToPackageJson();
package/src/paths.js CHANGED
@@ -61,6 +61,12 @@ export const LEGACY_INSTANCES_DIR = path.join(os.homedir(), '.zen-gitsync-instan
61
61
  // 这里只登记路径,不参与搬迁。
62
62
  export const LEGACY_INSTANCES_FILE = path.join(os.homedir(), '.zen-gitsync-instances.json');
63
63
 
64
+ // ── 跨机器 peer(g ai 与 g ai 对话)────────────────────────────
65
+ // 身份(昵称/头像/token/固定端口) + 对端清单 + 每个对端续聊用的会话 id。
66
+ // 与 config.json 分开存:它只跟"这台机器"有关,不属于任何项目,也不该跟着
67
+ // 项目配置一起被同步/备份。
68
+ export const PEERS_FILE = path.join(DATA_DIR, 'peers.json');
69
+
64
70
  // ── CLI 贴图 ─────────────────────────────────────────────────
65
71
  export const AI_IMAGES_DIR = path.join(DATA_DIR, 'ai-images');
66
72
  // 旧的是 ~/.git-commit-tool/ai-images(父目录名与主配置不同,少了个 .json)
@@ -0,0 +1,165 @@
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 客户端:本机 g ai 去问别的机器(跨机器通信的"出口")。
16
+ //
17
+ // 与 server.js 是镜像的两半:
18
+ // server.js —— 别人来问我(入口,固定端口,令牌校验)
19
+ // client.js —— 我去问别人(出口,读 peers.json 里的地址与令牌)
20
+ //
21
+ // 这一层不做自动发现:局域网里没有可信的发现协议(mDNS 在 Windows 上默认不开,
22
+ // 广播包也穿不过 Wi-Fi 隔离),地址必须由用户显式登记 —— 也就意味着**不存在**
23
+ // "随便一台机器就能连上来问"的情况,这正是我们要的。
24
+
25
+ import {
26
+ listPeers,
27
+ findPeer,
28
+ getSessionId,
29
+ setSessionId,
30
+ ensureSelf
31
+ } from './store.js';
32
+
33
+ const INFO_TIMEOUT_MS = 4000;
34
+ const ASK_TIMEOUT_MS = 10 * 60 * 1000;
35
+
36
+ /**
37
+ * 打一个 peer 接口,统一超时与错误文案。
38
+ * @param {string} url
39
+ * @param {{method?:string, token:string, body?:object, timeoutMs?:number}} opts
40
+ */
41
+ async function callPeer(url, { method = 'GET', token, body, timeoutMs = INFO_TIMEOUT_MS } = {}) {
42
+ const controller = new AbortController();
43
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
44
+ timer.unref?.();
45
+ try {
46
+ const res = await fetch(url, {
47
+ method,
48
+ headers: {
49
+ Authorization: `Bearer ${token || ''}`,
50
+ ...(body ? { 'Content-Type': 'application/json' } : {})
51
+ },
52
+ body: body ? JSON.stringify(body) : undefined,
53
+ signal: controller.signal
54
+ });
55
+ const text = await res.text();
56
+ let data = null;
57
+ try { data = text ? JSON.parse(text) : null; } catch { data = null; }
58
+
59
+ if (res.status === 401) throw new Error('令牌无效(对端 peers.json 里的 token 与本机不匹配)');
60
+ if (!res.ok || !data || data.ok === false) {
61
+ throw new Error(data?.error || `对端返回 ${res.status}`);
62
+ }
63
+ return data;
64
+ } catch (err) {
65
+ if (err?.name === 'AbortError') {
66
+ throw new Error(`连 ${url} 超时(${Math.round(timeoutMs / 1000)}s)`);
67
+ }
68
+ if (err instanceof TypeError) {
69
+ throw new Error(`连不上 ${url}(对方 g ui 没开 peer 端口、防火墙拦了,或地址写错)`);
70
+ }
71
+ throw err;
72
+ } finally {
73
+ clearTimeout(timer);
74
+ }
75
+ }
76
+
77
+ /**
78
+ * 取对端的身份信息(同时兼作"在不在线"的探活)。
79
+ * @param {{url:string, token:string}} peer
80
+ * @param {{timeoutMs?:number}} [opts]
81
+ */
82
+ export async function fetchPeerInfo(peer, { timeoutMs = INFO_TIMEOUT_MS } = {}) {
83
+ return callPeer(`${peer.url}/api/peer/info`, { token: peer.token, timeoutMs });
84
+ }
85
+
86
+ /**
87
+ * 向对端提问,拿回它的回答。
88
+ *
89
+ * @param {object} peer peers.json 里的一条
90
+ * @param {object} self 本机身份(把 id 带过去,对端据此保持会话连续性)
91
+ * @param {string} question
92
+ * @param {{timeoutMs?:number}} [opts]
93
+ * @returns {Promise<{answer:string, sessionId:string, ms:number}>}
94
+ */
95
+ export async function askPeer(peer, self, question, { timeoutMs = ASK_TIMEOUT_MS } = {}) {
96
+ const data = await callPeer(`${peer.url}/api/peer/ask`, {
97
+ method: 'POST',
98
+ token: peer.token,
99
+ body: { question, from: self?.id || '' },
100
+ timeoutMs
101
+ });
102
+ if (peer.id && data.sessionId) {
103
+ await setSessionId(`out:${peer.id}`, data.sessionId).catch(() => {});
104
+ }
105
+ return data;
106
+ }
107
+
108
+ /**
109
+ * 工具层用的"我都有哪些对端"—— 附带一次快速探活。
110
+ *
111
+ * 为什么把探活放在这里而不是常驻心跳:对端可能十天半个月不开机,常驻心跳只会
112
+ * 白烧请求;工具调用是低频的,届时有几台就探几台(并发,2s 级超时)。
113
+ *
114
+ * @param {{probe?:boolean}} [opts]
115
+ * @returns {Promise<Array<{id:string,name:string,url:string,online:boolean,label?:string}>>}
116
+ */
117
+ export async function listPeerStatus({ probe = true } = {}) {
118
+ const peers = await listPeers();
119
+ if (!peers.length) return [];
120
+ return Promise.all(peers.map(async (p) => {
121
+ if (!probe) return { ...p, online: false };
122
+ try {
123
+ const info = await fetchPeerInfo(p, { timeoutMs: 2000 });
124
+ return { id: info.id || p.id, name: info.name || p.name, url: p.url, online: true, label: info.version || '' };
125
+ } catch {
126
+ return { ...p, online: false };
127
+ }
128
+ }));
129
+ }
130
+
131
+ /**
132
+ * 按 id 或昵称提问(工具层入口)。
133
+ *
134
+ * @param {string} ref 对端 id 或昵称;空 = 只有一台对端时自动选它
135
+ * @param {string} question
136
+ */
137
+ export async function askPeerByRef(ref, question) {
138
+ const self = (await ensureSelf()).self;
139
+ const key = String(ref || '').trim();
140
+
141
+ let peer = key ? await findPeer(key) : null;
142
+ if (!peer && !key) {
143
+ const all = await listPeers();
144
+ if (all.length === 1) {
145
+ peer = await findPeer(all[0].url);
146
+ } else if (all.length > 1) {
147
+ throw new Error(`有多台对端,请指定 peer:${all.map((p) => p.name).join(' / ')}`);
148
+ }
149
+ }
150
+ if (!peer) {
151
+ const all = await listPeers();
152
+ throw new Error(all.length
153
+ ? `没有名为「${key}」的对端。现有:${all.map((p) => p.name).join(' / ')}`
154
+ : '还没有登记任何对端。在**对端机器**上开 `g peer on`,拿到它的地址和令牌后,在这台机器上执行 `g peer add <昵称> <地址> --token <令牌>`。');
155
+ }
156
+ if (!peer.url || !peer.token) throw new Error(`对端「${peer.name}」缺少地址或令牌,请重新 \`g peer add\``);
157
+
158
+ const result = await askPeer(peer, self, question);
159
+ return { peer, ...result };
160
+ }
161
+
162
+ /** 取与某个对端的上一次会话 id(调试/CLI 用)。 */
163
+ export async function lastSessionIdOf(peerId) {
164
+ return getSessionId(`out:${peerId}`);
165
+ }