zen-gitsync 2.17.10 → 2.17.12
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/README.md +34 -0
- package/package.json +3 -1
- package/src/ui/public/assets/AgentView-BNH92bKu.js +5 -0
- package/src/ui/public/assets/AppVersionBadge-DXWTExsY.js +2 -0
- package/src/ui/public/assets/BranchSelector-CJI-I8JY.js +1 -0
- package/src/ui/public/assets/CommitForm-CTYhgpkj.js +6 -0
- package/src/ui/public/assets/CommonDialog-DNnN70HC.js +1 -0
- package/src/ui/public/assets/EditorView-C2AbXelw.js +1 -0
- package/src/ui/public/assets/FileDiffViewer-ZD705xXx.js +14 -0
- package/src/ui/public/assets/{FlowExecutionViewer-BgEt5_Hz.js → FlowExecutionViewer-BP9w_OQb.js} +1 -1
- package/src/ui/public/assets/FlowOrchestrationWorkspace-DeSI8Mlj.js +21 -0
- package/src/ui/public/assets/IconButton-BxRsG5E1.js +1 -0
- package/src/ui/public/assets/LogList-1sIFUx4e.js +7 -0
- package/src/ui/public/assets/MindmapView-6_fbvhh3.js +1 -0
- package/src/ui/public/assets/MonitorView-CURagJDe.js +1 -0
- package/src/ui/public/assets/ProjectStartupButton-COyb4Fbk.js +1 -0
- package/src/ui/public/assets/RemoteManagerDialog-DoS9EWQr.js +1 -0
- package/src/ui/public/assets/RemoteRepoCard-BwrdI5UH.js +1 -0
- package/src/ui/public/assets/SourceMapView-DADzBGfb.js +3 -0
- package/src/ui/public/assets/SvgIcon-VCh_kGGV.js +1 -0
- package/src/ui/public/assets/TemplateManager-C4go4RZu.js +1 -0
- package/src/ui/public/assets/UserInputNode-Cl5-bI42.js +1 -0
- package/src/ui/public/assets/WorkbenchView-B8QOp8nM.css +1 -0
- package/src/ui/public/assets/WorkbenchView-DtCyyW3b.js +6 -0
- package/src/ui/public/assets/_plugin-vue_export-helper-Dcylt9fO.js +19 -0
- package/src/ui/public/assets/{configStore-BLRcYJdf.js → configStore-BlqdtNUA.js} +1 -1
- package/src/ui/public/assets/element-plus-C1PloHQu.css +1 -0
- package/src/ui/public/assets/{element-plus-DcZ-TVp6.js → element-plus-IG-bwpR5.js} +6 -6
- package/src/ui/public/assets/flow-mindmap-C_w8GJXm.js +139 -0
- package/src/ui/public/assets/{gitStore-DMEDMZMk.js → gitStore-BKkZm2m3.js} +4 -4
- package/src/ui/public/assets/index-Bq7iCtof.css +1 -0
- package/src/ui/public/assets/index-DGHrUfrh.js +28 -0
- package/src/ui/public/assets/{office-docx-C0g5Oepv.js → office-docx-CkLOvV7H.js} +5 -5
- package/src/ui/public/assets/{office-excel-D-uYLKaA.js → office-excel-CDjN3Va1.js} +1 -1
- package/src/ui/public/assets/{office-pptx-De3fnc6M.js → office-pptx-CVu3fXsL.js} +1 -1
- package/src/ui/public/assets/previewSandbox-DYMhXJ91.js +0 -0
- package/src/ui/public/assets/toolsStore-BlRHNqXl.js +1 -0
- package/src/ui/public/assets/{useThemeObserver-CG8k8U18.js → useThemeObserver-DAWKXvWv.js} +1 -1
- package/src/ui/public/assets/{vendor-BIgZEtkx.js → vendor-DcK6TCKZ.js} +27 -27
- package/src/ui/public/assets/vue-flow-B3gZmkCI.js +39 -0
- package/src/ui/public/index.html +18 -18
- package/src/ui/server/index.js +8 -0
- package/src/ui/server/routes/recentDirectoriesAiSummary.js +288 -0
- package/src/ui/server/routes/recentDirectoriesAiSummary.test.js +208 -0
- package/src/ui/server/routes/remoteRepos.js +117 -21
- package/src/ui/server/routes/remoteRepos.test.js +167 -1
- package/src/ui/server/routes/workbench/agentChat.js +9 -3
- package/src/ui/server/routes/workbench/agentParity.test.js +16 -0
- package/src/ui/server/routes/workbench/agentSessionStore.js +2 -2
- package/src/ui/server/routes/workbench/envContext.test.js +8 -8
- package/src/ui/server/routes/workbench/index.js +32 -736
- package/src/ui/server/routes/workbench/instructionStore.js +3 -113
- package/src/ui/server/routes/workbench/jobStore.js +2 -4
- package/src/ui/server/routes/workbench/jobStore.test.js +1 -1
- package/src/ui/server/routes/workbench/jsonParse.js +6 -143
- package/src/ui/server/routes/workbench/orchestratorStore.js +1 -8
- package/src/ui/server/routes/workbench/projectRegistry.js +6 -29
- package/src/ui/server/routes/workbench/projectRegistry.test.js +33 -56
- package/src/ui/server/routes/workbench/projectTool.js +0 -1
- package/src/ui/server/routes/workbench/projectTool.test.js +2 -3
- package/src/ui/server/routes/workbench/shared.js +2 -7
- package/src/ui/server/routes/workbench/taskRunner.js +18 -180
- package/src/ui/server/routes/workbench.js +4 -5
- package/src/ui/public/assets/AgentView-C-AJo7KS.js +0 -5
- package/src/ui/public/assets/AppVersionBadge-B6R6eN2N.js +0 -2
- package/src/ui/public/assets/BranchSelector-Cj3kMWON.js +0 -1
- package/src/ui/public/assets/CommitForm-DpXuFG-F.js +0 -6
- package/src/ui/public/assets/CommonDialog-BLPHDsn0.js +0 -1
- package/src/ui/public/assets/EditorView-B1OnoXXf.js +0 -1
- package/src/ui/public/assets/FileDiffViewer-BQl0JcWd.js +0 -14
- package/src/ui/public/assets/FlowOrchestrationWorkspace-CbLmsSF8.js +0 -21
- package/src/ui/public/assets/IconButton-aM3vaSIN.js +0 -1
- package/src/ui/public/assets/LogList-UjoVFZ-H.js +0 -7
- package/src/ui/public/assets/MindmapView-D_04v0n4.js +0 -1
- package/src/ui/public/assets/MonitorView-BD1_uzq7.js +0 -1
- package/src/ui/public/assets/ProjectStartupButton-BTYlVvzh.js +0 -1
- package/src/ui/public/assets/RemoteManagerDialog-20mpQMwu.js +0 -1
- package/src/ui/public/assets/RemoteRepoCard-DOrm17Xd.js +0 -1
- package/src/ui/public/assets/SourceMapView-nUU9bYPh.js +0 -3
- package/src/ui/public/assets/SvgIcon-LUynX3Iq.js +0 -1
- package/src/ui/public/assets/TemplateManager-Bo3Q7J9Y.js +0 -1
- package/src/ui/public/assets/UserInputNode-D5tSXiJz.js +0 -1
- package/src/ui/public/assets/WorkbenchView-C_Gmzvj1.css +0 -1
- package/src/ui/public/assets/WorkbenchView-UGC334JL.js +0 -13
- package/src/ui/public/assets/_plugin-vue_export-helper-D-D9qkRz.js +0 -19
- package/src/ui/public/assets/element-plus-BWbXh9mk.css +0 -1
- package/src/ui/public/assets/flow-mindmap-D1VTkZh3.js +0 -139
- package/src/ui/public/assets/index-CW3xs-af.css +0 -1
- package/src/ui/public/assets/index-S0_gRyiO.js +0 -26
- package/src/ui/public/assets/previewSandbox-DXAxqDVu.js +0 -0
- package/src/ui/public/assets/toolsStore-CpImRUhI.js +0 -1
- package/src/ui/public/assets/vue-flow-CPCFgenl.js +0 -39
- package/src/ui/server/routes/workbench/sessionStore.js +0 -126
|
@@ -38,6 +38,7 @@ import { spawn, spawnSync } from 'node:child_process';
|
|
|
38
38
|
import fs from 'node:fs/promises';
|
|
39
39
|
import os from 'node:os';
|
|
40
40
|
import path from 'node:path';
|
|
41
|
+
import { stripVTControlCharacters } from 'node:util';
|
|
41
42
|
import { asyncRoute, HttpError } from '../utils/asyncRoute.js';
|
|
42
43
|
import { getToolInstallers, launchCommandInTerminal, parseVersionOutput, publicInstallerInfo } from './fileOpen.js';
|
|
43
44
|
import { readRegistryPathDirs } from '../../../utils/shellPath.js';
|
|
@@ -226,14 +227,59 @@ export async function findCliExecutable(provider) {
|
|
|
226
227
|
|
|
227
228
|
// ── CLI 调用 ────────────────────────────────────────────────────────────────
|
|
228
229
|
|
|
230
|
+
/** 剥掉 ANSI 转义序列。CLI 一旦被"强制上色",我们拿到的就是带色码的文本 ——
|
|
231
|
+
* 那东西既不能 JSON.parse,也不能直接摆在界面上给用户看。 */
|
|
232
|
+
export function stripAnsi(text) {
|
|
233
|
+
return stripVTControlCharacters(String(text ?? ''));
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* "强制上色"这一族环境变量。它们**优先级高于 NO_COLOR**:
|
|
238
|
+
* 实测(2026-09-22)只设 `CLICOLOR_FORCE=1` 时,gh 连我们显式传下去的
|
|
239
|
+
* `NO_COLOR=1` 都不理,`gh repo list --json` 会输出**高亮 + 折行**的 JSON,
|
|
240
|
+
* 首行正是 `\x1b[1;37m[`。
|
|
241
|
+
* 后果是三重的、而且每一条都不报错:
|
|
242
|
+
* ① `parseRepoJson` 解不出来 → repos 空;
|
|
243
|
+
* ② `gh auth status` 里的账号名被色码包住 → user 抠不出来(null);
|
|
244
|
+
* ③ 界面上的"错误原因"变成 `\x1b[1;37m[\x1b[m` 这种乱码(用户截图来问的那种)。
|
|
245
|
+
* 所以不能只"设 NO_COLOR",必须把整族变量从子进程环境里删掉。
|
|
246
|
+
*/
|
|
247
|
+
const COLOR_FORCING_ENV_KEYS = ['CLICOLOR_FORCE', 'FORCE_COLOR', 'GH_FORCE_TTY', 'GITEE_FORCE_TTY'];
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* 拼子进程环境:先删掉"强制上色"整族变量,再显式声明不要颜色与非交互。
|
|
251
|
+
*
|
|
252
|
+
* 为什么要按 key 遍历删除而不是 `delete env.CLICOLOR_FORCE`:
|
|
253
|
+
* Windows 上环境变量名**不区分大小写**,而 `process.env` 的 key 保留原样 ——
|
|
254
|
+
* `clicolor_force` / `gh_force_tty` 这种小写写法会被大写 delete 漏掉。
|
|
255
|
+
* (`...overrides` 放在最后是刻意的:调用方显式指定的变量应当说话算数。)
|
|
256
|
+
*/
|
|
257
|
+
export function buildCliEnv(overrides = {}) {
|
|
258
|
+
const env = { ...process.env };
|
|
259
|
+
for (const key of Object.keys(env)) {
|
|
260
|
+
if (COLOR_FORCING_ENV_KEYS.includes(key.toUpperCase())) delete env[key];
|
|
261
|
+
}
|
|
262
|
+
return {
|
|
263
|
+
...env,
|
|
264
|
+
NO_COLOR: '1',
|
|
265
|
+
CLICOLOR: '0',
|
|
266
|
+
GH_PROMPT_DISABLED: '1',
|
|
267
|
+
GH_NO_UPDATE_NOTIFIER: '1',
|
|
268
|
+
GITEE_NO_UPDATE_NOTIFIER: '1',
|
|
269
|
+
GIT_TERMINAL_PROMPT: '0',
|
|
270
|
+
...overrides,
|
|
271
|
+
};
|
|
272
|
+
}
|
|
273
|
+
|
|
229
274
|
/**
|
|
230
275
|
* 跑一条 CLI 命令并把 stdout 收全。
|
|
231
276
|
*
|
|
232
277
|
* 全程非交互:GH_PROMPT_DISABLED / GIT_TERMINAL_PROMPT=0 保证需要凭据时
|
|
233
278
|
* **快速失败而不是挂住**(挂住会连带前端轮询一起卡死,与 directoryFetch.js
|
|
234
279
|
* 里 fetch 的处理是同一个考虑)。
|
|
235
|
-
*
|
|
236
|
-
*
|
|
280
|
+
* 颜色:见 buildCliEnv(删掉强制上色变量)+ finish() 里再兜一层 stripAnsi ——
|
|
281
|
+
* 万一将来某个 CLI 不认 NO_COLOR,我们也绝不会把色码当数据用。
|
|
282
|
+
* GITEE_NO_UPDATE_NOTIFIER:不要让每次调用都去查一次更新。
|
|
237
283
|
*
|
|
238
284
|
* Windows 上 npm 装出来的是 .cmd 垫片,不能直接 spawn,必须过一层 shell
|
|
239
285
|
* (这里的 args 全部是服务端硬编码常量,不含任何用户输入)。
|
|
@@ -245,25 +291,19 @@ export function runCli(executable, args, { timeoutMs = CLI_TIMEOUT_MS, env = {}
|
|
|
245
291
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
246
292
|
windowsHide: true,
|
|
247
293
|
shell: useShell,
|
|
248
|
-
env:
|
|
249
|
-
...process.env,
|
|
250
|
-
NO_COLOR: '1',
|
|
251
|
-
GH_PROMPT_DISABLED: '1',
|
|
252
|
-
GH_NO_UPDATE_NOTIFIER: '1',
|
|
253
|
-
GITEE_NO_UPDATE_NOTIFIER: '1',
|
|
254
|
-
GIT_TERMINAL_PROMPT: '0',
|
|
255
|
-
...env,
|
|
256
|
-
},
|
|
294
|
+
env: buildCliEnv(env),
|
|
257
295
|
});
|
|
258
296
|
|
|
259
297
|
let stdout = '';
|
|
260
298
|
let stderr = '';
|
|
261
299
|
let done = false;
|
|
300
|
+
// 色码必须在**收全之后**再剥:按 chunk 剥会把跨 chunk 的转义序列切坏
|
|
301
|
+
// (前一个 chunk 停在 `\x1b[`,后一个 chunk 从 `1;37m` 开始)。
|
|
262
302
|
const finish = (code, timedOut) => {
|
|
263
303
|
if (done) return;
|
|
264
304
|
done = true;
|
|
265
305
|
try { child.kill('SIGKILL'); } catch {}
|
|
266
|
-
resolve({ code, stdout, stderr, timedOut: !!timedOut });
|
|
306
|
+
resolve({ code, stdout: stripAnsi(stdout), stderr: stripAnsi(stderr), timedOut: !!timedOut });
|
|
267
307
|
};
|
|
268
308
|
|
|
269
309
|
child.stdout.on('data', (chunk) => { stdout += chunk; });
|
|
@@ -277,12 +317,20 @@ export function runCli(executable, args, { timeoutMs = CLI_TIMEOUT_MS, env = {}
|
|
|
277
317
|
});
|
|
278
318
|
}
|
|
279
319
|
|
|
280
|
-
/**
|
|
281
|
-
|
|
320
|
+
/**
|
|
321
|
+
* 从 CLI 输出里挑一句能给用户看的话(gitee 把错误打在 stdout,gh 打在 stderr)。
|
|
322
|
+
*
|
|
323
|
+
* 跳过"剥掉色码后只剩标点/符号"的行:被高亮过的 JSON 压掉色码后可能只剩一个
|
|
324
|
+
* `[`,拿它当错误提示等于没说 —— 界面上就会出现用户完全看不懂的一行(实测踩到)。
|
|
325
|
+
*/
|
|
326
|
+
export function firstMeaningfulLine(...texts) {
|
|
282
327
|
for (const text of texts) {
|
|
283
328
|
if (!text) continue;
|
|
284
|
-
for (const
|
|
285
|
-
|
|
329
|
+
for (const raw of String(text).split(/\r?\n/)) {
|
|
330
|
+
const line = stripAnsi(raw).trim();
|
|
331
|
+
if (!line) continue;
|
|
332
|
+
if (!/[\p{L}\p{N}]/u.test(line)) continue;
|
|
333
|
+
return line;
|
|
286
334
|
}
|
|
287
335
|
}
|
|
288
336
|
return '';
|
|
@@ -290,13 +338,19 @@ function firstMeaningfulLine(...texts) {
|
|
|
290
338
|
|
|
291
339
|
/** 宽松 JSON 解析:容忍 CLI 在 JSON 前面打警告/更新提示行。
|
|
292
340
|
* 只定位第一个 `[` 或 `{` 再整体交给 JSON.parse —— 不做任何字符串清洗,
|
|
293
|
-
* 解析不出来就是 null,由调用方决定怎么降级。
|
|
341
|
+
* 解析不出来就是 null,由调用方决定怎么降级。
|
|
342
|
+
*
|
|
343
|
+
* 唯一"清洗"就是剥 ANSI:被强制上色的 `--json` 输出是**带色码的多行 JSON**,
|
|
344
|
+
* 色码不影响结构,剥掉就能正常解析(2026-09-22 实测:CLICOLOR_FORCE=1 时
|
|
345
|
+
* gh 输出首行是 `\x1b[1;37m[`)。runCli 出口已经剥过一遍,这里再兜一层,
|
|
346
|
+
* 保证任何直接调用本函数的路径都不会被色码坑到。 */
|
|
294
347
|
export function parseJsonLoose(text) {
|
|
295
348
|
if (!text) return null;
|
|
296
|
-
const
|
|
349
|
+
const plain = stripAnsi(text);
|
|
350
|
+
const start = plain.search(/[[{]/);
|
|
297
351
|
if (start < 0) return null;
|
|
298
352
|
try {
|
|
299
|
-
return JSON.parse(
|
|
353
|
+
return JSON.parse(plain.slice(start));
|
|
300
354
|
} catch {
|
|
301
355
|
return null;
|
|
302
356
|
}
|
|
@@ -387,6 +441,25 @@ async function detectCli(provider, executable, version) {
|
|
|
387
441
|
};
|
|
388
442
|
}
|
|
389
443
|
|
|
444
|
+
/**
|
|
445
|
+
* 两个平台都要给的字段(前端排序 + 卡片元信息要用):
|
|
446
|
+
* pushedAt 最近推送 —— 前端默认按它倒序排(最活跃的排前面)
|
|
447
|
+
* createdAt 创建时间
|
|
448
|
+
* forks 复刻数
|
|
449
|
+
* defaultBranch 默认分支 —— 卡片上只在非 main/master 时显示
|
|
450
|
+
* license 许可证
|
|
451
|
+
* 取不到时一律 null(不写 0),前端据此判断"这条信息没有",而不是显示一个假的 0。
|
|
452
|
+
*/
|
|
453
|
+
function commonRepoFields(source) {
|
|
454
|
+
return {
|
|
455
|
+
pushedAt: source.pushedAt || null,
|
|
456
|
+
createdAt: source.createdAt || null,
|
|
457
|
+
forks: typeof source.forks === 'number' ? source.forks : 0,
|
|
458
|
+
defaultBranch: source.defaultBranch || null,
|
|
459
|
+
license: source.license || null,
|
|
460
|
+
};
|
|
461
|
+
}
|
|
462
|
+
|
|
390
463
|
/** gh repo list 的 --json 字段 → 统一形状 */
|
|
391
464
|
export function normalizeGithubRepos(raw) {
|
|
392
465
|
return raw.map((r) => ({
|
|
@@ -399,6 +472,19 @@ export function normalizeGithubRepos(raw) {
|
|
|
399
472
|
stars: typeof r.stargazerCount === 'number' ? r.stargazerCount : 0,
|
|
400
473
|
updatedAt: r.updatedAt || null,
|
|
401
474
|
url: r.url || '',
|
|
475
|
+
...commonRepoFields({
|
|
476
|
+
pushedAt: r.pushedAt,
|
|
477
|
+
createdAt: r.createdAt,
|
|
478
|
+
forks: typeof r.forkCount === 'number' ? r.forkCount : undefined,
|
|
479
|
+
// defaultBranchRef 是对象({name}),没设默认分支时为 null
|
|
480
|
+
defaultBranch: r.defaultBranchRef?.name,
|
|
481
|
+
// gh 的 licenseInfo 只给 key/name/nickname(spdxId 在 list 里没有)。
|
|
482
|
+
// key === 'other' 表示"有这个 LICENSE 文件但 GitHub 认不出是哪一种",
|
|
483
|
+
// 显示成 "Other" 是纯噪音,直接当作没有。
|
|
484
|
+
license: r.licenseInfo && r.licenseInfo.key !== 'other'
|
|
485
|
+
? (r.licenseInfo.name || r.licenseInfo.key)
|
|
486
|
+
: undefined,
|
|
487
|
+
}),
|
|
402
488
|
}));
|
|
403
489
|
}
|
|
404
490
|
|
|
@@ -415,6 +501,14 @@ export function normalizeGiteeRepos(raw) {
|
|
|
415
501
|
stars: typeof r.stargazers_count === 'number' ? r.stargazers_count : 0,
|
|
416
502
|
updatedAt: r.updated_at || null,
|
|
417
503
|
url: r.html_url || (r.full_name ? `https://gitee.com/${r.full_name}` : ''),
|
|
504
|
+
...commonRepoFields({
|
|
505
|
+
pushedAt: r.pushed_at,
|
|
506
|
+
createdAt: r.created_at,
|
|
507
|
+
forks: typeof r.forks_count === 'number' ? r.forks_count : undefined,
|
|
508
|
+
defaultBranch: r.default_branch,
|
|
509
|
+
// 未设置许可证时 gitee 给的是空字符串(实测),当成 null
|
|
510
|
+
license: r.license,
|
|
511
|
+
}),
|
|
418
512
|
}));
|
|
419
513
|
}
|
|
420
514
|
|
|
@@ -425,7 +519,9 @@ async function listRepos(provider, executable) {
|
|
|
425
519
|
const res = await runCli(executable, [
|
|
426
520
|
'repo', 'list',
|
|
427
521
|
'--limit', String(GH_LIMIT),
|
|
428
|
-
|
|
522
|
+
// 字段要和 normalizeGithubRepos 一一对应:少一个不会报错,只会静默变成 null,
|
|
523
|
+
// 表现成"卡片上少了半行信息"。
|
|
524
|
+
'--json', 'name,nameWithOwner,description,isPrivate,isFork,primaryLanguage,stargazerCount,forkCount,defaultBranchRef,licenseInfo,updatedAt,pushedAt,createdAt,url',
|
|
429
525
|
]);
|
|
430
526
|
if (res.timedOut) return { repos: [], error: 'gh repo list 超时(25 秒),请检查网络或代理设置' };
|
|
431
527
|
const parsed = parseRepoJson(res.stdout);
|
|
@@ -443,7 +539,7 @@ async function listRepos(provider, executable) {
|
|
|
443
539
|
'repo', 'list',
|
|
444
540
|
'--limit', String(GITEE_PAGE_SIZE),
|
|
445
541
|
'--page', String(page),
|
|
446
|
-
'--json=full_name,human_name,name,path,description,private,public,fork,language,stargazers_count,updated_at,html_url',
|
|
542
|
+
'--json=full_name,human_name,name,path,description,private,public,fork,language,stargazers_count,forks_count,default_branch,license,updated_at,pushed_at,created_at,html_url',
|
|
447
543
|
'--no-tui',
|
|
448
544
|
]);
|
|
449
545
|
if (res.timedOut) {
|
|
@@ -14,17 +14,23 @@
|
|
|
14
14
|
//
|
|
15
15
|
// src/ui/server/routes/remoteRepos.js 单元测试。
|
|
16
16
|
//
|
|
17
|
-
//
|
|
17
|
+
// 重点覆盖三处**实测踩过的坑**,它们都不会报错、只会静默给出错误结果:
|
|
18
18
|
// 1. `gitee auth status` 未登录时退出码是 0 —— 用退出码判会永远认为已登录
|
|
19
19
|
// 2. `parseRepoJson` 对纯对象(如 auth status 的 JSON)返回 null ——
|
|
20
20
|
// 拿它去解登录态会让解析悄悄退化成文本匹配,登录后拿不到用户名
|
|
21
|
+
// 3. 环境里带了"强制上色"变量(如 CLICOLOR_FORCE=1)时,CLI 的 --json 输出
|
|
22
|
+
// 会带 ANSI 色码 —— JSON 解不出来、账号名抠不出来,界面上只剩 `\x1b[1;37m[`
|
|
21
23
|
// 另外覆盖四条路由分支(未安装 / 未登录 / 已登录 / 拉取失败)与 provider 校验。
|
|
22
24
|
//
|
|
23
25
|
// 不真起 Express:沿用 branchStatus.test.js 的 mock app + mock req/res 做法。
|
|
24
26
|
// CLI 调用通过 registerRemoteReposRoutes 的 *Impl 注入口换成假实现,不联网、不装 gh。
|
|
27
|
+
// 唯独"色码"那几条必须真起子进程 —— 用 node 自己当被测 CLI(process.execPath),
|
|
28
|
+
// 不依赖机器上有没有装 gh。
|
|
25
29
|
import { test } from 'node:test'
|
|
26
30
|
import assert from 'node:assert/strict'
|
|
27
31
|
import {
|
|
32
|
+
buildCliEnv,
|
|
33
|
+
firstMeaningfulLine,
|
|
28
34
|
interpretGiteeAuthStatus,
|
|
29
35
|
interpretGithubAuthStatus,
|
|
30
36
|
normalizeGiteeRepos,
|
|
@@ -33,8 +39,17 @@ import {
|
|
|
33
39
|
parseRepoJson,
|
|
34
40
|
pickUserName,
|
|
35
41
|
registerRemoteReposRoutes,
|
|
42
|
+
runCli,
|
|
43
|
+
stripAnsi,
|
|
36
44
|
} from './remoteRepos.js'
|
|
37
45
|
|
|
46
|
+
const ESC = '\u001b'
|
|
47
|
+
|
|
48
|
+
/** 用 node 自己当"被测 CLI":跑一段内联脚本,把 stdout 收回来。 */
|
|
49
|
+
function runNode(code, options) {
|
|
50
|
+
return runCli(process.execPath, ['-e', code], options)
|
|
51
|
+
}
|
|
52
|
+
|
|
38
53
|
/** 最小 express app mock:只实现 get + post 与 handler 调用 */
|
|
39
54
|
function makeApp() {
|
|
40
55
|
const handlers = new Map()
|
|
@@ -193,6 +208,157 @@ test('normalizeGiteeRepos: private 缺失时用 public 反推;url 缺失时用 f
|
|
|
193
208
|
assert.equal(explicit.isPrivate, true)
|
|
194
209
|
})
|
|
195
210
|
|
|
211
|
+
// ── 排序 / 卡片元信息用到的字段 ──────────────────────────────────────────────
|
|
212
|
+
//
|
|
213
|
+
// 前端默认按 pushedAt 倒序排,卡片第三行显示"最近推送 / Fork / 默认分支 / 许可证"。
|
|
214
|
+
// 这些字段名两个平台各不相同,映射写错**不会报错** —— 只会静默变成 null,
|
|
215
|
+
// 表现成"卡片上少半行字"或"排序乱掉",所以逐个钉住。
|
|
216
|
+
test('normalizeGithubRepos: forkCount/defaultBranchRef/licenseInfo 映射到统一字段', () => {
|
|
217
|
+
const [repo] = normalizeGithubRepos([{
|
|
218
|
+
nameWithOwner: 'xz333221/zen-gitsync',
|
|
219
|
+
forkCount: 4,
|
|
220
|
+
defaultBranchRef: { name: 'develop' },
|
|
221
|
+
licenseInfo: { key: 'mit', name: 'MIT License' },
|
|
222
|
+
pushedAt: '2026-09-22T12:00:00Z',
|
|
223
|
+
createdAt: '2024-11-25T08:11:38Z',
|
|
224
|
+
}])
|
|
225
|
+
assert.equal(repo.forks, 4)
|
|
226
|
+
assert.equal(repo.defaultBranch, 'develop')
|
|
227
|
+
assert.equal(repo.license, 'MIT License')
|
|
228
|
+
assert.equal(repo.pushedAt, '2026-09-22T12:00:00Z')
|
|
229
|
+
assert.equal(repo.createdAt, '2024-11-25T08:11:38Z')
|
|
230
|
+
|
|
231
|
+
// 字段缺失时是 null / 0,不是 undefined —— 前端直接拿它做判断和比较
|
|
232
|
+
const [bare] = normalizeGithubRepos([{}])
|
|
233
|
+
assert.equal(bare.forks, 0)
|
|
234
|
+
assert.equal(bare.defaultBranch, null)
|
|
235
|
+
assert.equal(bare.license, null)
|
|
236
|
+
assert.equal(bare.pushedAt, null)
|
|
237
|
+
})
|
|
238
|
+
|
|
239
|
+
test('normalizeGithubRepos: licenseInfo.key=other(有 LICENSE 但认不出)不算许可证', () => {
|
|
240
|
+
// 实测:本仓库自己的 licenseInfo 就是 {key:'other', name:'Other'} ——
|
|
241
|
+
// 卡片上显示一个 "Other" 徽标是纯噪音,当成没有处理。
|
|
242
|
+
const [repo] = normalizeGithubRepos([{ nameWithOwner: 'a/b', licenseInfo: { key: 'other', name: 'Other' } }])
|
|
243
|
+
assert.equal(repo.license, null)
|
|
244
|
+
})
|
|
245
|
+
|
|
246
|
+
test('normalizeGiteeRepos: 未设置许可证时 gitee 给空串,要归一成 null', () => {
|
|
247
|
+
const [none] = normalizeGiteeRepos([{ full_name: 'flowdash/blog', license: '' }])
|
|
248
|
+
assert.equal(none.license, null)
|
|
249
|
+
assert.equal(none.forks, 0)
|
|
250
|
+
assert.equal(none.defaultBranch, null)
|
|
251
|
+
|
|
252
|
+
const [full] = normalizeGiteeRepos([{
|
|
253
|
+
full_name: 'flowdash/article-generator',
|
|
254
|
+
license: 'MIT',
|
|
255
|
+
forks_count: 2,
|
|
256
|
+
default_branch: 'develop',
|
|
257
|
+
pushed_at: '2026-09-02T01:08:48+08:00',
|
|
258
|
+
created_at: '2026-04-30T01:29:35+08:00',
|
|
259
|
+
}])
|
|
260
|
+
assert.equal(full.license, 'MIT')
|
|
261
|
+
assert.equal(full.forks, 2)
|
|
262
|
+
assert.equal(full.defaultBranch, 'develop')
|
|
263
|
+
assert.equal(full.pushedAt, '2026-09-02T01:08:48+08:00')
|
|
264
|
+
assert.equal(full.createdAt, '2026-04-30T01:29:35+08:00')
|
|
265
|
+
})
|
|
266
|
+
|
|
267
|
+
// ── 色码(ANSI)—— 强制上色的环境会把解析和提示一起搞坏 ───────────────────────
|
|
268
|
+
//
|
|
269
|
+
// 实测(2026-09-22):只要启动服务的那个终端里带着 CLICOLOR_FORCE=1,gh 就**不理**
|
|
270
|
+
// 我们传下去的 NO_COLOR=1,`gh repo list --json` 会吐**高亮 + 折行**的 JSON:
|
|
271
|
+
// \x1b[1;37m[\x1b[m
|
|
272
|
+
// \x1b[1;37m{\x1b[m
|
|
273
|
+
// \x1b[1;34m"name"\x1b[m\x1b[1;37m:\x1b[m \x1b[32m"zen-gitsync"\x1b[m
|
|
274
|
+
// 于是:① repos 空(JSON.parse 失败)② user 为 null(账号名被色码包住)
|
|
275
|
+
// ③ 界面上的"错误原因"变成 `\x1b[1;37m[\x1b[m` 这种乱码。
|
|
276
|
+
|
|
277
|
+
test('stripAnsi: 剥掉 SGR 与光标类转义序列', () => {
|
|
278
|
+
assert.equal(stripAnsi(`${ESC}[1;37m[${ESC}[m`), '[')
|
|
279
|
+
assert.equal(stripAnsi('plain text'), 'plain text')
|
|
280
|
+
assert.equal(stripAnsi(`${ESC}[2K${ESC}[1Aabc`), 'abc')
|
|
281
|
+
assert.equal(stripAnsi(null), '')
|
|
282
|
+
})
|
|
283
|
+
|
|
284
|
+
test('buildCliEnv: 继承来的"强制上色"变量一律清掉,并显式关色(Windows 上大小写不敏感)', () => {
|
|
285
|
+
process.env.CLICOLOR_FORCE = '1'
|
|
286
|
+
process.env.gh_force_tty = '100%'
|
|
287
|
+
process.env.FORCE_COLOR = '3'
|
|
288
|
+
try {
|
|
289
|
+
const env = buildCliEnv()
|
|
290
|
+
assert.equal(env.CLICOLOR_FORCE, undefined)
|
|
291
|
+
// 小写写法必须一起清 —— Windows 上环境变量名不区分大小写,
|
|
292
|
+
// 只 delete 大写那种写法会漏掉它,而漏掉就等于没修。
|
|
293
|
+
assert.equal(env.gh_force_tty, undefined)
|
|
294
|
+
assert.equal(env.GH_FORCE_TTY, undefined)
|
|
295
|
+
assert.equal(env.FORCE_COLOR, undefined)
|
|
296
|
+
assert.equal(env.NO_COLOR, '1')
|
|
297
|
+
assert.equal(env.CLICOLOR, '0')
|
|
298
|
+
// 其它环境变量照旧带下去(子进程要能拿到 PATH 等)
|
|
299
|
+
assert.equal(env.PATH, process.env.PATH)
|
|
300
|
+
} finally {
|
|
301
|
+
delete process.env.CLICOLOR_FORCE
|
|
302
|
+
delete process.env.gh_force_tty
|
|
303
|
+
delete process.env.FORCE_COLOR
|
|
304
|
+
}
|
|
305
|
+
})
|
|
306
|
+
|
|
307
|
+
test('runCli: 父进程带着强制上色变量时,子进程既看不到它、输出里的色码也被剥掉', async () => {
|
|
308
|
+
const code = 'process.stdout.write(String(process.env.CLICOLOR_FORCE) + "|" + String(process.env.NO_COLOR) + "|" + String(process.env.GH_FORCE_TTY))'
|
|
309
|
+
process.env.CLICOLOR_FORCE = '1'
|
|
310
|
+
process.env.GH_FORCE_TTY = '80'
|
|
311
|
+
try {
|
|
312
|
+
const res = await runNode(code)
|
|
313
|
+
assert.equal(res.code, 0)
|
|
314
|
+
assert.equal(res.stdout, 'undefined|1|undefined')
|
|
315
|
+
} finally {
|
|
316
|
+
delete process.env.CLICOLOR_FORCE
|
|
317
|
+
delete process.env.GH_FORCE_TTY
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
// 色码在出口处统一剥掉(按 chunk 剥会把跨 chunk 的转义序列切坏,所以必须在收全后剥)
|
|
321
|
+
const colored = await runNode(`process.stdout.write(${JSON.stringify(`${ESC}[1;37m[${ESC}[m\nabc\n`)})`)
|
|
322
|
+
assert.equal(colored.stdout, '[\nabc\n')
|
|
323
|
+
|
|
324
|
+
// 同样适用于 stderr —— 报错信息是要直接摆到界面上的
|
|
325
|
+
const coloredErr = await runNode(`process.stderr.write(${JSON.stringify(`${ESC}[31mHTTP 401: Bad credentials${ESC}[m\n`)})`)
|
|
326
|
+
assert.equal(coloredErr.stderr, 'HTTP 401: Bad credentials\n')
|
|
327
|
+
})
|
|
328
|
+
|
|
329
|
+
test('runCli + interpretGithubAuthStatus: 彩色 auth status 也能抠出账号名', async () => {
|
|
330
|
+
// 没有这一层修复时,账号名被 \x1b[1;37m 包住 → 正则匹配不到 → user 为 null,
|
|
331
|
+
// 界面显示"已登录 "后面空一格(users 看不到自己是谁)。
|
|
332
|
+
const output = `github.com\n ${ESC}[32m✓${ESC}[m Logged in to github.com account ${ESC}[1;37mxz333221${ESC}[m (keyring)\n`
|
|
333
|
+
const res = await runNode(`process.stdout.write(${JSON.stringify(`${output}\n`)})`)
|
|
334
|
+
assert.deepEqual(interpretGithubAuthStatus(res.code, res.stdout), {
|
|
335
|
+
authenticated: true,
|
|
336
|
+
user: 'xz333221',
|
|
337
|
+
})
|
|
338
|
+
})
|
|
339
|
+
|
|
340
|
+
test('parseJsonLoose: 高亮过的多行 JSON 剥掉色码后照常解析(兜底,不依赖 runCli)', () => {
|
|
341
|
+
const colored = [
|
|
342
|
+
`${ESC}[1;37m[${ESC}[m`,
|
|
343
|
+
` ${ESC}[1;37m{${ESC}[m`,
|
|
344
|
+
` ${ESC}[1;34m"name"${ESC}[m${ESC}[1;37m:${ESC}[m ${ESC}[32m"zen-gitsync"${ESC}[m`,
|
|
345
|
+
` ${ESC}[1;37m}${ESC}[m`,
|
|
346
|
+
`${ESC}[1;37m]${ESC}[m`,
|
|
347
|
+
].join('\n')
|
|
348
|
+
assert.deepEqual(parseJsonLoose(colored), [{ name: 'zen-gitsync' }])
|
|
349
|
+
assert.deepEqual(parseRepoJson(colored), [{ name: 'zen-gitsync' }])
|
|
350
|
+
})
|
|
351
|
+
|
|
352
|
+
test('firstMeaningfulLine: 剥掉色码后只剩标点的行不算"能给用户看的话"', () => {
|
|
353
|
+
// 用户看到的那行乱码正是这一条:整段输出剥完色码只剩 [,拿它当错误提示等于没说
|
|
354
|
+
assert.equal(firstMeaningfulLine('', `${ESC}[1;37m[${ESC}[m`), '')
|
|
355
|
+
assert.equal(
|
|
356
|
+
firstMeaningfulLine(`${ESC}[1;37m[${ESC}[m`, 'HTTP 401: Bad credentials (https://api.github.com/graphql)'),
|
|
357
|
+
'HTTP 401: Bad credentials (https://api.github.com/graphql)',
|
|
358
|
+
)
|
|
359
|
+
assert.equal(firstMeaningfulLine(''), '')
|
|
360
|
+
})
|
|
361
|
+
|
|
196
362
|
// ── 路由分支 ────────────────────────────────────────────────────────────────
|
|
197
363
|
|
|
198
364
|
test('GET /api/remote-repos: provider 非法 → 400', async () => {
|
|
@@ -298,19 +298,25 @@ export async function runAgentTurn({ session, model, userMessage, images = [], c
|
|
|
298
298
|
|
|
299
299
|
const { content, toolCalls } = result;
|
|
300
300
|
|
|
301
|
+
// 推理内容必须原样带回历史。DeepSeek 系 thinking 模式下带 tool_calls 的 assistant
|
|
302
|
+
// 消息一旦缺 reasoning_content,下一轮回传就被上游 400 拒掉:
|
|
303
|
+
// "The `reasoning_content` in the thinking mode must be passed back to the API"。
|
|
304
|
+
// 口径与 CLI 侧 src/cli/ai/turn.js 的 assistant.reasoning_content 保持一致。
|
|
305
|
+
const withReasoning = msg => (result.reasoning ? { ...msg, reasoning_content: result.reasoning } : msg);
|
|
306
|
+
|
|
301
307
|
// 无工具调用:本轮结束
|
|
302
308
|
if (toolCalls.length === 0) {
|
|
303
|
-
session.messages.push({ role: 'assistant', content: content || null });
|
|
309
|
+
session.messages.push(withReasoning({ role: 'assistant', content: content || null }));
|
|
304
310
|
send({ type: 'done', content: content || '' });
|
|
305
311
|
return { aborted: false };
|
|
306
312
|
}
|
|
307
313
|
|
|
308
314
|
// 有工具调用:assistant(带 tool_calls)入历史
|
|
309
|
-
session.messages.push({
|
|
315
|
+
session.messages.push(withReasoning({
|
|
310
316
|
role: 'assistant',
|
|
311
317
|
content: content || null,
|
|
312
318
|
tool_calls: toolCalls
|
|
313
|
-
});
|
|
319
|
+
}));
|
|
314
320
|
|
|
315
321
|
// 逐个执行工具
|
|
316
322
|
for (const tc of toolCalls) {
|
|
@@ -165,3 +165,19 @@ test('共享模块本身必须导出这些口径', () => {
|
|
|
165
165
|
}
|
|
166
166
|
}
|
|
167
167
|
});
|
|
168
|
+
|
|
169
|
+
test('两个入口都把推理内容写进 assistant 历史消息', () => {
|
|
170
|
+
// 上游要求(DeepSeek 系 thinking 模式 + tool calls):回传的历史里,assistant 消息当初产出的
|
|
171
|
+
// reasoning_content 必须原样带回,否则下一轮请求被 400 拒掉:
|
|
172
|
+
// The `reasoning_content` in the thinking mode must be passed back to the API.
|
|
173
|
+
// 传输层已经把它返回了(streamChatOnce 的 result.reasoning),漏的是"入历史"这一步 ——
|
|
174
|
+
// Web 侧曾经只解构 content/toolCalls,于是纯聊天没事、一调工具就挂,又是两个入口表现分叉。
|
|
175
|
+
// 只认代码行,注释里的提及不算(CLI 是 `assistant.reasoning_content = …`,Web 是 `reasoning_content: …`)。
|
|
176
|
+
for (const { label, file } of ENTRY_POINTS) {
|
|
177
|
+
const code = readFileSync(file, 'utf8')
|
|
178
|
+
.split(/\r?\n/)
|
|
179
|
+
.filter(line => !/^\s*(\/\/|\*|\/\*)/.test(line))
|
|
180
|
+
.join('\n');
|
|
181
|
+
assert.match(code, /reasoning_content/, `${label} 没有把推理内容写进 assistant 历史消息`);
|
|
182
|
+
}
|
|
183
|
+
});
|
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
//
|
|
15
15
|
// 智能体对话 session 持久化层。
|
|
16
16
|
//
|
|
17
|
-
//
|
|
17
|
+
// 这里存储的是完整 agent 对话(含工具调用)—— 智能体面板专用,与任务执行链路的 job 记录互不相干。
|
|
18
18
|
// 消息格式兼容 OpenAI chat completions:
|
|
19
19
|
// - { role: 'system', content: string }
|
|
20
20
|
// - { role: 'user', content: string | content_parts[] }
|
|
@@ -29,7 +29,7 @@ import path from 'path';
|
|
|
29
29
|
import { logger } from './shared.js';
|
|
30
30
|
import { AGENT_SESSIONS_DIR } from '../../../../paths.js';
|
|
31
31
|
|
|
32
|
-
//
|
|
32
|
+
// 数据目录定义见 src/paths.js
|
|
33
33
|
export { AGENT_SESSIONS_DIR };
|
|
34
34
|
// 历史遗留别名:此前这里有个"从 import.meta.url 推路径"的错误实现(Windows 下不可靠),
|
|
35
35
|
// 后来补了个 _FIXED 版本两套并存。现在统一到 paths.js 的单一常量。
|
|
@@ -57,11 +57,11 @@ test('项目清单带名称、路径与三列计数,并标出当前项目', ()
|
|
|
57
57
|
const projects = [entry('D:\\ws\\zen-gitsync'), entry('D:\\ws\\article-generator')];
|
|
58
58
|
const tasks = [
|
|
59
59
|
// 已完成:simple 任务跑过一次且 job 是 done
|
|
60
|
-
{ id: 't1', projectPath: 'D:\\ws\\zen-gitsync', type: 'simple'
|
|
60
|
+
{ id: 't1', projectPath: 'D:\\ws\\zen-gitsync', type: 'simple' },
|
|
61
61
|
// 进行中:有 job 处于 running
|
|
62
|
-
{ id: 't2', projectPath: 'D:\\ws\\article-generator', type: 'simple'
|
|
62
|
+
{ id: 't2', projectPath: 'D:\\ws\\article-generator', type: 'simple' },
|
|
63
63
|
// 待处理:没子任务也没执行过
|
|
64
|
-
{ id: 't3', projectPath: 'D:\\ws\\article-generator', type: 'simple'
|
|
64
|
+
{ id: 't3', projectPath: 'D:\\ws\\article-generator', type: 'simple' },
|
|
65
65
|
];
|
|
66
66
|
const jobs = [job('j1', 't1', 'done'), job('j2', 't2', 'running')];
|
|
67
67
|
|
|
@@ -106,7 +106,7 @@ test('项目数超过上限时只列前 N 个,但合计仍按全量算', () =>
|
|
|
106
106
|
for (let i = 0; i < total; i += 1) {
|
|
107
107
|
const p = `D:\\ws\\p${i}`;
|
|
108
108
|
projects.push(entry(p));
|
|
109
|
-
tasks.push({ id: `t${i}`, projectPath: p, type: 'simple'
|
|
109
|
+
tasks.push({ id: `t${i}`, projectPath: p, type: 'simple' });
|
|
110
110
|
}
|
|
111
111
|
|
|
112
112
|
const block = buildEnvContextBlock({
|
|
@@ -133,8 +133,8 @@ test('maxProjects 可覆盖,且非法值回落到默认上限', () => {
|
|
|
133
133
|
test('没关联项目的任务不计入项目行,但计入合计', () => {
|
|
134
134
|
const projects = [entry('D:\\ws\\a')];
|
|
135
135
|
const tasks = [
|
|
136
|
-
{ id: 't1', projectPath: 'D:\\ws\\a', type: 'simple'
|
|
137
|
-
{ id: 't2', projectPath: '', type: 'simple'
|
|
136
|
+
{ id: 't1', projectPath: 'D:\\ws\\a', type: 'simple' },
|
|
137
|
+
{ id: 't2', projectPath: '', type: 'simple' },
|
|
138
138
|
];
|
|
139
139
|
const block = buildEnvContextBlock({ projects, tasks, jobs: [], ...PATHS });
|
|
140
140
|
// 项目行只认 key 匹配得上的那条(t1)→ 待处理 1
|
|
@@ -153,8 +153,8 @@ test('没关联项目的任务不计入项目行,但计入合计', () => {
|
|
|
153
153
|
test('项目行/合计的段数必须与 TASK_COLUMNS 一致,且每列都有中文标签', () => {
|
|
154
154
|
const projects = [entry('D:\\ws\\a')];
|
|
155
155
|
const tasks = [
|
|
156
|
-
{ id: 't1', projectPath: 'D:\\ws\\a', type: 'simple'
|
|
157
|
-
{ id: 't2', projectPath: 'D:\\ws\\a', type: 'simple'
|
|
156
|
+
{ id: 't1', projectPath: 'D:\\ws\\a', type: 'simple' },
|
|
157
|
+
{ id: 't2', projectPath: 'D:\\ws\\a', type: 'simple' },
|
|
158
158
|
];
|
|
159
159
|
const block = buildEnvContextBlock({ projects, tasks, jobs: [], ...PATHS });
|
|
160
160
|
|