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.
Files changed (93) hide show
  1. package/README.md +34 -0
  2. package/package.json +3 -1
  3. package/src/ui/public/assets/AgentView-BNH92bKu.js +5 -0
  4. package/src/ui/public/assets/AppVersionBadge-DXWTExsY.js +2 -0
  5. package/src/ui/public/assets/BranchSelector-CJI-I8JY.js +1 -0
  6. package/src/ui/public/assets/CommitForm-CTYhgpkj.js +6 -0
  7. package/src/ui/public/assets/CommonDialog-DNnN70HC.js +1 -0
  8. package/src/ui/public/assets/EditorView-C2AbXelw.js +1 -0
  9. package/src/ui/public/assets/FileDiffViewer-ZD705xXx.js +14 -0
  10. package/src/ui/public/assets/{FlowExecutionViewer-BgEt5_Hz.js → FlowExecutionViewer-BP9w_OQb.js} +1 -1
  11. package/src/ui/public/assets/FlowOrchestrationWorkspace-DeSI8Mlj.js +21 -0
  12. package/src/ui/public/assets/IconButton-BxRsG5E1.js +1 -0
  13. package/src/ui/public/assets/LogList-1sIFUx4e.js +7 -0
  14. package/src/ui/public/assets/MindmapView-6_fbvhh3.js +1 -0
  15. package/src/ui/public/assets/MonitorView-CURagJDe.js +1 -0
  16. package/src/ui/public/assets/ProjectStartupButton-COyb4Fbk.js +1 -0
  17. package/src/ui/public/assets/RemoteManagerDialog-DoS9EWQr.js +1 -0
  18. package/src/ui/public/assets/RemoteRepoCard-BwrdI5UH.js +1 -0
  19. package/src/ui/public/assets/SourceMapView-DADzBGfb.js +3 -0
  20. package/src/ui/public/assets/SvgIcon-VCh_kGGV.js +1 -0
  21. package/src/ui/public/assets/TemplateManager-C4go4RZu.js +1 -0
  22. package/src/ui/public/assets/UserInputNode-Cl5-bI42.js +1 -0
  23. package/src/ui/public/assets/WorkbenchView-B8QOp8nM.css +1 -0
  24. package/src/ui/public/assets/WorkbenchView-DtCyyW3b.js +6 -0
  25. package/src/ui/public/assets/_plugin-vue_export-helper-Dcylt9fO.js +19 -0
  26. package/src/ui/public/assets/{configStore-BLRcYJdf.js → configStore-BlqdtNUA.js} +1 -1
  27. package/src/ui/public/assets/element-plus-C1PloHQu.css +1 -0
  28. package/src/ui/public/assets/{element-plus-DcZ-TVp6.js → element-plus-IG-bwpR5.js} +6 -6
  29. package/src/ui/public/assets/flow-mindmap-C_w8GJXm.js +139 -0
  30. package/src/ui/public/assets/{gitStore-DMEDMZMk.js → gitStore-BKkZm2m3.js} +4 -4
  31. package/src/ui/public/assets/index-Bq7iCtof.css +1 -0
  32. package/src/ui/public/assets/index-DGHrUfrh.js +28 -0
  33. package/src/ui/public/assets/{office-docx-C0g5Oepv.js → office-docx-CkLOvV7H.js} +5 -5
  34. package/src/ui/public/assets/{office-excel-D-uYLKaA.js → office-excel-CDjN3Va1.js} +1 -1
  35. package/src/ui/public/assets/{office-pptx-De3fnc6M.js → office-pptx-CVu3fXsL.js} +1 -1
  36. package/src/ui/public/assets/previewSandbox-DYMhXJ91.js +0 -0
  37. package/src/ui/public/assets/toolsStore-BlRHNqXl.js +1 -0
  38. package/src/ui/public/assets/{useThemeObserver-CG8k8U18.js → useThemeObserver-DAWKXvWv.js} +1 -1
  39. package/src/ui/public/assets/{vendor-BIgZEtkx.js → vendor-DcK6TCKZ.js} +27 -27
  40. package/src/ui/public/assets/vue-flow-B3gZmkCI.js +39 -0
  41. package/src/ui/public/index.html +18 -18
  42. package/src/ui/server/index.js +8 -0
  43. package/src/ui/server/routes/recentDirectoriesAiSummary.js +288 -0
  44. package/src/ui/server/routes/recentDirectoriesAiSummary.test.js +208 -0
  45. package/src/ui/server/routes/remoteRepos.js +117 -21
  46. package/src/ui/server/routes/remoteRepos.test.js +167 -1
  47. package/src/ui/server/routes/workbench/agentChat.js +9 -3
  48. package/src/ui/server/routes/workbench/agentParity.test.js +16 -0
  49. package/src/ui/server/routes/workbench/agentSessionStore.js +2 -2
  50. package/src/ui/server/routes/workbench/envContext.test.js +8 -8
  51. package/src/ui/server/routes/workbench/index.js +32 -736
  52. package/src/ui/server/routes/workbench/instructionStore.js +3 -113
  53. package/src/ui/server/routes/workbench/jobStore.js +2 -4
  54. package/src/ui/server/routes/workbench/jobStore.test.js +1 -1
  55. package/src/ui/server/routes/workbench/jsonParse.js +6 -143
  56. package/src/ui/server/routes/workbench/orchestratorStore.js +1 -8
  57. package/src/ui/server/routes/workbench/projectRegistry.js +6 -29
  58. package/src/ui/server/routes/workbench/projectRegistry.test.js +33 -56
  59. package/src/ui/server/routes/workbench/projectTool.js +0 -1
  60. package/src/ui/server/routes/workbench/projectTool.test.js +2 -3
  61. package/src/ui/server/routes/workbench/shared.js +2 -7
  62. package/src/ui/server/routes/workbench/taskRunner.js +18 -180
  63. package/src/ui/server/routes/workbench.js +4 -5
  64. package/src/ui/public/assets/AgentView-C-AJo7KS.js +0 -5
  65. package/src/ui/public/assets/AppVersionBadge-B6R6eN2N.js +0 -2
  66. package/src/ui/public/assets/BranchSelector-Cj3kMWON.js +0 -1
  67. package/src/ui/public/assets/CommitForm-DpXuFG-F.js +0 -6
  68. package/src/ui/public/assets/CommonDialog-BLPHDsn0.js +0 -1
  69. package/src/ui/public/assets/EditorView-B1OnoXXf.js +0 -1
  70. package/src/ui/public/assets/FileDiffViewer-BQl0JcWd.js +0 -14
  71. package/src/ui/public/assets/FlowOrchestrationWorkspace-CbLmsSF8.js +0 -21
  72. package/src/ui/public/assets/IconButton-aM3vaSIN.js +0 -1
  73. package/src/ui/public/assets/LogList-UjoVFZ-H.js +0 -7
  74. package/src/ui/public/assets/MindmapView-D_04v0n4.js +0 -1
  75. package/src/ui/public/assets/MonitorView-BD1_uzq7.js +0 -1
  76. package/src/ui/public/assets/ProjectStartupButton-BTYlVvzh.js +0 -1
  77. package/src/ui/public/assets/RemoteManagerDialog-20mpQMwu.js +0 -1
  78. package/src/ui/public/assets/RemoteRepoCard-DOrm17Xd.js +0 -1
  79. package/src/ui/public/assets/SourceMapView-nUU9bYPh.js +0 -3
  80. package/src/ui/public/assets/SvgIcon-LUynX3Iq.js +0 -1
  81. package/src/ui/public/assets/TemplateManager-Bo3Q7J9Y.js +0 -1
  82. package/src/ui/public/assets/UserInputNode-D5tSXiJz.js +0 -1
  83. package/src/ui/public/assets/WorkbenchView-C_Gmzvj1.css +0 -1
  84. package/src/ui/public/assets/WorkbenchView-UGC334JL.js +0 -13
  85. package/src/ui/public/assets/_plugin-vue_export-helper-D-D9qkRz.js +0 -19
  86. package/src/ui/public/assets/element-plus-BWbXh9mk.css +0 -1
  87. package/src/ui/public/assets/flow-mindmap-D1VTkZh3.js +0 -139
  88. package/src/ui/public/assets/index-CW3xs-af.css +0 -1
  89. package/src/ui/public/assets/index-S0_gRyiO.js +0 -26
  90. package/src/ui/public/assets/previewSandbox-DXAxqDVu.js +0 -0
  91. package/src/ui/public/assets/toolsStore-CpImRUhI.js +0 -1
  92. package/src/ui/public/assets/vue-flow-CPCFgenl.js +0 -39
  93. 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
