zcode-pro 1.3.1 → 1.4.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.
@@ -5,7 +5,7 @@
5
5
  import { createServer } from 'node:http';
6
6
  import { execFile, spawn, spawnSync } from 'node:child_process';
7
7
  import { createDecipheriv, createHash } from 'node:crypto';
8
- import { appendFileSync, chmodSync, copyFileSync, createWriteStream, existsSync, mkdirSync, readdirSync, readFileSync, readlinkSync, realpathSync, renameSync, rmSync, statSync, unlinkSync, writeFileSync } from 'node:fs';
8
+ import { appendFileSync, chmodSync, closeSync, copyFileSync, createWriteStream, existsSync, mkdirSync, openSync, readdirSync, readFileSync, readlinkSync, readSync, realpathSync, renameSync, rmSync, statSync, unlinkSync, writeFileSync } from 'node:fs';
9
9
  import { mkdtemp } from 'node:fs/promises'; // 注意:fs 的 mkdtemp 是回调版,await 它会把 undefined 当回调
10
10
  import { basename, delimiter, dirname, isAbsolute, join, resolve, sep } from 'node:path';
11
11
  import { homedir, tmpdir, userInfo } from 'node:os';
@@ -17,7 +17,7 @@ import { reorderWorkspaceTasks, reorderGroupMembers } from './taskOrder.mjs';
17
17
  import { webdavRequest, webdavPutWithMkcol, webdavHttpText, parseWebdavListing, findCurl } from './webdav.mjs';
18
18
  import { resolveZcodeExecutable } from './paths.mjs';
19
19
 
20
- const VERSION = '1.3.1';
20
+ const VERSION = '1.4.1';
21
21
 
22
22
  // 全局提示词固定在用户主目录:官方加载器按 HOME/USERPROFILE 拼 .zcode/AGENTS.md,
23
23
  // 不读 ZCODE_DATA_BASE_DIR(数据根迁走时全局指令仍在原位)。
@@ -35,6 +35,102 @@ function defaultRtkDir() {
35
35
  return process.env.RTK_DIR || join(homedir(), '.zcode-rtk');
36
36
  }
37
37
 
38
+ // 主题功能的背景图库:用户上传的图片都存这里,helper 以 /bg/file/<name> 提供,
39
+ // 注入 CSS 直接引用该 URL(CSS url() 带不了 token 头,该路由免鉴权,见下)
40
+ const BG_DIR_NAME = 'backgrounds';
41
+ function backgroundsDir(dataRoot) {
42
+ return join(dataRoot, BG_DIR_NAME);
43
+ }
44
+ // 背景图上传约束:只收常见位图格式,单张 ≤10MB,图库 ≤50 张(防塞爆磁盘与同步)
45
+ const BG_EXT = new Set(['.png', '.jpg', '.jpeg', '.webp', '.gif', '.bmp', '.avif']);
46
+ const BG_MAX_BYTES = 10 * 1024 * 1024;
47
+ const BG_MAX_COUNT = 50;
48
+ const BG_MIME = { '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.webp': 'image/webp', '.gif': 'image/gif', '.bmp': 'image/bmp', '.avif': 'image/avif' };
49
+ // 文件名安全化:去路径分隔符与控制字符,空白折叠,保留扩展名;非法返回 null
50
+ function sanitizeBgName(raw) {
51
+ if (typeof raw !== 'string') return null;
52
+ const name = raw.replace(/[\r\n\t\/\\]/g, '').replace(/\s+/g, ' ').trim();
53
+ if (!name || name.startsWith('.') || name.length > 120) return null;
54
+ const ext = name.slice(name.lastIndexOf('.')).toLowerCase();
55
+ if (!BG_EXT.has(ext)) return null;
56
+ return name;
57
+ }
58
+ function listBgFiles(dataRoot) {
59
+ const dir = backgroundsDir(dataRoot);
60
+ try {
61
+ return readdirSync(dir)
62
+ .map((name) => { const st = statSync(join(dir, name)); return { name, size: st.size, mtime: st.mtimeMs }; })
63
+ .filter((f) => BG_EXT.has(f.name.slice(f.name.lastIndexOf('.')).toLowerCase()))
64
+ .sort((a, b) => b.mtime - a.mtime);
65
+ } catch { return []; } // 目录不存在 = 空图库
66
+ }
67
+
68
+ // 主题配置(设置 → 外观 → 主题/背景图):skin 为皮肤 id(default = 不覆盖,
69
+ // custom = 用 custom 锚点色现算色阶);bg.mode 选背景来源——none 无背景 /
70
+ // library 本地图库(file 为当前文件)/ folder 文件夹(定时切换,folderRandom
71
+ // 随机或顺序)/ url 网址(intervalMin 针对随机图 API 定时刷新);
72
+ // maskOpacity 遮罩不透明度 0-0.99(越高图片越向主题背景色淡化,文字对比越
73
+ // 清晰,默认 0.82)、blurPx 图片模糊 0-24px(默认 0)、panelOpacity 面板
74
+ // 不透明度 0-1(侧栏/面板/标签栏,越低透出图片越多,默认 0.3)
75
+ const THEME_SKINS = new Set(['default', 'dracula', 'nord', 'tokyonight', 'catppuccin', 'gruvbox', 'onedark', 'solarized', 'rosepine', 'everforest', 'nightowl', 'kanagawa', 'custom']);
76
+ const BG_MODES = new Set(['none', 'library', 'folder', 'url']);
77
+ const BG_FITS = new Set(['cover', 'contain', 'fill', 'tile']);
78
+ const HEX_COLOR = /^#[0-9a-fA-F]{6}$/;
79
+ const CUSTOM_KEYS = ['bg', 'card', 'fg', 'muted', 'accent'];
80
+ function mergeCustomSkin(saved) {
81
+ const out = {};
82
+ for (const mode of ['light', 'dark']) {
83
+ const src = saved && typeof saved === 'object' && saved[mode] && typeof saved[mode] === 'object' ? saved[mode] : {};
84
+ out[mode] = {};
85
+ for (const k of CUSTOM_KEYS) out[mode][k] = HEX_COLOR.test(src[k]) ? src[k].toLowerCase() : null;
86
+ }
87
+ return out;
88
+ }
89
+ function str(v, max) {
90
+ return typeof v === 'string' && v.length <= max ? v : null;
91
+ }
92
+ // 分区背景来源(bg.regions.left/center/right):null = 跟随全局;
93
+ // folder 轮换复用全局的 intervalMin 与 folderRandom
94
+ function mergeBgRegion(src) {
95
+ if (!src || typeof src !== 'object') return null;
96
+ const mode = src.mode === 'library' || src.mode === 'folder' || src.mode === 'url' ? src.mode : null;
97
+ if (!mode) return null;
98
+ return {
99
+ mode,
100
+ file: mode === 'library' ? (str(src.file, 200) || '') : '',
101
+ folder: mode === 'folder' ? (str(src.folder, 4096) || '') : '',
102
+ url: mode === 'url' ? (str(src.url, 2000) || '').trim() : '',
103
+ };
104
+ }
105
+ export function mergeThemeConfig(saved) {
106
+ const t = saved && typeof saved === 'object' ? saved : {};
107
+ const bg = t.bg && typeof t.bg === 'object' ? t.bg : {};
108
+ const cfg = {
109
+ skin: THEME_SKINS.has(t.skin) ? t.skin : 'default',
110
+ custom: mergeCustomSkin(t.custom),
111
+ bg: {
112
+ mode: BG_MODES.has(bg.mode) ? bg.mode : 'none',
113
+ file: str(bg.file, 200) || '',
114
+ folder: str(bg.folder, 4096) || '',
115
+ folderRandom: bg.folderRandom !== false,
116
+ url: (str(bg.url, 2000) || '').trim(),
117
+ intervalMin: Number.isFinite(Number(bg.intervalMin)) ? Math.min(1440, Math.max(0, Math.round(Number(bg.intervalMin)))) : 0,
118
+ fit: BG_FITS.has(bg.fit) ? bg.fit : 'cover',
119
+ // null/undefined/非数值都落默认(Number(null)=0 是有限数,须先排除 null)
120
+ maskOpacity: bg.maskOpacity != null && Number.isFinite(Number(bg.maskOpacity))
121
+ ? Math.min(0.99, Math.max(0, Number(bg.maskOpacity))) : 0.82,
122
+ blurPx: bg.blurPx != null && Number.isFinite(Number(bg.blurPx)) ? Math.min(24, Math.max(0, Math.round(Number(bg.blurPx)))) : 0,
123
+ panelOpacity: bg.panelOpacity != null && Number.isFinite(Number(bg.panelOpacity)) ? Math.min(1, Math.max(0, Number(bg.panelOpacity))) : 0.3,
124
+ regions: {
125
+ left: mergeBgRegion(bg.regions && bg.regions.left),
126
+ center: mergeBgRegion(bg.regions && bg.regions.center),
127
+ right: mergeBgRegion(bg.regions && bg.regions.right),
128
+ },
129
+ },
130
+ };
131
+ return cfg;
132
+ }
133
+
38
134
  // headroom 插件的配置文件(与 /hr-* 命令、钩子共用,headroom-lib.sh 按 HOME 定位)