- * NO_COLOR + GITEE_NO_UPDATE_NOTIFIER:输出要拿来解析,不能混进 ANSI 色码,
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
- /** 从 CLI 输出里挑一句能给用户看的话(gitee 把错误打在 stdout,gh 打在 stderr) */
281
- function firstMeaningfulLine(...texts) {
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 line of String(text).split(/\r?\n/).map((s) => s.trim())) {
285
- if (line && !/^\s*$/.test(line)) return line;
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 start = text.search(/[[{]/);
349
+ const plain = stripAnsi(text);
350
+ const start = plain.search(/[[{]/);
297
351
  if (start < 0) return null;
298
352
  try {
299
- return JSON.parse(text.slice(start));
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
- '--json', 'name,nameWithOwner,description,isPrivate,isFork,primaryLanguage,stargazerCount,updatedAt,url',
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
- // 与 ai-split-sessions 不同,这里存储的是完整 agent 对话(含工具调用)。
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
- // 数据目录:与 ai-split-sessions 平级,定义见 src/paths.js
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', subtasks: [] },
60
+ { id: 't1', projectPath: 'D:\\ws\\zen-gitsync', type: 'simple' },
61
61
  // 进行中:有 job 处于 running
62
- { id: 't2', projectPath: 'D:\\ws\\article-generator', type: 'simple', subtasks: [] },
62
+ { id: 't2', projectPath: 'D:\\ws\\article-generator', type: 'simple' },
63
63
  // 待处理:没子任务也没执行过
64
- { id: 't3', projectPath: 'D:\\ws\\article-generator', type: 'simple', subtasks: [] },
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', subtasks: [] });
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', subtasks: [] },
137
- { id: 't2', projectPath: '', type: 'simple', subtasks: [] },
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', subtasks: [] },
157
- { id: 't2', projectPath: 'D:\\ws\\a', type: 'simple', subtasks: [] },
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