39
135
  function defaultHeadroomFile() {
40
136
  return join(homedir(), '.zcode', 'headroom.json');
@@ -140,8 +236,10 @@ export function defaultConfig() {
140
236
  lastPushAt: null,
141
237
  lastPullAt: null,
142
238
  },
143
- // 一次性迁移标记:migrateConfigOnce 执行过哪些纠偏(见 startHelper),
144
- // 有标记的迁移不再重复,用户此后手动改回的设置不再被覆盖
239
+ theme: mergeThemeConfig(undefined),
240
+ // 一次性迁移标记:记录弹窗类迁移做过没有(目前只有别名迁移的
241
+ // aliasRenameDismissed,经 /config POST 写入),有标记的迁移不再重复,
242
+ // 用户此后手动改回的设置不再被覆盖
145
243
  migrations: {},
146
244
  };
147
245
  }
@@ -162,6 +260,7 @@ export function loadConfig(configFile) {
162
260
  aliases: { ...((saved.aliases && typeof saved.aliases === 'object') ? saved.aliases : {}) },
163
261
  headroomRoutes: { ...((saved.headroomRoutes && typeof saved.headroomRoutes === 'object' && !Array.isArray(saved.headroomRoutes)) ? saved.headroomRoutes : {}) },
164
262
  titleGen: mergeTitleGenConfig(saved.titleGen),
263
+ theme: mergeThemeConfig(saved.theme),
165
264
  proxyScopes: mergeProxyScopes(saved.proxyScopes),
166
265
  devMode: saved.devMode === true,
167
266
  // devActive 每次读取时重算(保存时会随配置一起写入文件,但读取方不用保存的值):
@@ -185,19 +284,9 @@ export function saveConfig(configFile, config) {
185
284
  renameSync(tmp, configFile);
186
285
  }
187
286
 
188
- // 一次性配置迁移:升级后对历史设置做纠偏,执行过的迁移记入 migrations 标记,
189
- // 之后不再重复(用户手动改回的设置不会被再次覆盖)。失败静默、下次启动再试。
190
- // - wsRunningSpinOff:“折叠项目运行提示”实验性功能会在后台周期性短暂展开
191
- // 项目复核运行状态,新版本不再默认开启,历史开启过的用户升级后强制关闭一次
192
- function migrateConfigOnce(configFile) {
193
- try {
194
- const config = loadConfig(configFile);
195
- if (config.migrations && config.migrations.wsRunningSpinOff) return;
196
- config.features = { ...config.features, wsRunningSpin: false };
197
- config.migrations = { ...(config.migrations || {}), wsRunningSpinOff: true };
198
- saveConfig(configFile, config);
199
- } catch { /* 配置不可读/不可写时跳过,下次启动再试 */ }
200
- }
287
+ // 一次性配置迁移已移除(原 wsRunningSpinOff):旧实验版“折叠项目运行提示”
288
+ // 会后台周期性短暂展开项目,当时给无标记的配置强制关过一次;该功能 1.3.1
289
+ // 已重写,迁移对新装机(首启无配置文件、首存后才建)误伤为默认关闭,故删。
201
290
 
202
291
  function json(res, status, obj) {
203
292
  const body = JSON.stringify(obj);
@@ -210,13 +299,13 @@ function json(res, status, obj) {
210
299
  res.end(body);
211
300
  }
212
301
 
213
- function readBody(req) {
302
+ function readBody(req, limitBytes = 1 << 20) {
214
303
  return new Promise((resolveBody, rejectBody) => {
215
304
  const chunks = [];
216
305
  let size = 0;
217
306
  req.on('data', (c) => {
218
307
  size += c.length;
219
- if (size > 1 << 20) { rejectBody(new Error('请求体过大')); req.destroy(); return; }
308
+ if (size > limitBytes) { rejectBody(new Error('请求体过大')); req.destroy(); return; }
220
309
  chunks.push(c);
221
310
  });
222
311
  req.on('end', () => {
@@ -267,7 +356,6 @@ function titleGenLog(dataRoot, line) {
267
356
  export function startHelper({ port, token, dataRoot, state, agentsFile = defaultAgentsFile() }) {
268
357
  const configFile = join(dataRoot, 'zcodepro.json');
269
358
  const settingsFile = join(dataRoot, 'v2', 'setting.json');
270
- migrateConfigOnce(configFile);
271
359
  const bootConfig = loadConfig(configFile);
272
360
  activeProxyUrl = String(bootConfig.proxy || '').trim();
273
361
  activeProxyScopes = { ...bootConfig.proxyScopes };
@@ -286,6 +374,44 @@ export function startHelper({ port, token, dataRoot, state, agentsFile = default
286
374
  json(res, 204, {});
287
375
  return;
288
376
  }
377
+ // 背景图取文件:CSS url() 发不了自定义头,该路由在 token 校验之前放行。
378
+ // 仅 Host 校验(127.0.0.1,防 DNS rebinding)+ 文件名白名单目录内取图,
379
+ // 不接受路径参数之外的任何输入。文件夹模式从配置的目录里取(同样免 token)
380
+ const serveBgFile = (name, dir, res2) => {
381
+ const safe = sanitizeBgName(name);
382
+ const file = safe && join(dir, safe);
383
+ if (!file || !existsSync(file)) {
384
+ res2.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' });
385
+ res2.end('not found');
386
+ return;
387
+ }
388
+ try {
389
+ const buf = readFileSync(file);
390
+ res2.writeHead(200, {
391
+ 'Content-Type': BG_MIME[safe.slice(safe.lastIndexOf('.')).toLowerCase()] || 'application/octet-stream',
392
+ 'Cache-Control': 'no-cache',
393
+ 'Access-Control-Allow-Origin': '*',
394
+ });
395
+ res2.end(buf);
396
+ } catch {
397
+ res2.writeHead(500, { 'Content-Type': 'text/plain; charset=utf-8' });
398
+ res2.end('read error');
399
+ }
400
+ };
401
+ if (req.method === 'GET' && url.pathname.startsWith('/bg/file/')) {
402
+ serveBgFile(decodeURIComponent(url.pathname.slice('/bg/file/'.length)), backgroundsDir(dataRoot), res);
403
+ return;
404
+ }
405
+ if (req.method === 'GET' && url.pathname.startsWith('/bg/folder/file/')) {
406
+ const dir = loadConfig(configFile).theme.bg.folder;
407
+ if (!dir) {
408
+ res.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' });
409
+ res.end('no folder');
410
+ return;
411
+ }
412
+ serveBgFile(decodeURIComponent(url.pathname.slice('/bg/folder/file/'.length)), dir, res);
413
+ return;
414
+ }
289
415
  if (token && req.headers['x-zcodepro-token'] !== token) {
290
416
  json(res, 401, { ok: false, error: 'unauthorized' });
291
417
  return;
@@ -377,6 +503,31 @@ export function startHelper({ port, token, dataRoot, state, agentsFile = default
377
503
  }
378
504
  current.statusBar = next;
379
505
  }
506
+ // 主题(设置 → 样式 → 颜色/背景图):按字段合并,白名单校验在
507
+ // mergeThemeConfig;library 的 file 与 folder 的目录可能指向已不存在的
508
+ // 目标(图已删/目录移走/同步后缺失),注入层按“取不到 = 无背景”降级
509
+ if (body && typeof body === 'object' && body.theme && typeof body.theme === 'object') {
510
+ const bt = body.theme;
511
+ const cur = current.theme;
512
+ const next = {
513
+ skin: bt.skin !== undefined ? bt.skin : cur.skin,
514
+ custom: bt.custom !== undefined ? bt.custom : cur.custom,
515
+ bg: {
516
+ mode: bt.bg && bt.bg.mode !== undefined ? bt.bg.mode : cur.bg.mode,
517
+ file: bt.bg && bt.bg.file !== undefined ? bt.bg.file : cur.bg.file,
518
+ folder: bt.bg && bt.bg.folder !== undefined ? bt.bg.folder : cur.bg.folder,
519
+ folderRandom: bt.bg && bt.bg.folderRandom !== undefined ? bt.bg.folderRandom : cur.bg.folderRandom,
520
+ url: bt.bg && bt.bg.url !== undefined ? bt.bg.url : cur.bg.url,
521
+ intervalMin: bt.bg && bt.bg.intervalMin !== undefined ? bt.bg.intervalMin : cur.bg.intervalMin,
522
+ fit: bt.bg && bt.bg.fit !== undefined ? bt.bg.fit : cur.bg.fit,
523
+ maskOpacity: bt.bg && bt.bg.maskOpacity !== undefined ? bt.bg.maskOpacity : cur.bg.maskOpacity,
524
+ blurPx: bt.bg && bt.bg.blurPx !== undefined ? bt.bg.blurPx : cur.bg.blurPx,
525
+ panelOpacity: bt.bg && bt.bg.panelOpacity !== undefined ? bt.bg.panelOpacity : cur.bg.panelOpacity,
526
+ regions: bt.bg && bt.bg.regions !== undefined ? bt.bg.regions : cur.bg.regions,
527
+ },
528
+ };
529
+ current.theme = mergeThemeConfig(next);
530
+ }
380
531
  if (body && typeof body === 'object' && body.titleGen && typeof body.titleGen === 'object') {
381
532
  current.titleGen = mergeTitleGenConfig({ ...current.titleGen, ...body.titleGen });
382
533
  }
@@ -392,6 +543,117 @@ export function startHelper({ port, token, dataRoot, state, agentsFile = default
392
543
  json(res, 200, { ok: true, config: current });
393
544
  return;
394
545
  }
546
+ // —— 主题背景图库:列表 / base64 上传 / 删除(取文件见上方免 token 的 /bg/file)——
547
+ if (req.method === 'GET' && url.pathname === '/bg') {
548
+ json(res, 200, { ok: true, files: listBgFiles(dataRoot), maxCount: BG_MAX_COUNT });
549
+ return;
550
+ }
551
+ if (req.method === 'POST' && url.pathname === '/bg') {
552
+ // 图片走 base64(约为原文件的 1.34 倍),上限放宽到 16MB;
553
+ // 其余端点维持 1MB
554
+ const body = await readBody(req, 16 << 20);
555
+ const name = sanitizeBgName(body?.name);
556
+ const data = typeof body?.data === 'string' ? body.data : '';
557
+ if (!name) {
558
+ json(res, 400, { ok: false, code: 'bg-name', error: '背景图文件名需以 png/jpg/jpeg/webp/gif/bmp/avif 结尾,且不含路径' });
559
+ return;
560
+ }
561
+ if (!/^data:image\/[a-z+]+;base64,/.test(data)) {
562
+ json(res, 400, { ok: false, code: 'bg-data', error: '背景图数据需为 data:image/*;base64 格式' });
563
+ return;
564
+ }
565
+ const b64 = data.slice(data.indexOf(',') + 1);
566
+ const buf = Buffer.from(b64, 'base64');
567
+ if (!buf.length || buf.length > BG_MAX_BYTES) {
568
+ json(res, 400, { ok: false, code: 'bg-size', error: `背景图需在 10MB 以内(收到 ${(buf.length / 1048576).toFixed(1)}MB)` });
569
+ return;
570
+ }
571
+ const dir = backgroundsDir(dataRoot);
572
+ const existing = listBgFiles(dataRoot);
573
+ if (!existsSync(join(dir, name)) && existing.length >= BG_MAX_COUNT) {
574
+ json(res, 400, { ok: false, code: 'bg-count', error: `图库最多 ${BG_MAX_COUNT} 张,请先删除一些再上传` });
575
+ return;
576
+ }
577
+ // 图片解码校验:文件头魔数匹配扩展名(防止把可执行文件改名成 png 上传)
578
+ const magicOk = {
579
+ '.png': buf.slice(0, 8).toString('hex') === '89504e470d0a1a0a',
580
+ '.jpg': buf[0] === 0xff && buf[1] === 0xd8,
581
+ '.jpeg': buf[0] === 0xff && buf[1] === 0xd8,
582
+ '.gif': buf.slice(0, 3).toString('ascii') === 'GIF',
583
+ '.webp': buf.slice(0, 4).toString('ascii') === 'RIFF' && buf.slice(8, 12).toString('ascii') === 'WEBP',
584
+ '.bmp': buf[0] === 0x42 && buf[1] === 0x4d,
585
+ '.avif': buf.slice(4, 8).toString('ascii') === 'ftyp',
586
+ }[name.slice(name.lastIndexOf('.')).toLowerCase()];
587
+ if (!magicOk) {
588
+ json(res, 400, { ok: false, code: 'bg-magic', error: '图片内容与扩展名不符' });
589
+ return;
590
+ }
591
+ try {
592
+ mkdirSync(dir, { recursive: true });
593
+ const tmp = join(dir, name + '.tmp');
594
+ writeFileSync(tmp, buf);
595
+ renameSync(tmp, join(dir, name));
596
+ } catch (errBg) {
597
+ json(res, 500, { ok: false, code: 'bg-write', error: '写入背景图失败: ' + (errBg?.message || errBg) });
598
+ return;
599
+ }
600
+ json(res, 200, { ok: true, files: listBgFiles(dataRoot) });
601
+ return;
602
+ }
603
+ if (req.method === 'POST' && url.pathname === '/bg/delete') {
604
+ const body = await readBody(req);
605
+ const name = sanitizeBgName(body?.name);
606
+ if (!name) { json(res, 400, { ok: false, code: 'bg-name', error: '非法文件名' }); return; }
607
+ try {
608
+ const file = join(backgroundsDir(dataRoot), name);
609
+ if (existsSync(file)) unlinkSync(file);
610
+ } catch (errBg) {
611
+ json(res, 500, { ok: false, code: 'bg-delete', error: '删除背景图失败: ' + (errBg?.message || errBg) });
612
+ return;
613
+ }
614
+ json(res, 200, { ok: true, files: listBgFiles(dataRoot) });
615
+ return;
616
+ }
617
+ // —— 文件夹背景:设置/刷新列表(目录本身存 theme.bg.folder,文件原地读
618
+ // 取不复制);切换由注入层定时驱动(随机或顺序)
619
+ const listFolderBg = (dir) => {
620
+ const st = statSync(dir);
621
+ if (!st.isDirectory()) return { error: '不是文件夹:' + dir };
622
+ const files = readdirSync(dir)
623
+ .filter((name) => BG_EXT.has(name.slice(name.lastIndexOf('.')).toLowerCase()))
624
+ .map((name) => { const s = statSync(join(dir, name)); return { name, size: s.size, mtime: s.mtimeMs }; })
625
+ .sort((a, b) => a.name.localeCompare(b.name));
626
+ return { files };
627
+ };
628
+ if (req.method === 'POST' && url.pathname === '/bg/folder') {
629
+ const body = await readBody(req);
630
+ const dir = typeof body?.dir === 'string' ? body.dir.trim() : '';
631
+ if (!dir || dir.length > 4096) { json(res, 400, { ok: false, code: 'bg-folder', error: '文件夹路径无效' }); return; }
632
+ if (!isAbsolute(dir)) { json(res, 400, { ok: false, code: 'bg-folder', error: '文件夹路径需为绝对路径(以 / 或盘符开头)' }); return; }
633
+ if (!existsSync(dir)) { json(res, 404, { ok: false, code: 'bg-folder', error: '文件夹不存在:' + dir }); return; }
634
+ try {
635
+ const r = listFolderBg(dir);
636
+ if (r.error) { json(res, 400, { ok: false, code: 'bg-folder', error: r.error }); return; }
637
+ json(res, 200, { ok: true, files: r.files, dir });
638
+ } catch (errBg) {
639
+ json(res, 500, { ok: false, code: 'bg-folder', error: '读取文件夹失败: ' + (errBg?.message || errBg) });
640
+ return;
641
+ }
642
+ return;
643
+ }
644
+ if (req.method === 'GET' && url.pathname === '/bg/folder/list') {
645
+ // dir 查询参数:分区背景列自己目录时传入;缺省列全局设置的目录
646
+ const qDir = url.searchParams.get('dir');
647
+ const dir = (qDir && qDir.length <= 4096) ? qDir : loadConfig(configFile).theme.bg.folder;
648
+ if (!dir || !existsSync(dir)) { json(res, 200, { ok: true, files: [], missing: true, dir: dir || '' }); return; }
649
+ try {
650
+ const r = listFolderBg(dir);
651
+ json(res, 200, r.error ? { ok: true, files: [], missing: true, dir } : { ok: true, files: r.files, dir });
652
+ } catch {
653
+ json(res, 200, { ok: true, files: [], missing: true, dir });
654
+ }
655
+ return;
656
+ }
395
657
  // 空闲项目内存回收(idle-reclaim.js):判断各项目的会话进程(zcode-cli)是否
396
658
  // 仍挂着“运行中的用户命令”。按平台走不同适配器(Linux /proc、macOS ps+lsof、
397
659
  // Windows PowerShell);scan=false 表示当前环境不可用,注入层据此停用整个
@@ -1051,7 +1313,7 @@ export function startHelper({ port, token, dataRoot, state, agentsFile = default
1051
1313
  json(res, r.status || 500, { ok: false, code: r.code || 'sync-run', error: r.error });
1052
1314
  return;
1053
1315
  }
1054
- json(res, 200, { ok: true, direction, applied: r.applied, ...(typeof r.pruned === 'number' ? { pruned: r.pruned } : {}), sync: loadConfig(configFile).sync });
1316
+ json(res, 200, { ok: true, direction, applied: r.applied, ...(typeof r.pruned === 'number' ? { pruned: r.pruned } : {}), ...(r.bg ? { bg: r.bg } : {}), sync: loadConfig(configFile).sync });
1055
1317
  return;
1056
1318
  }
1057
1319
  // —— 同步连通性测试(“测试”按钮):用表单当前值逐步验证(可先测再存),不写入配置文件 ——
@@ -1264,10 +1526,70 @@ let tasksCountCache = 0; // 任务总数缓存(/stats/usage 高频轮询,60
1264
1526
  // (协议 camelCase 与原始 snake_case),两种都解析。文件小(个位数、每会话一个),
1265
1527
  // 按 mtime+size 缓存只重读变过的文件;聚合结果含按日分桶(本地时区)供“今日”用
1266
1528
  const usageFileCache = new Map(); // 文件名 → { mtime, size, recs: [{at, in, out, session, model}] }
1529
+ // 解析一段 rollout 文本为记录;completeBytes = 已完整解析的整行字节数
1530
+ //(末行若是不完整 JSON(写入中途)不计入,调用方据此决定偏移推进量)
1531
+ function parseRolloutLines(text) {
1532
+ const recs = [];
1533
+ let completeBytes = 0;
1534
+ let consumed = 0;
1535
+ const parts = text.split('\n');
1536
+ for (let pi = 0; pi < parts.length; pi++) {
1537
+ const line = parts[pi];
1538
+ const isTail = pi === parts.length - 1; // 末段无换行结尾
1539
+ const lineBytes = Buffer.byteLength(line, 'utf8') + 1; // +1 换行(末段实际没有,不影响“不推进”语义)
1540
+ if (!line.trim()) {
1541
+ if (!isTail) { consumed += lineBytes; completeBytes = consumed; }
1542
+ continue;
1543
+ }
1544
+ let d;
1545
+ try {
1546
+ d = JSON.parse(line);
1547
+ } catch {
1548
+ // 只有末段解析失败才视为“写入中途的半行”:偏移停在整行边界等下次补齐;
1549
+ // 中间的坏行(崩溃残留等)按旧行为跳过继续,不能截断后面的全部数据
1550
+ if (isTail) break;
1551
+ consumed += lineBytes;
1552
+ completeBytes = consumed;
1553
+ continue;
1554
+ }
1555
+ const u = (d.response && d.response.usage) || d.usage;
1556
+ if (u) {
1557
+ const num = (v) => Number(v) || 0;
1558
+ // 两种键风格语义相反,必须分开处理:
1559
+ // 协议格式(inputTokens)的总输入已含缓存读(官方 summary 证实);
1560
+ // 原始 API 格式(input_tokens,Anthropic 语义)三个字段分开、需相加
1561
+ let input, cacheRead, cacheWrite;
1562
+ if (u.inputTokens != null) {
1563
+ input = num(u.inputTokens);
1564
+ cacheRead = num(u.cacheReadTokens);
1565
+ cacheWrite = num(u.cacheWriteTokens);
1566
+ } else {
1567
+ cacheRead = num(u.cache_read_input_tokens);
1568
+ cacheWrite = num(u.cache_write_input_tokens);
1569
+ input = num(u.input_tokens) + cacheRead + cacheWrite;
1570
+ }
1571
+ const output = num(u.outputTokens ?? u.output_tokens);
1572
+ recs.push({
1573
+ at: d.completedAt || d.startedAt || '',
1574
+ in: input, out: output,
1575
+ cacheRead,
1576
+ session: String(d.sessionId || ''),
1577
+ });
1578
+ }
1579
+ consumed += isTail ? Buffer.byteLength(line, 'utf8') : lineBytes;
1580
+ completeBytes = consumed;
1581
+ }
1582
+ return { recs, completeBytes };
1583
+ }
1584
+
1267
1585
  function rolloutRecords() {
1268
1586
  const dir = join(homedir(), '.zcode', 'cli', 'rollout');
1269
1587
  let names = [];
1270
1588
  try { names = readdirSync(dir).filter((n) => n.startsWith('model-io-') && n.endsWith('.jsonl')); } catch { return []; }
1589
+ // 缓存条目随文件淘汰:会话记录滚动删除后残留条目不再增长(虽小但是纯泄漏)
1590
+ for (const k of usageFileCache.keys()) {
1591
+ if (!names.includes(k)) usageFileCache.delete(k);
1592
+ }
1271
1593
  const recs = [];
1272
1594
  for (const name of names) {
1273
1595
  const full = join(dir, name);
@@ -1278,38 +1600,37 @@ function rolloutRecords() {
1278
1600
  recs.push(...cached.recs);
1279
1601
  continue;
1280
1602
  }
1281
- const parsed = [];
1603
+ // 追加式增量解析:jsonl 只追加(流式输出期间当前会话文件持续增长,整文件
1604
+ // 重读重解析一次可达数十毫秒)。从上次已完整解析的偏移续读新增部分;末行
1605
+ // 若是写入中途的半行会被丢弃且偏移不推进,下次变化时重读该行
1606
+ if (cached && st.size > cached.size && st.mtimeMs >= cached.mtimeMs) {
1607
+ const fd = openSync(full, 'r');
1608
+ let tail = '';
1609
+ try {
1610
+ const buf = Buffer.alloc(st.size - cached.size);
1611
+ readSync(fd, buf, 0, buf.length, cached.size);
1612
+ tail = buf.toString('utf8');
1613
+ } catch { tail = ''; } finally { closeSync(fd); }
1614
+ const { recs: added, completeBytes } = parseRolloutLines(tail);
1615
+ usageFileCache.set(name, {
1616
+ mtime: st.mtimeMs,
1617
+ size: cached.size + completeBytes,
1618
+ recs: cached.recs.concat(added),
1619
+ });
1620
+ recs.push(...cached.recs, ...added);
1621
+ continue;
1622
+ }
1623
+ let parsed = [];
1624
+ let parsedBytes = st.size;
1282
1625
  try {
1283
- for (const line of readFileSync(full, 'utf8').split('\n')) {
1284
- if (!line.trim()) continue;
1285
- let d;
1286
- try { d = JSON.parse(line); } catch { continue; }
1287
- const u = (d.response && d.response.usage) || d.usage;
1288
- if (!u) continue;
1289
- const num = (v) => Number(v) || 0;
1290
- // 两种键风格语义相反,必须分开处理:
1291
- // 协议格式(inputTokens)的总输入已含缓存读(官方 summary 证实);
1292
- // 原始 API 格式(input_tokens,Anthropic 语义)三个字段分开、需相加
1293
- let input, cacheRead, cacheWrite;
1294
- if (u.inputTokens != null) {
1295
- input = num(u.inputTokens);
1296
- cacheRead = num(u.cacheReadTokens);
1297
- cacheWrite = num(u.cacheWriteTokens);
1298
- } else {
1299
- cacheRead = num(u.cache_read_input_tokens);
1300
- cacheWrite = num(u.cache_write_input_tokens);
1301
- input = num(u.input_tokens) + cacheRead + cacheWrite;
1302
- }
1303
- const output = num(u.outputTokens ?? u.output_tokens);
1304
- parsed.push({
1305
- at: d.completedAt || d.startedAt || '',
1306
- in: input, out: output,
1307
- cacheRead,
1308
- session: String(d.sessionId || ''),
1309
- });
1310
- }
1626
+ const text = readFileSync(full, 'utf8');
1627
+ const r = parseRolloutLines(text);
1628
+ parsed = r.recs;
1629
+ // 全量读不存在半行截断(readFileSync 拿到的是当下完整内容),但保守起见
1630
+ // 同样只把已完整解析的字节数记为偏移
1631
+ parsedBytes = Math.min(r.completeBytes, Buffer.byteLength(text, 'utf8'));
1311
1632
  } catch { /* 文件读失败跳过 */ }
1312
- usageFileCache.set(name, { mtime: st.mtimeMs, size: st.size, recs: parsed });
1633
+ usageFileCache.set(name, { mtime: st.mtimeMs, size: parsedBytes, recs: parsed });
1313
1634
  recs.push(...parsed);
1314
1635
  }
1315
1636
  return recs;
@@ -1317,6 +1638,9 @@ function rolloutRecords() {
1317
1638
 
1318
1639
  function collectUsageStats(sessionId) {
1319
1640
  const recs = rolloutRecords();
1641
+ // 分钟桶只服务每轮统计(必须带 session);不带 session 的调用(状态栏
1642
+ // 回退路径)只要全局累计,跳过分桶省去全记录遍历与 60 桶序列化
1643
+ const wantTurns = !!sessionId;
1320
1644
  const today = new Date();
1321
1645
  const todayKey = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}-${String(today.getDate()).padStart(2, '0')}`;
1322
1646
  const global = { todayTokens: 0, todayOut: 0, totalTokens: 0, totalOut: 0, tasks: 0, records: recs.length };
@@ -1331,10 +1655,10 @@ function collectUsageStats(sessionId) {
1331
1655
  if (day === todayKey) { global.todayTokens += total; global.todayOut += r.out; }
1332
1656
  // 每轮回答只统计主对话流的调用:起名等辅助请求是独立小上下文、
1333
1657
  // 缓存从零开始,混进分钟桶会把命中率拉到个位数(全局统计仍全量计入)
1334
- if (total < 5000) continue;
1658
+ if (!wantTurns || total < 5000) continue;
1335
1659
  // 指定会话时每轮桶只算该会话的调用——否则切到别的会话聊天后,
1336
1660
  // 那些调用会被区间归属算进当前会话最后一行的头上
1337
- if (sessionId && r.session !== sessionId) continue;
1661
+ if (r.session !== sessionId) continue;
1338
1662
  const t = Date.parse(r.at);
1339
1663
  if (!Number.isFinite(t)) continue;
1340
1664
  const d = new Date(t);
@@ -1499,6 +1823,87 @@ function syncFileUrlByName(sync, fileName) {
1499
1823
  return syncBaseDir(sync) + '/' + encodeURIComponent(fileName);
1500
1824
  }
1501
1825
 
1826
+ // —— 背景图库随 zcodepro 组同步:镜像语义,覆盖式、无历史版本 ——
1827
+ // 上传 = 本地图全部传上去(同名同大小跳过),云端多余删除;
1828
+ // 下载 = 云端图全部落到本机(同名同大小跳过),本地多余删除
1829
+ function syncBgDirUrl(sync) {
1830
+ return syncBaseDir(sync) + '/' + BG_DIR_NAME;
1831
+ }
1832
+ function syncBgFileUrl(sync, name) {
1833
+ return syncBgDirUrl(sync) + '/' + encodeURIComponent(name);
1834
+ }
1835
+ // 云端图库列表:目录不存在(404)视为空库;条目按扩展名白名单过滤
1836
+ async function syncBgListRemote(sync, proxyUrl) {
1837
+ const list = await webdavRequest({ method: 'PROPFIND', url: syncBgDirUrl(sync) + '/', login: sync.login, password: sync.password, proxyUrl, headers: ['Depth: 1'], timeoutMs: 30000 });
1838
+ if (!list.ok) return { error: list.error, code: 'sync-request', status: 502 };
1839
+ if (list.httpCode === 404) return { files: [] };
1840
+ if (list.httpCode < 200 || list.httpCode >= 300) return { error: webdavHttpText(list.httpCode), code: 'sync-request', status: 502 };
1841
+ return { files: parseWebdavListing(list.body).filter((f) => BG_EXT.has(f.name.slice(f.name.lastIndexOf('.')).toLowerCase())) };
1842
+ }
1843
+ async function syncBgPush({ sync, proxyUrl, dataRoot }) {
1844
+ const localFiles = listBgFiles(dataRoot);
1845
+ const remote = await syncBgListRemote(sync, proxyUrl);
1846
+ if (remote.error) return remote;
1847
+ const remoteByName = new Map(remote.files.map((f) => [f.name, f]));
1848
+ const dir = backgroundsDir(dataRoot);
1849
+ const uploaded = [];
1850
+ for (const f of localFiles) {
1851
+ const r = remoteByName.get(f.name);
1852
+ if (r && r.size === f.size) continue;
1853
+ const ext = f.name.slice(f.name.lastIndexOf('.')).toLowerCase();
1854
+ const put = await webdavPutWithMkcol({
1855
+ url: syncBgFileUrl(sync, f.name), login: sync.login, password: sync.password,
1856
+ uploadFile: join(dir, f.name), proxyUrl, timeoutMs: 120000,
1857
+ contentType: BG_MIME[ext] || 'application/octet-stream',
1858
+ });
1859
+ if (!put.ok) return { code: 'sync-request', status: 502, error: put.error };
1860
+ if (put.httpCode < 200 || put.httpCode >= 300) return { code: 'sync-request', status: 502, error: webdavHttpText(put.httpCode) };
1861
+ uploaded.push(f.name);
1862
+ }
1863
+ const deleted = [];
1864
+ const localNames = new Set(localFiles.map((f) => f.name));
1865
+ for (const f of remote.files) {
1866
+ if (localNames.has(f.name)) continue;
1867
+ const del = await webdavRequest({ method: 'DELETE', url: syncBgFileUrl(sync, f.name), login: sync.login, password: sync.password, proxyUrl, timeoutMs: 30000 });
1868
+ if (!del.ok) return { code: 'sync-request', status: 502, error: del.error };
1869
+ if (del.httpCode === 404) continue; // 已被并发清掉
1870
+ if (del.httpCode < 200 || del.httpCode >= 300) return { code: 'sync-request', status: 502, error: webdavHttpText(del.httpCode) };
1871
+ deleted.push(f.name);
1872
+ }
1873
+ return { uploaded, deleted, count: localFiles.length };
1874
+ }
1875
+ async function syncBgPull({ sync, proxyUrl, dataRoot }) {
1876
+ const remote = await syncBgListRemote(sync, proxyUrl);
1877
+ if (remote.error) return remote;
1878
+ const localFiles = listBgFiles(dataRoot);
1879
+ const localByName = new Map(localFiles.map((f) => [f.name, f]));
1880
+ const dir = backgroundsDir(dataRoot);
1881
+ mkdirSync(dir, { recursive: true });
1882
+ const downloaded = [];
1883
+ for (const f of remote.files) {
1884
+ const name = sanitizeBgName(f.name);
1885
+ if (!name) continue; // 云端的异常文件名条目不镜像
1886
+ const l = localByName.get(name);
1887
+ if (l && l.size === f.size) continue;
1888
+ const tmp = join(dir, name + '.tmp');
1889
+ const get = await webdavRequest({ method: 'GET', url: syncBgFileUrl(sync, name), login: sync.login, password: sync.password, proxyUrl, timeoutMs: 120000, downloadFile: tmp });
1890
+ const bad = !get.ok || get.httpCode < 200 || get.httpCode >= 300;
1891
+ if (bad) {
1892
+ try { if (existsSync(tmp)) unlinkSync(tmp); } catch { /* ignore */ }
1893
+ return { code: 'sync-request', status: 502, error: get.ok ? webdavHttpText(get.httpCode) : get.error };
1894
+ }
1895
+ renameSync(tmp, join(dir, name));
1896
+ downloaded.push(name);
1897
+ }
1898
+ const deleted = [];
1899
+ const remoteNames = new Set(remote.files.map((f) => f.name));
1900
+ for (const f of localFiles) {
1901
+ if (remoteNames.has(f.name)) continue;
1902
+ try { unlinkSync(join(dir, f.name)); deleted.push(f.name); } catch { /* ignore */ }
1903
+ }
1904
+ return { downloaded, deleted, count: remote.files.length };
1905
+ }
1906
+
1502
1907
  // 信封校验:云端文件必须是 { app: 'zcode-pro', kind, format, data } 且组别与文件名
1503
1908
  // 前缀一致;4MB 上限挡住异常大文件
1504
1909
  function parseSyncEnvelope(body, kind, label) {
@@ -1615,7 +2020,14 @@ async function runSyncPush({ dataRoot, configFile, config, sync, proxyUrl }) {
1615
2020
  saveConfig(configFile, config);
1616
2021
  // 上传成功后清理超出保留个数的旧历史版本(best-effort,失败不影响上传结果)
1617
2022
  const pruned = await pruneSyncHistory(sync, proxyUrl, keep);
1618
- return { applied: pushed, pruned };
2023
+ // 背景图库镜像上传(勾了 zcodepro 组才传;设置文档已传走,图库失败单独报错,重试幂等)
2024
+ let bg = null;
2025
+ if (sync.include.zcodepro) {
2026
+ const r = await syncBgPush({ sync, proxyUrl, dataRoot });
2027
+ if (r.error) return r;
2028
+ bg = r;
2029
+ }
2030
+ return { applied: pushed, pruned, bg };
1619
2031
  }
1620
2032
 
1621
2033
  // 下载:把云端所选组应用到本机(各组独立文件,勾选几组读几组,可只覆盖模型设置)。
@@ -1635,7 +2047,16 @@ async function runSyncPull({ dataRoot, configFile, config, sync, proxyUrl }) {
1635
2047
  return { code: 'sync-remote-missing', status: 404, error: '云端没有同步数据,请先在其他电脑上传' };
1636
2048
  }
1637
2049
  sync.lastPullAt = Date.now();
1638
- return applySyncDocs({ docs, dataRoot, configFile, config, sync });
2050
+ const applied = await applySyncDocs({ docs, dataRoot, configFile, config, sync });
2051
+ if (applied && applied.error) return applied;
2052
+ // 背景图库镜像下载(勾了 zcodepro 组才拉;设置文档已应用,图库失败单独报错,重试幂等)
2053
+ let bg = null;
2054
+ if (sync.include.zcodepro) {
2055
+ const r = await syncBgPull({ sync, proxyUrl, dataRoot });
2056
+ if (r.error) return r;
2057
+ bg = r;
2058
+ }
2059
+ return { ...applied, bg };
1639
2060
  }
1640
2061
 
1641
2062
  // 把云端读到的各组数据落到本机(runSyncPull 与按文件的历史版本导入共用):
@@ -2950,11 +3371,25 @@ function findBinFile(names, extraCandidates) {
2950
3371
  }
2951
3372
 
2952
3373
  // 执行 --version 并提取首个版本号(原样输出如 "rtk 0.49.0"、"headroom, version 0.37.0")
3374
+ // 二进制版本缓存(路径+mtime+size):--version 要 spawn 进程(headroom 的
3375
+ // Python CLI 冷启约 600ms),版本只在二进制被升级(mtime/size 变化)时才可能变
3376
+ const binVersionCache = new Map();
2953
3377
  async function binVersionOf(bin) {
3378
+ let key = null;
3379
+ try {
3380
+ const st = statSync(bin);
3381
+ key = bin + ':' + st.mtimeMs + ':' + st.size;
3382
+ } catch { key = null; }
3383
+ if (key && binVersionCache.has(key)) return binVersionCache.get(key);
2954
3384
  const r = await execCapture(bin, ['--version'], 10000);
2955
- if (!r.ok) return null;
2956
3385
  const m = /(\d+(?:\.\d+){1,3}(?:[A-Za-z0-9.+-]*)?)/.exec(r.output);
2957
- return m ? m[1] : null;
3386
+ const v = r.ok && m ? m[1] : null;
3387
+ if (key) {
3388
+ binVersionCache.set(key, v);
3389
+ // 同一路径的旧键失效(防升级后多代堆积)
3390
+ for (const k of binVersionCache.keys()) if (k.startsWith(bin + ':') && k !== key) binVersionCache.delete(k);
3391
+ }
3392
+ return v;
2958
3393
  }
2959
3394
 
2960
3395
  // 升级任务快照/创建/停止:任务在 helper 进程内后台执行,面板轮询展示进度