zcode-pro 0.17.2 → 0.19.0

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.
@@ -3,19 +3,20 @@
3
3
  // 仅绑定 127.0.0.1,并通过每次启动随机生成的 token 鉴权。
4
4
 
5
5
  import { createServer } from 'node:http';
6
- import { execFile, spawn } from 'node:child_process';
7
- import { chmodSync, copyFileSync, existsSync, mkdirSync, readdirSync, readFileSync, readlinkSync, realpathSync, renameSync, rmSync, statSync, unlinkSync, writeFileSync } from 'node:fs';
6
+ import { execFile, spawn, spawnSync } from 'node:child_process';
7
+ import { chmodSync, copyFileSync, createWriteStream, existsSync, mkdirSync, readdirSync, readFileSync, readlinkSync, realpathSync, renameSync, rmSync, statSync, unlinkSync, writeFileSync } from 'node:fs';
8
8
  import { mkdtemp } from 'node:fs/promises'; // 注意:fs 的 mkdtemp 是回调版,await 它会把 undefined 当回调
9
9
  import { basename, delimiter, dirname, isAbsolute, join, resolve, sep } from 'node:path';
10
10
  import { homedir, tmpdir } from 'node:os';
11
- import { pathToFileURL } from 'node:url';
11
+ import { fileURLToPath, pathToFileURL } from 'node:url';
12
12
  import { readSettings, writeSettingsAtomic, remapSettingsPaths, isProjectOpenInTabs } from './settings.mjs';
13
- import { taskIndexPath, probeTaskIndexWritable, remapTaskIndexPaths, taskIndexDriverAvailable, listOverriddenTitles, listTaskWorkspaceMap } from './taskIndex.mjs';
13
+ import { taskIndexPath, probeTaskIndexWritable, remapTaskIndexPaths, taskIndexDriverAvailable, listOverriddenTitles, listTaskWorkspaceMap, setTaskTitleIfNotOverridden, getTaskProviderModel } from './taskIndex.mjs';
14
14
  import { pickFolderSystem } from './pickFolder.mjs';
15
15
  import { reorderWorkspaceTasks, reorderGroupMembers } from './taskOrder.mjs';
16
- import { webdavRequest, webdavPutWithMkcol, webdavHttpText } from './webdav.mjs';
16
+ import { webdavRequest, webdavPutWithMkcol, webdavHttpText, parseWebdavListing, findCurl } from './webdav.mjs';
17
+ import { resolveZcodeExecutable } from './paths.mjs';
17
18
 
18
- const VERSION = '0.17.2';
19
+ const VERSION = '0.19.0';
19
20
 
20
21
  // 全局提示词固定在用户主目录:官方加载器按 HOME/USERPROFILE 拼 .zcode/AGENTS.md,
21
22
  // 不读 ZCODE_DATA_BASE_DIR(数据根迁走时全局指令仍在原位)。
@@ -38,8 +39,10 @@ function defaultHeadroomFile() {
38
39
  return join(homedir(), '.zcode', 'headroom.json');
39
40
  }
40
41
 
41
- // 本插件市场(zcode-plugins 仓库)在 ZCode CLI 数据目录里的落点
42
+ // 本插件市场(zcode-plugins 仓库)在 ZCode CLI 数据目录里的落点。
43
+ // 市场未添加时(新机器)从这里自动补装,免得“安装”按钮因市场缺失永远失败。
42
44
  const ZCODE_PLUGINS_MARKETPLACE = 'duanluan-zcode-plugins';
45
+ const ZCODE_PLUGINS_MARKETPLACE_SOURCE = 'duanluan/zcode-plugins';
43
46
 
44
47
  function cliPluginsDir() {
45
48
  return join(homedir(), '.zcode', 'cli', 'plugins');
@@ -74,6 +77,7 @@ export function defaultConfig() {
74
77
  listItemSpacing: null, // 列表项之间的间距(应用默认 6px)
75
78
  quoteCodeSpacing: null, // 引用/代码块上下留白(应用默认 16px,my-4)
76
79
  lineHeight: null, // 回答行高,倍数(应用默认 1.75)
80
+ thinkingLineHeight: null, // 思考行高,倍数(应用默认 1.5)
77
81
  codeLineHeight: null, // 代码块行高,倍数(应用默认 12px 字号 × 20px 行盒,约 1.65)
78
82
  tableSpacing: null, // 表格上下边距(未设置时跟随段落间距)
79
83
  tableCellPaddingV: null, // 单元格上下边距(应用默认 3px)
@@ -88,9 +92,35 @@ export function defaultConfig() {
88
92
  },
89
93
  // 项目路径(规范化,无尾分隔符)→ 自定义别名。只影响界面渲染,不改动任何真实数据。
90
94
  aliases: {},
95
+ // headroom 启用开关的记忆(供应商 id → { proxy, direct }):关闭时把走 headroom 的
96
+ // 供应商地址切回直连并记录下来,再次开启时按记录把地址切回代理
97
+ headroomRoutes: {},
98
+ // 任务名提示词(自动起名):新会话开始作答后按可配置的供应商与提示词生成
99
+ // 标题,并按“手动重命名”写入任务索引。默认关闭。provider:
100
+ // 'session' = 跟随当前会话(调用时从任务索引解析该会话的供应商,默认)、
101
+ // '' = 不跟随(自填 baseUrl/apiKey/format)、其余 = 供应商 id(保存时同步
102
+ // 解析一份 baseUrl/apiKey/format 副本,供应商日后被删仍可直连);
103
+ // model 默认 glm-5.3-flash(起名用快模型即可),跟随会话时可留空
104
+ //(用会话当前模型);prompt 留空 = 内置默认提示词
105
+ titleGen: {
106
+ enabled: false,
107
+ provider: 'session',
108
+ model: 'glm-5.3-flash',
109
+ prompt: '',
110
+ baseUrl: '',
111
+ apiKey: '',
112
+ format: '',
113
+ },
91
114
  // HTTP 代理(http(s)://host:port):helper 发起的网络访问走它——插件市场更新/安装
92
- // (zcode CLI → git)、headroom 本体的检查更新与升级(pip)。空 = 不用代理。
115
+ // (zcode CLI → git)、headroom/rtk 的安装与升级(pip/GitHub)。空 = 不用代理。
93
116
  proxy: '',
117
+ // 代理生效范围(按用途开关):plugins = 插件市场更新/安装/卸载;deps = 插件依赖
118
+ // 软件(headroom 与 rtk)的安装、检查更新与升级;sync = WebDAV 同步。同步默认
119
+ // 关闭——坚果云等国内网盘通常直连即可,代理停了同步会跟着失败
120
+ proxyScopes: { plugins: true, deps: true, sync: false },
121
+ // 开发模式(设置弹窗“调试”标签页):开启后出现「重载」按钮,可在不重启
122
+ // ZCode 的情况下加载新代码(构建 → 换新启动器进程 → 各窗口自动刷新重注入)
123
+ devMode: false,
94
124
  // 设置同步(设置弹窗“同步”标签页,交互参考 Tampermonkey 的手动同步):
95
125
  // 仅 WebDAV、仅在点击按钮时传输(type 字段预留多后端扩展,当前恒为 webdav)。
96
126
  // url 为 WebDAV 服务器地址;dir 为其下的上传目录(默认 zcode-pro,避免直接
@@ -126,6 +156,13 @@ export function loadConfig(configFile) {
126
156
  features: { ...defaultConfig().features, ...(saved.features || {}) },
127
157
  styles: { ...defaultConfig().styles, ...(saved.styles && typeof saved.styles === 'object' ? saved.styles : {}) },
128
158
  aliases: { ...((saved.aliases && typeof saved.aliases === 'object') ? saved.aliases : {}) },
159
+ headroomRoutes: { ...((saved.headroomRoutes && typeof saved.headroomRoutes === 'object' && !Array.isArray(saved.headroomRoutes)) ? saved.headroomRoutes : {}) },
160
+ titleGen: mergeTitleGenConfig(saved.titleGen),
161
+ proxyScopes: mergeProxyScopes(saved.proxyScopes),
162
+ devMode: saved.devMode === true,
163
+ // devActive 每次读取时重算(保存时会随配置一起写入文件,但读取方不用保存的值):
164
+ // 开发模式生效 = 配置开关或启动器带 ZCODEPRO_DEV=1,设置弹窗据此决定是否显示“调试”标签页(普通用户不可见)
165
+ devActive: saved.devMode === true || process.env.ZCODEPRO_DEV === '1',
129
166
  sync: mergeSyncConfig(saved.sync),
130
167
  };
131
168
  } catch {
@@ -186,12 +223,16 @@ function readBody(req) {
186
223
  });
187
224
  }
188
225
 
189
- // 软件代理(设置弹窗“代理”标签页):helper 发起的网络访问(zcode CLI→git、pip)
190
- // 统一走这里;startHelper 启动时读配置,保存代理时即时更新
226
+ // 软件代理(设置弹窗“代理”标签页):helper 发起的网络访问(zcode CLI→git、pip、
227
+ // WebDAV 同步)按用途(proxyScopes)走这里;startHelper 启动时读配置,
228
+ // 保存代理地址或范围开关时即时更新
191
229
  let activeProxyUrl = '';
230
+ let activeProxyScopes = { plugins: true, deps: true, sync: false };
192
231
 
193
- function proxyEnv(extra = {}) {
194
- if (!activeProxyUrl) return extra;
232
+ // scope:'plugins'(插件市场)/ 'deps'(headroom、rtk)/ 'sync'(WebDAV 同步);
233
+ // 'none' 表示这次调用与下载无关(headroom 生命周期钩子等),永不注入下载代理
234
+ function proxyEnv(extra = {}, scope = 'deps') {
235
+ if (scope === 'none' || !activeProxyUrl || !activeProxyScopes[scope]) return extra;
195
236
  return {
196
237
  HTTP_PROXY: activeProxyUrl, HTTPS_PROXY: activeProxyUrl,
197
238
  http_proxy: activeProxyUrl, https_proxy: activeProxyUrl,
@@ -199,11 +240,19 @@ function proxyEnv(extra = {}) {
199
240
  };
200
241
  }
201
242
 
243
+ // 该用途当前是否走代理:同步按 proxyUrl 参数传给 webdav(curl 路径用,
244
+ // fetch 兜底路径不认代理环境变量)
245
+ function proxyUrlFor(scope) {
246
+ return activeProxyScopes[scope] ? activeProxyUrl : '';
247
+ }
248
+
202
249
  export function startHelper({ port, token, dataRoot, state, agentsFile = defaultAgentsFile() }) {
203
250
  const configFile = join(dataRoot, 'zcodepro.json');
204
251
  const settingsFile = join(dataRoot, 'v2', 'setting.json');
205
252
  migrateConfigOnce(configFile);
206
- activeProxyUrl = String(loadConfig(configFile).proxy || '').trim();
253
+ const bootConfig = loadConfig(configFile);
254
+ activeProxyUrl = String(bootConfig.proxy || '').trim();
255
+ activeProxyScopes = { ...bootConfig.proxyScopes };
207
256
 
208
257
  const server = createServer(async (req, res) => {
209
258
  const url = new URL(req.url, `http://127.0.0.1:${port}`);
@@ -245,6 +294,16 @@ export function startHelper({ port, token, dataRoot, state, agentsFile = default
245
294
  }
246
295
  activeProxyUrl = current.proxy;
247
296
  }
297
+ if (body && typeof body === 'object' && body.proxyScopes && typeof body.proxyScopes === 'object') {
298
+ // 代理生效范围按单项提交(每个开关只改自己那项),即时生效
299
+ for (const k of ['plugins', 'deps', 'sync']) {
300
+ if (typeof body.proxyScopes[k] === 'boolean') current.proxyScopes[k] = body.proxyScopes[k];
301
+ }
302
+ activeProxyScopes = { ...current.proxyScopes };
303
+ }
304
+ if (body && typeof body === 'object' && typeof body.devMode === 'boolean') {
305
+ current.devMode = body.devMode;
306
+ }
248
307
  if (body && typeof body === 'object' && body.features && typeof body.features === 'object') {
249
308
  applyFeaturesPartial(current.features, body.features);
250
309
  }
@@ -257,6 +316,9 @@ export function startHelper({ port, token, dataRoot, state, agentsFile = default
257
316
  if (body && typeof body === 'object' && body.styles && typeof body.styles === 'object') {
258
317
  current.styles = applyStylesPartial(current.styles, body.styles);
259
318
  }
319
+ if (body && typeof body === 'object' && body.titleGen && typeof body.titleGen === 'object') {
320
+ current.titleGen = mergeTitleGenConfig({ ...current.titleGen, ...body.titleGen });
321
+ }
260
322
  saveConfig(configFile, current);
261
323
  json(res, 200, { ok: true, config: current });
262
324
  return;
@@ -365,6 +427,89 @@ export function startHelper({ port, token, dataRoot, state, agentsFile = default
365
427
  json(res, 200, { ok: true, enabled: config.features.titleLock !== false, titles: r.titles });
366
428
  return;
367
429
  }
430
+ // —— 智能起名:配置读写 + 供应商列表 + 生成并写库 ——
431
+ if (req.method === 'GET' && url.pathname === '/title-gen') {
432
+ json(res, 200, { ok: true, titleGen: loadConfig(configFile).titleGen, defaultPrompt: titleGenDefaultPrompt() });
433
+ return;
434
+ }
435
+ if (req.method === 'POST' && url.pathname === '/title-gen') {
436
+ const body = await readBody(req);
437
+ if (body && typeof body === 'object' && body.titleGen && typeof body.titleGen === 'object') {
438
+ const current = loadConfig(configFile);
439
+ const next = { ...current.titleGen, ...body.titleGen };
440
+ // 选了列表内供应商:同步解析并保存其地址/密钥/协议副本——供应商日后
441
+ // 被删,这份副本仍可直连(界面标注“已删除”并展开字段);
442
+ // 跟随会话不存副本(每次调用时按该会话实时解析)
443
+ const providerId = (next.provider || '').trim();
444
+ if (providerId && providerId !== 'session') {
445
+ const found = listTitleGenProviders().find((p) => p.id === providerId);
446
+ if (found) {
447
+ next.baseUrl = found.baseUrl;
448
+ next.apiKey = found.apiKey;
449
+ next.format = found.format;
450
+ }
451
+ // 找不到(手输 id 或已删):保留原有副本字段不动
452
+ } else if (providerId === 'session') {
453
+ // 跟随会话不存副本(每次调用时按该会话实时解析)
454
+ next.baseUrl = '';
455
+ next.apiKey = '';
456
+ next.format = '';
457
+ }
458
+ current.titleGen = mergeTitleGenConfig(next);
459
+ saveConfig(configFile, current);
460
+ json(res, 200, { ok: true, titleGen: current.titleGen });
461
+ return;
462
+ }
463
+ json(res, 400, { ok: false, code: 'invalid-request', error: 'body 需包含 titleGen 对象' });
464
+ return;
465
+ }
466
+ // 供应商下拉数据:与视觉代理同源同规则,但只列 helper 能在会话外直接
467
+ // 调用的条目(有地址、密钥且协议可判定),并带可选模型列表;不回密钥
468
+ if (req.method === 'GET' && url.pathname === '/title-gen/providers') {
469
+ json(res, 200, {
470
+ ok: true,
471
+ providers: listTitleGenProviders().map(({ apiKey, ...rest }) => rest),
472
+ });
473
+ return;
474
+ }
475
+ // 生成(并可选写库):text = 会话首条消息;sessId 指定且非 test 时按
476
+ // “手动重命名”写入任务索引(已重命名的会话不会被动);test = 设置面板
477
+ // 的测试按钮,只生成不写库
478
+ if (req.method === 'POST' && url.pathname === '/title-gen/run') {
479
+ const body = await readBody(req);
480
+ const text = typeof body?.text === 'string' ? body.text : '';
481
+ if (!text.trim()) {
482
+ json(res, 400, { ok: false, code: 'invalid-request', error: 'text 不能为空' });
483
+ return;
484
+ }
485
+ const cfg = loadConfig(configFile).titleGen;
486
+ if (cfg.enabled !== true && body.test !== true) {
487
+ json(res, 400, { ok: false, code: 'title-gen-disabled', error: '智能起名未开启' });
488
+ return;
489
+ }
490
+ const sessId = typeof body?.sessId === 'string' && /^sess_[A-Za-z0-9._-]+$/.test(body.sessId) ? body.sessId : '';
491
+ const title = await generateTitleWith(cfg, text, dataRoot, sessId);
492
+ let written = false;
493
+ if (sessId && body.test !== true) {
494
+ // 写库撞上宿主写入时稍候重试(起名不急这几秒)
495
+ for (let attempt = 0; ; attempt++) {
496
+ try {
497
+ const r = await setTaskTitleIfNotOverridden(dataRoot, sessId, title);
498
+ written = r.written;
499
+ break;
500
+ } catch (err) {
501
+ if (err.code === 'index-busy' && attempt < 2) {
502
+ await new Promise((r) => setTimeout(r, 1200));
503
+ continue;
504
+ }
505
+ json(res, 500, { ok: false, code: err.code || 'index-error', error: err.message });
506
+ return;
507
+ }
508
+ }
509
+ }
510
+ json(res, 200, { ok: true, title, written });
511
+ return;
512
+ }
368
513
  // —— zcode-vision 插件(图片视觉代理):设置弹窗“视觉代理”标签页读写 ————
369
514
  if (req.method === 'GET' && url.pathname === '/vision') {
370
515
  const file = defaultVisionFile();
@@ -373,7 +518,7 @@ export function startHelper({ port, token, dataRoot, state, agentsFile = default
373
518
  json(res, 500, { ok: false, code: 'vision-read', error: 'zcode-vision.json 不是合法 JSON' });
374
519
  return;
375
520
  }
376
- json(res, 200, { ok: true, config: parsed, file });
521
+ json(res, 200, { ok: true, config: parsed, file, hook: findVisionHook(), pluginVersion: installedPluginVersion('zcode-vision') });
377
522
  return;
378
523
  }
379
524
  if (req.method === 'POST' && url.pathname === '/vision') {
@@ -421,10 +566,14 @@ export function startHelper({ port, token, dataRoot, state, agentsFile = default
421
566
  const dir = defaultRtkDir();
422
567
  const mode = readRtkMode(join(dir, 'mode'));
423
568
  const info = await rtkBinaryInfo();
569
+ // 已装插件探测:新版(≥1.3.0)钩子入口是跨平台 rtk-pretooluse.mjs,旧版是 .sh
570
+ const hook = findMarketplaceHook('rtk', join('hooks', 'rtk-pretooluse.mjs'))
571
+ || findMarketplaceHook('rtk', join('hooks', 'rtk-pretooluse.sh'));
424
572
  // rtkBinaryInfo 走 PATH 命中时 binPath 是命令名;换绝对路径用于面板展示
425
573
  json(res, 200, {
426
574
  ok: true, mode, whitelist: readRtkWhitelist(join(dir, 'whitelist')),
427
- dir, ...readRtkBuiltinWhitelist(), ...info,
575
+ dir, hook, pluginVersion: installedPluginVersion('rtk'),
576
+ ...readRtkBuiltinWhitelist(), ...info,
428
577
  binPath: info.binPath ? (rtkFindBin() || info.binPath) : null,
429
578
  });
430
579
  return;
@@ -498,12 +647,15 @@ export function startHelper({ port, token, dataRoot, state, agentsFile = default
498
647
  json(res, 500, { ok: false, code: 'headroom-read', error: '读取 headroom.json 失败: ' + (err?.message || err) });
499
648
  return;
500
649
  }
501
- const hook = findMarketplaceHook('headroom', join('hooks', 'ensure-proxy.sh'));
650
+ // 已安装探测:新版(≥1.2.0)钩子入口是跨平台 ensure-proxy.mjs,旧版是 ensure-proxy.sh
651
+ const hook = findMarketplaceHook('headroom', join('hooks', 'ensure-proxy.mjs'))
652
+ || findMarketplaceHook('headroom', join('hooks', 'ensure-proxy.sh'));
502
653
  // 插件版本取自缓存目录名(…/headroom/1.0.2/hooks/…)
503
654
  const vm = hook ? /[/\\]headroom[/\\](\d+(?:\.\d+){0,3})[/\\]/.exec(hook) : null;
504
655
  json(res, 200, {
505
656
  ok: true, config, file, hook,
506
657
  pluginVersion: vm ? vm[1] : null,
658
+ usage: headroomUsageScan(dataRoot),
507
659
  ...(await headroomBinaryInfo()),
508
660
  });
509
661
  return;
@@ -563,6 +715,15 @@ export function startHelper({ port, token, dataRoot, state, agentsFile = default
563
715
  return;
564
716
  }
565
717
  output = r.output;
718
+ } else if (body && typeof body.usage === 'boolean') {
719
+ // 启用开关:只改 ZCode 供应商配置里的地址(走/不走本地压缩代理),不动代理进程
720
+ const r = setHeadroomUsage(dataRoot, configFile, body.usage);
721
+ if (!r.ok) {
722
+ json(res, 500, { ok: false, code: r.code, error: r.error });
723
+ return;
724
+ }
725
+ json(res, 200, { ok: true, usage: headroomUsageScan(dataRoot), changed: r.changed });
726
+ return;
566
727
  } else if (body && body.cancelUpgrade === true) {
567
728
  json(res, 200, { ok: true, upgrade: cancelHeadroomUpgrade() });
568
729
  return;
@@ -598,7 +759,7 @@ export function startHelper({ port, token, dataRoot, state, agentsFile = default
598
759
  return;
599
760
  }
600
761
  } else {
601
- json(res, 400, { ok: false, code: 'invalid-request', error: 'body 需包含 action/backend/power/interval 之一' });
762
+ json(res, 400, { ok: false, code: 'invalid-request', error: 'body 需包含 action/backend/power/usage/interval 之一' });
602
763
  return;
603
764
  }
604
765
  let config = null;
@@ -606,6 +767,41 @@ export function startHelper({ port, token, dataRoot, state, agentsFile = default
606
767
  json(res, 200, { ok: true, output, config });
607
768
  return;
608
769
  }
770
+ // —— 开发模式重载(设置弹窗“调试”标签页):不重启 ZCode 换新代码 ——
771
+ if (req.method === 'POST' && url.pathname === '/dev/reload') {
772
+ if (loadConfig(configFile).devMode !== true && process.env.ZCODEPRO_DEV !== '1') {
773
+ json(res, 404, { ok: false, error: 'not found' });
774
+ return;
775
+ }
776
+ // 源码目录(有 package.json 与 esbuild)先构建,安装副本没有构建环境则跳过
777
+ let built = false;
778
+ const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..', '..');
779
+ const esbuildBin = [join(repoRoot, 'node_modules', 'esbuild', 'bin', 'esbuild'),
780
+ join(repoRoot, 'node_modules', 'esbuild', 'bin', 'esbuild.exe')]
781
+ .find((p) => existsSync(p)) || '';
782
+ if (esbuildBin) {
783
+ try {
784
+ const b = spawnSync(esbuildBin, ['src/inject/index.js', '--bundle',
785
+ '--format=iife', '--target=chrome120', '--outfile=dist/inject.js'],
786
+ { cwd: repoRoot, timeout: 60000, encoding: 'utf8', windowsHide: true });
787
+ built = !b.error && b.status === 0;
788
+ } catch { /* 构建失败也继续交接(可能只改了 host 端代码) */ }
789
+ }
790
+ json(res, 200, { ok: true, built });
791
+ // 先应答再交接:拉起新的启动器进程(同一入口与参数,读新构建的 inject.js
792
+ // 与新 host 代码),本进程随后退出。页面刷新由注入脚本的换代守卫触发
793
+ // (见 src/inject/index.js);新进程的 helper 端口占用靠监听重试等待
794
+ setTimeout(() => {
795
+ try {
796
+ const child = spawn(process.execPath, process.argv.slice(1), {
797
+ detached: true, stdio: 'ignore', windowsHide: true, env: process.env,
798
+ });
799
+ child.unref();
800
+ } catch { /* 拉不起来就只能等下次手动重启 */ }
801
+ setTimeout(() => process.exit(0), 250);
802
+ }, 150);
803
+ return;
804
+ }
609
805
  // —— 软件代理连通性检测:经 curl -x 走代理访问插件市场/本体升级实际要用的站点 ——
610
806
  if (req.method === 'POST' && url.pathname === '/proxy/test') {
611
807
  const body = await readBody(req);
@@ -639,11 +835,21 @@ export function startHelper({ port, token, dataRoot, state, agentsFile = default
639
835
  ['GitHub', 'https://github.com'],
640
836
  ['PyPI', pypiTarget],
641
837
  ];
838
+ // Windows 精简环境 PATH 可能不含 curl:与同步功能共用 findCurl(补测 System32)
839
+ const curlBin = findCurl();
642
840
  const results = [];
643
841
  for (const [name, targetUrl] of targets) {
644
842
  results.push(await new Promise((resolveT) => {
645
843
  const started = Date.now();
646
- execFile('curl', ['-s', '-o', '/dev/null', '-w', '%{http_code}', '--max-time', '8', '-x', proxy, targetUrl],
844
+ if (!curlBin) {
845
+ resolveT({ name, httpCode: null, ms: null, ok: false, error: '未找到 curl 命令' });
846
+ return;
847
+ }
848
+ // -I 只取响应头:连通性检测不必下载整页(github 主页数百 KB,
849
+ // 经慢代理节点 8 秒必然超时误报)。-o 丢弃输出用各平台的空设备
850
+ // (Windows 上 /dev/null 是普通路径且目录不存在,必须用 NUL)
851
+ const nullDev = process.platform === 'win32' ? 'NUL' : '/dev/null';
852
+ execFile(curlBin, ['-sI', '-o', nullDev, '-w', '%{http_code}', '--max-time', '8', '-x', proxy, targetUrl],
647
853
  { timeout: 10000, windowsHide: true }, (err, stdout) => {
648
854
  if (err && err.code === 'ENOENT') {
649
855
  resolveT({ name, httpCode: null, ms: null, ok: false, error: '未找到 curl' });
@@ -699,7 +905,7 @@ export function startHelper({ port, token, dataRoot, state, agentsFile = default
699
905
  return;
700
906
  }
701
907
  const config = loadConfig(configFile);
702
- const r = await runSettingsSync({ direction, dataRoot, configFile, config, proxyUrl: activeProxyUrl });
908
+ const r = await runSettingsSync({ direction, dataRoot, configFile, config, proxyUrl: proxyUrlFor('sync') });
703
909
  if (r.error) {
704
910
  json(res, r.status || 500, { ok: false, code: r.code || 'sync-run', error: r.error });
705
911
  return;
@@ -707,8 +913,132 @@ export function startHelper({ port, token, dataRoot, state, agentsFile = default
707
913
  json(res, 200, { ok: true, direction, applied: r.applied, sync: loadConfig(configFile).sync });
708
914
  return;
709
915
  }
916
+ // —— 同步连通性测试(“测试”按钮):用表单当前值逐步验证(可先测再存),不写入配置文件 ——
917
+ if (req.method === 'POST' && url.pathname === '/sync/test') {
918
+ const body = await readBody(req);
919
+ const v = validateSyncForm(body?.sync || {});
920
+ if (v.error) {
921
+ json(res, 400, { ok: false, code: 'sync-invalid', error: v.error });
922
+ return;
923
+ }
924
+ // dir 缺省时沿用已保存的值
925
+ const sync = { ...loadConfig(configFile).sync, ...v.sync };
926
+ const r = await runSyncTest({ sync, proxyUrl: proxyUrlFor('sync') });
927
+ if (r.error) {
928
+ json(res, r.status || 500, { ok: false, code: r.code || 'sync-test', error: r.error });
929
+ return;
930
+ }
931
+ json(res, 200, { ok: true, targets: r.targets, fileCount: r.fileCount });
932
+ return;
933
+ }
934
+ // —— 云端版本列表(“从云端下载”先弹文件选择,任选一个版本恢复)——
935
+ if (req.method === 'POST' && url.pathname === '/sync/versions') {
936
+ const config = loadConfig(configFile);
937
+ const sync = config.sync;
938
+ if (!sync.url || !/^https?:\/\/\S+$/.test(sync.url)) {
939
+ json(res, 400, { ok: false, code: 'sync-not-configured', error: '请先填写 WebDAV URL 并保存' });
940
+ return;
941
+ }
942
+ const r = await listSyncVersions(sync, proxyUrlFor('sync'));
943
+ if (r.error) {
944
+ json(res, r.status || 500, { ok: false, code: r.code || 'sync-request', error: r.error });
945
+ return;
946
+ }
947
+ json(res, 200, { ok: true, files: r.files });
948
+ return;
949
+ }
950
+ // —— 按文件操作(文件名必须整串匹配 SYNC_VERSION_RE):apply 导入 / delete 删除 / export 保存到磁盘 ——
951
+ if (req.method === 'POST' && url.pathname.startsWith('/sync/version/')) {
952
+ const action = url.pathname.slice('/sync/version/'.length);
953
+ if (!['apply', 'delete', 'export'].includes(action)) {
954
+ json(res, 404, { ok: false, error: 'not found' });
955
+ return;
956
+ }
957
+ const body = await readBody(req);
958
+ const file = typeof body?.file === 'string' ? body.file : '';
959
+ if (!SYNC_VERSION_RE.test(file)) {
960
+ json(res, 400, { ok: false, code: 'sync-file-invalid', error: '文件名不合法(只能是同步目录里的 zcode-pro 备份文件)' });
961
+ return;
962
+ }
963
+ const config = loadConfig(configFile);
964
+ const sync = config.sync;
965
+ if (!sync.url || !/^https?:\/\/\S+$/.test(sync.url)) {
966
+ json(res, 400, { ok: false, code: 'sync-not-configured', error: '请先填写 WebDAV URL 并保存' });
967
+ return;
968
+ }
969
+ const remoteMissing = () => json(res, 404, { ok: false, code: 'sync-remote-missing', error: `云端文件 ${file} 不存在(可能已被删除)` });
970
+ if (action === 'apply') {
971
+ const remote = await fetchSyncNamedDoc(sync, proxyUrlFor('sync'), file);
972
+ if (remote.error) {
973
+ json(res, remote.status || 500, { ok: false, code: remote.code, error: remote.error });
974
+ return;
975
+ }
976
+ if (remote.missing) {
977
+ remoteMissing();
978
+ return;
979
+ }
980
+ const kind = file.startsWith('model') ? 'model' : 'zcodepro';
981
+ sync.lastPullAt = Date.now();
982
+ const r = applySyncDocs({ docs: { [kind]: remote.doc.data }, dataRoot, configFile, config, sync });
983
+ json(res, 200, { ok: true, applied: r.applied, sync: loadConfig(configFile).sync });
984
+ return;
985
+ }
986
+ if (action === 'delete') {
987
+ const r = await webdavRequest({ method: 'DELETE', url: syncFileUrlByName(sync, file), login: sync.login, password: sync.password, proxyUrl: proxyUrlFor('sync'), timeoutMs: 30000 });
988
+ if (!r.ok) {
989
+ json(res, 502, { ok: false, code: 'sync-request', error: r.error });
990
+ return;
991
+ }
992
+ if (r.httpCode === 404) {
993
+ remoteMissing();
994
+ return;
995
+ }
996
+ if (r.httpCode < 200 || r.httpCode >= 300) {
997
+ json(res, 502, { ok: false, code: 'sync-request', error: webdavHttpText(r.httpCode) });
998
+ return;
999
+ }
1000
+ json(res, 200, { ok: true });
1001
+ return;
1002
+ }
1003
+ // export:把云端文件原样保存到本机(不做信封校验,取回什么存什么)
1004
+ const r = await webdavRequest({ method: 'GET', url: syncFileUrlByName(sync, file), login: sync.login, password: sync.password, proxyUrl: proxyUrlFor('sync'), timeoutMs: 60000 });
1005
+ if (!r.ok) {
1006
+ json(res, 502, { ok: false, code: 'sync-request', error: r.error });
1007
+ return;
1008
+ }
1009
+ if (r.httpCode === 404 || r.httpCode === 409) {
1010
+ remoteMissing();
1011
+ return;
1012
+ }
1013
+ if (r.httpCode < 200 || r.httpCode >= 300) {
1014
+ json(res, 502, { ok: false, code: 'sync-request', error: webdavHttpText(r.httpCode) });
1015
+ return;
1016
+ }
1017
+ if (r.body.length > 8 * 1024 * 1024) {
1018
+ json(res, 400, { ok: false, code: 'sync-remote-invalid', error: `云端 ${file} 超过 8MB,已拒绝保存` });
1019
+ return;
1020
+ }
1021
+ // 系统目录选择器选保存位置;环境没有选择器时退回用户主目录,用户取消则中止
1022
+ const picked = await pickFolderSystem(homedir(), '选择保存位置');
1023
+ let target = '';
1024
+ if (picked && typeof picked.path === 'string' && picked.path) target = join(picked.path, file);
1025
+ else if (picked && picked.unavailable) target = join(homedir(), file);
1026
+ else {
1027
+ json(res, 200, { ok: false, code: 'export-cancelled', error: '已取消' });
1028
+ return;
1029
+ }
1030
+ try {
1031
+ writeFileSync(target, r.body, 'utf8');
1032
+ } catch (err) {
1033
+ json(res, 500, { ok: false, code: 'sync-export', error: `写入 ${target} 失败: ${err?.message || err}` });
1034
+ return;
1035
+ }
1036
+ json(res, 200, { ok: true, path: target });
1037
+ return;
1038
+ }
710
1039
  // —— zcode-plugins 市场更新:读状态(带 30 分钟节流的市场同步)与执行更新 ————
711
1040
  if (req.method === 'GET' && url.pathname === '/plugins/status') {
1041
+ await ensureZcodePluginsMarketplace();
712
1042
  if (Date.now() - lastMarketplaceSync > 30 * 60 * 1000) {
713
1043
  await runZcodeCli(['plugins', 'marketplace', 'update', ZCODE_PLUGINS_MARKETPLACE], 60000);
714
1044
  lastMarketplaceSync = Date.now();
@@ -726,6 +1056,8 @@ export function startHelper({ port, token, dataRoot, state, agentsFile = default
726
1056
  // name 指定时只处理该插件:已装且有更新则更新,未装则安装(各插件面板的单插件操作)
727
1057
  const only = typeof body?.name === 'string' && body.name.trim() ? body.name.trim() : null;
728
1058
  const steps = [];
1059
+ const added = await ensureZcodePluginsMarketplace();
1060
+ if (added) steps.push({ step: 'marketplace add', ok: added.ok, output: added.output || added.error });
729
1061
  const sync = await runZcodeCli(['plugins', 'marketplace', 'update', ZCODE_PLUGINS_MARKETPLACE], 60000);
730
1062
  lastMarketplaceSync = Date.now();
731
1063
  steps.push({ step: 'marketplace update', ok: sync.ok, output: sync.output });
@@ -742,7 +1074,10 @@ export function startHelper({ port, token, dataRoot, state, agentsFile = default
742
1074
  const r = await runZcodeCli(['plugins', 'update', `${p.name}@${ZCODE_PLUGINS_MARKETPLACE}`], 120000);
743
1075
  steps.push({ step: `update ${p.name}`, ok: r.ok, output: r.output });
744
1076
  if (r.ok) updated += 1;
745
- } else if (!p.installed && (body?.installMissing || only)) {
1077
+ } else if (!p.installed && only) {
1078
+ // 安装只走单插件入口(name 指定=各面板的安装卡):
1079
+ // 全量「检查并更新」/启动自动更新只升级已装插件——卸载必须被尊重,
1080
+ // 未装插件一律由用户在各 tab 显式点安装
746
1081
  const r = await runZcodeCli(['plugins', 'install', `${p.name}@${ZCODE_PLUGINS_MARKETPLACE}`], 120000);
747
1082
  steps.push({ step: `install ${p.name}`, ok: r.ok, output: r.output });
748
1083
  if (r.ok) updated += 1;
@@ -760,9 +1095,24 @@ export function startHelper({ port, token, dataRoot, state, agentsFile = default
760
1095
  }
761
1096
  });
762
1097
 
1098
+ // 端口被占时重试:开发重载的交接窗口内,旧进程退出略晚于新进程启动,
1099
+ // 新进程在这里等它让出端口(最多 20 秒),而不是启动失败
763
1100
  return new Promise((resolveStarted, rejectStarted) => {
764
- server.once('error', rejectStarted);
765
- server.listen(port, '127.0.0.1', () => resolveStarted(server));
1101
+ let attempts = 0;
1102
+ const tryListen = () => {
1103
+ attempts += 1;
1104
+ server.removeAllListeners('error');
1105
+ server.once('error', (err) => {
1106
+ if (err && err.code === 'EADDRINUSE' && attempts < 40) {
1107
+ try { server.close(); } catch { /* ignore */ }
1108
+ setTimeout(tryListen, 500);
1109
+ return;
1110
+ }
1111
+ rejectStarted(err);
1112
+ });
1113
+ server.listen(port, '127.0.0.1', () => resolveStarted(server));
1114
+ };
1115
+ tryListen();
766
1116
  });
767
1117
  }
768
1118
 
@@ -785,6 +1135,37 @@ function clampInt(v, min, max, dflt) {
785
1135
 
786
1136
  // —— 设置同步(“同步”标签页)的实现 ——
787
1137
 
1138
+ // loadConfig 时把磁盘上的 titleGen 段按默认值归一(智能起名):只收已知字段,
1139
+ // 字符串限长;enabled 默认关闭(改动官方起名行为,需用户显式开启)
1140
+ function mergeTitleGenConfig(saved) {
1141
+ const d = defaultConfig().titleGen;
1142
+ const s = (saved && typeof saved === 'object' && !Array.isArray(saved)) ? saved : {};
1143
+ // 旧版配置的 'cli'(登录账号单独成模式)统一迁移为 'session':
1144
+ // 登录账号的套餐已在供应商列表里,跟随会话即覆盖原语义
1145
+ const provider = typeof s.provider === 'string' ? s.provider.trim().slice(0, 200) : d.provider;
1146
+ return {
1147
+ enabled: s.enabled === true,
1148
+ provider: provider === 'cli' ? 'session' : provider,
1149
+ model: typeof s.model === 'string' ? s.model.trim().slice(0, 200) : d.model,
1150
+ prompt: typeof s.prompt === 'string' ? s.prompt.slice(0, 20000) : d.prompt,
1151
+ baseUrl: typeof s.baseUrl === 'string' ? s.baseUrl.trim().slice(0, 500) : d.baseUrl,
1152
+ apiKey: typeof s.apiKey === 'string' ? s.apiKey.trim().slice(0, 500) : d.apiKey,
1153
+ format: s.format === 'openai' ? 'openai' : s.format === 'anthropic' ? 'anthropic' : '',
1154
+ };
1155
+ }
1156
+
1157
+ // loadConfig 时把磁盘上的 proxyScopes 按默认值归一(只收布尔,缺的补默认:
1158
+ // 插件市场/依赖软件开、同步关)
1159
+ function mergeProxyScopes(saved) {
1160
+ const d = defaultConfig().proxyScopes;
1161
+ const s = (saved && typeof saved === 'object') ? saved : {};
1162
+ return {
1163
+ plugins: typeof s.plugins === 'boolean' ? s.plugins : d.plugins,
1164
+ deps: typeof s.deps === 'boolean' ? s.deps : d.deps,
1165
+ sync: typeof s.sync === 'boolean' ? s.sync : d.sync,
1166
+ };
1167
+ }
1168
+
788
1169
  // loadConfig 时把磁盘上的 sync 段按默认值归一(长度限幅、时间戳只收数字)。
789
1170
  // 当前唯一后端是 WebDAV,type 一律归为 webdav(不做“禁用”态:同步本就仅手动触发)
790
1171
  function mergeSyncConfig(saved) {
@@ -832,6 +1213,18 @@ function validateSyncForm(raw) {
832
1213
  // 上传/下载按勾选逐文件进行,可单独覆盖其中一项 ——
833
1214
  const SYNC_FILES = { zcodepro: 'zcodepro.json', model: 'model.json' };
834
1215
 
1216
+ // 云端版本文件名的合法形态:固定名(zcodepro.json / model.json,始终是最新一版)
1217
+ // 或组名 + ISO 时间戳(冒号换成 -,部分网盘文件名不允许冒号),如
1218
+ // zcodepro-2025-10-25T14-59-53-169Z.json。按文件操作的端点(导入/删除/导出)
1219
+ // 都先整串匹配该正则,杜绝 ../ 之类的路径注入
1220
+ const SYNC_VERSION_RE = /^(zcodepro|model)(?:-\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}-\d{3}Z)?\.json$/;
1221
+
1222
+ // 历史版本文件名(同一次上传的各组共用同一时间戳,像“一次备份”而不是两个时刻)
1223
+ function syncVersionName(kind, ts) {
1224
+ const iso = ts.toISOString(); // 2025-10-25T14:59:53.169Z
1225
+ return `${kind}-${iso.slice(0, 19).replace(/:/g, '-')}-${iso.slice(20, 23)}Z.json`;
1226
+ }
1227
+
835
1228
  // 云端目录 = URL(服务器地址,容忍尾斜杠)+ 上传目录(默认 zcode-pro,可多级)。
836
1229
  // 目录段逐段编码,中文/空格等文件夹名也能正确落到地址里
837
1230
  function syncBaseDir(sync) {
@@ -846,6 +1239,25 @@ function syncFileUrl(sync, kind) {
846
1239
  return syncBaseDir(sync) + '/' + SYNC_FILES[kind];
847
1240
  }
848
1241
 
1242
+ // 拼出云端目录里某个已校验文件名(固定名或历史版本)的完整地址
1243
+ function syncFileUrlByName(sync, fileName) {
1244
+ return syncBaseDir(sync) + '/' + encodeURIComponent(fileName);
1245
+ }
1246
+
1247
+ // 信封校验:云端文件必须是 { app: 'zcode-pro', kind, format, data } 且组别与文件名
1248
+ // 前缀一致;4MB 上限挡住异常大文件
1249
+ function parseSyncEnvelope(body, kind, label) {
1250
+ if (body.length > 4 * 1024 * 1024) {
1251
+ return { error: `云端 ${label} 超过 4MB,已拒绝应用`, code: 'sync-remote-invalid', status: 400 };
1252
+ }
1253
+ let doc = null;
1254
+ try { doc = JSON.parse(body); } catch { /* 落到统一报错 */ }
1255
+ if (!doc || typeof doc !== 'object' || doc.app !== 'zcode-pro' || doc.kind !== kind || !doc.data || typeof doc.data !== 'object') {
1256
+ return { error: `云端 ${label} 不是 zcode-pro 的同步数据(可能被其他内容占用),请删除该文件或换一个目录`, code: 'sync-remote-invalid', status: 409 };
1257
+ }
1258
+ return { doc };
1259
+ }
1260
+
849
1261
  // 读云端某个同步文件:{ doc } 成功 | { missing } 云端没有该组数据 | { error, code, status } 失败。
850
1262
  // 文件里已是其他内容(或组别不符)时拒绝覆盖;4MB 上限挡住异常大文件。
851
1263
  async function fetchSyncDoc(sync, proxyUrl, kind) {
@@ -856,15 +1268,19 @@ async function fetchSyncDoc(sync, proxyUrl, kind) {
856
1268
  if (r.httpCode === 404 || r.httpCode === 409) return { missing: true };
857
1269
  if (r.httpCode < 200 || r.httpCode >= 300) return { error: webdavHttpText(r.httpCode), code: 'sync-request', status: 502 };
858
1270
  if (!r.body.trim()) return { missing: true }; // 个别网盘对不存在的文件返回 200 空内容
859
- if (r.body.length > 4 * 1024 * 1024) {
860
- return { error: `云端 ${SYNC_FILES[kind]} 超过 4MB,已拒绝应用`, code: 'sync-remote-invalid', status: 400 };
861
- }
862
- let doc = null;
863
- try { doc = JSON.parse(r.body); } catch { /* 落到统一报错 */ }
864
- if (!doc || typeof doc !== 'object' || doc.app !== 'zcode-pro' || doc.kind !== kind || !doc.data || typeof doc.data !== 'object') {
865
- return { error: `云端已存在其他内容的 ${SYNC_FILES[kind]},请换一个目录`, code: 'sync-remote-invalid', status: 409 };
866
- }
867
- return { doc };
1271
+ return parseSyncEnvelope(r.body, kind, SYNC_FILES[kind]);
1272
+ }
1273
+
1274
+ // 读云端某个版本文件(固定名或带时间戳的历史版本,文件名需已过 SYNC_VERSION_RE)。
1275
+ // 返回值约定与 fetchSyncDoc 相同;kind 由文件名前缀决定
1276
+ async function fetchSyncNamedDoc(sync, proxyUrl, fileName) {
1277
+ const kind = fileName.startsWith('model') ? 'model' : 'zcodepro';
1278
+ const r = await webdavRequest({ method: 'GET', url: syncFileUrlByName(sync, fileName), login: sync.login, password: sync.password, proxyUrl });
1279
+ if (!r.ok) return { error: r.error, code: 'sync-request', status: 502 };
1280
+ if (r.httpCode === 404 || r.httpCode === 409) return { missing: true };
1281
+ if (r.httpCode < 200 || r.httpCode >= 300) return { error: webdavHttpText(r.httpCode), code: 'sync-request', status: 502 };
1282
+ if (!r.body.trim()) return { missing: true };
1283
+ return parseSyncEnvelope(r.body, kind, fileName);
868
1284
  }
869
1285
 
870
1286
  async function runSettingsSync({ direction, dataRoot, configFile, config, proxyUrl }) {
@@ -880,8 +1296,10 @@ async function runSettingsSync({ direction, dataRoot, configFile, config, proxyU
880
1296
  : runSyncPull({ dataRoot, configFile, config, sync, proxyUrl });
881
1297
  }
882
1298
 
883
- // 上传:每组独立一个云端文件(zcodepro.json / model.json),勾选几组写几个;
884
- // 写前预读只为确认没顶到别人的文件,自己的文件直接整份覆盖
1299
+ // 上传:每组独立一个云端文件(zcodepro.json / model.json,始终最新一版),勾选几组
1300
+ // 写几个;写前预读云端内容,别名与本机的合并后再写(合并规则见 mergeAliases),
1301
+ // 其余内容整份覆盖。同一份内容再写一个带时间戳的历史版本文件,供下载时任选版本
1302
+ // 恢复(旧版 zcode-pro 仍读固定名)
885
1303
  async function runSyncPush({ dataRoot, configFile, config, sync, proxyUrl }) {
886
1304
  const docs = {};
887
1305
  if (sync.include.zcodepro) {
@@ -904,22 +1322,34 @@ async function runSyncPush({ dataRoot, configFile, config, sync, proxyUrl }) {
904
1322
  return { code: 'sync-run', status: 400, error: '本机没有可同步的内容' };
905
1323
  }
906
1324
  const pushed = [];
1325
+ const stamp = new Date(); // 各组共用同一时间戳,对应“一次备份”
907
1326
  const dir = await mkdtemp(join(tmpdir(), 'zcodepro-sync-'));
908
1327
  try {
909
1328
  for (const kind of Object.keys(docs)) {
910
1329
  const remote = await fetchSyncDoc(sync, proxyUrl, kind);
911
1330
  if (remote.error) return remote;
912
- const doc = { app: 'zcode-pro', kind, format: 1, savedAt: new Date().toISOString(), data: docs[kind] };
1331
+ // 别名先与云端合并:本机没配过的条目从云端带上,别让这次上传把别的机器配的别名顶掉
1332
+ if (kind === 'zcodepro' && remote.doc) {
1333
+ const remoteAliases = sanitizeAliases(remote.doc.data?.aliases);
1334
+ if (remoteAliases) docs.zcodepro.aliases = mergeAliases(docs.zcodepro.aliases, remoteAliases);
1335
+ }
1336
+ const doc = { app: 'zcode-pro', kind, format: 1, savedAt: stamp.toISOString(), data: docs[kind] };
913
1337
  const docPath = join(dir, SYNC_FILES[kind]);
914
1338
  writeFileSync(docPath, JSON.stringify(doc, null, 2), 'utf8');
915
- const put = await webdavPutWithMkcol({
916
- url: syncFileUrl(sync, kind), login: sync.login, password: sync.password,
1339
+ const putUrl = (name) => webdavPutWithMkcol({
1340
+ url: syncFileUrlByName(sync, name), login: sync.login, password: sync.password,
917
1341
  uploadFile: docPath, proxyUrl, timeoutMs: 60000,
918
1342
  });
1343
+ const put = await putUrl(SYNC_FILES[kind]);
919
1344
  if (!put.ok) return { code: 'sync-request', status: 502, error: put.error };
920
1345
  if (put.httpCode < 200 || put.httpCode >= 300) {
921
1346
  return { code: 'sync-request', status: 502, error: webdavHttpText(put.httpCode) };
922
1347
  }
1348
+ const putVersion = await putUrl(syncVersionName(kind, stamp));
1349
+ if (!putVersion.ok) return { code: 'sync-request', status: 502, error: putVersion.error };
1350
+ if (putVersion.httpCode < 200 || putVersion.httpCode >= 300) {
1351
+ return { code: 'sync-request', status: 502, error: webdavHttpText(putVersion.httpCode) };
1352
+ }
923
1353
  pushed.push(kind);
924
1354
  }
925
1355
  } finally {
@@ -931,8 +1361,8 @@ async function runSyncPush({ dataRoot, configFile, config, sync, proxyUrl }) {
931
1361
  }
932
1362
 
933
1363
  // 下载:把云端所选组应用到本机(各组独立文件,勾选几组读几组,可只覆盖模型设置)。
934
- // 覆盖前留 .zcodepro-backup 备份;zcodepro 组只覆盖功能/样式/别名/代理,
935
- // sync 配置与凭据始终保留本机的
1364
+ // 覆盖前留 .zcodepro-backup 备份;zcodepro 组覆盖功能/样式/代理,别名与本机的
1365
+ // 合并(合并规则见 mergeAliases);sync 配置与凭据始终保留本机的
936
1366
  async function runSyncPull({ dataRoot, configFile, config, sync, proxyUrl }) {
937
1367
  // 先把勾选组的云端数据全部读到(任何一组读取失败都不动本机文件),再逐组应用
938
1368
  const docs = {};
@@ -945,6 +1375,14 @@ async function runSyncPull({ dataRoot, configFile, config, sync, proxyUrl }) {
945
1375
  if (!Object.keys(docs).length) {
946
1376
  return { code: 'sync-remote-missing', status: 404, error: '云端没有同步数据,请先在其他电脑上传' };
947
1377
  }
1378
+ sync.lastPullAt = Date.now();
1379
+ return applySyncDocs({ docs, dataRoot, configFile, config, sync });
1380
+ }
1381
+
1382
+ // 把云端读到的各组数据落到本机(runSyncPull 与按文件的历史版本导入共用):
1383
+ // zcodepro 组覆盖功能/样式/代理(sync 配置与凭据保留本机的),别名与本机的合并,
1384
+ // model 组覆盖 v2/config.json 的 provider 与 provider_config.json;覆盖前都留备份
1385
+ function applySyncDocs({ docs, dataRoot, configFile, config, sync }) {
948
1386
  const applied = [];
949
1387
  const backupFile = (p) => {
950
1388
  try { if (existsSync(p)) copyFileSync(p, p + '.zcodepro-backup'); } catch { /* 备份失败不阻塞 */ }
@@ -954,15 +1392,16 @@ async function runSyncPull({ dataRoot, configFile, config, sync, proxyUrl }) {
954
1392
  writeFileSync(tmp, JSON.stringify(obj, null, 2) + '\n', 'utf8');
955
1393
  renameSync(tmp, p);
956
1394
  };
957
- sync.lastPullAt = Date.now();
958
1395
 
959
1396
  if (docs.zcodepro) {
960
1397
  const rz = docs.zcodepro;
961
1398
  const next = loadConfig(configFile);
962
1399
  applyFeaturesPartial(next.features, rz.features);
963
1400
  next.styles = applyStylesPartial(next.styles, rz.styles);
964
- const aliases = sanitizeAliases(rz.aliases);
965
- if (aliases) next.aliases = aliases;
1401
+ // 别名与本机的合并(本机配过的优先),不再整份替换——各机器项目路径不同,
1402
+ // 合并后两边的别名都在,谁也不会被对方的同步顶掉
1403
+ const remoteAliases = sanitizeAliases(rz.aliases);
1404
+ if (remoteAliases) next.aliases = mergeAliases(next.aliases, remoteAliases);
966
1405
  if (typeof rz.proxy === 'string') {
967
1406
  const v = rz.proxy.trim();
968
1407
  if (v === '' || /^https?:\/\/\S+:\d+$/.test(v)) next.proxy = v;
@@ -988,10 +1427,79 @@ async function runSyncPull({ dataRoot, configFile, config, sync, proxyUrl }) {
988
1427
  }
989
1428
  applied.push('model');
990
1429
  }
1430
+ // 没动 zcodepro.json 时,lastPullAt 也要写入配置文件(动了则已随 next 一起保存)
991
1431
  if (!applied.includes('zcodepro')) saveConfig(configFile, config);
992
1432
  return { applied };
993
1433
  }
994
1434
 
1435
+ // 同步“测试”按钮的实现:分三步验证,任一步失败即止(后续步骤没有意义)。
1436
+ // ① 连接与凭据:PROPFIND 上传目录——404/409 只说明目录还没建,能拿到 HTTP 响应
1437
+ // 且不是 401/403 即证明服务器可达、凭据可用(坚果云的应用密码可限定目录,
1438
+ // 探服务器根会误报);② 上传目录:不存在时逐段 MKCOL 建出来再验(顺带验证写
1439
+ // 权限,与上传时的自动建目录一致);③ 文件列表:PROPFIND Depth 1 数备份文件。
1440
+ // targets 的 step 供前端映射本地化步骤名(connect/dir/list)
1441
+ async function runSyncTest({ sync, proxyUrl }) {
1442
+ const auth = { login: sync.login, password: sync.password, proxyUrl };
1443
+ const targets = [];
1444
+ const probe = async (step, run) => {
1445
+ const started = Date.now();
1446
+ const resp = await run();
1447
+ const ok = resp.ok && resp.httpCode >= 200 && resp.httpCode < 300;
1448
+ targets.push({
1449
+ step, ok,
1450
+ ms: Date.now() - started,
1451
+ httpCode: resp.ok ? resp.httpCode : null,
1452
+ error: ok ? '' : (resp.ok ? webdavHttpText(resp.httpCode) : resp.error),
1453
+ });
1454
+ return { ok, resp };
1455
+ };
1456
+ const baseDir = syncBaseDir(sync);
1457
+ // ① 只要拿到响应且不是凭据问题就算连通(404/409 = 目录还没建,交给第②步处理)
1458
+ const connect = await probe('connect', () => webdavRequest({ method: 'PROPFIND', url: baseDir, ...auth, timeoutMs: 20000, headers: ['Depth: 0'] }));
1459
+ if (!connect.ok && connect.resp.ok && (connect.resp.httpCode === 404 || connect.resp.httpCode === 409)) {
1460
+ // 目录缺失不算连接失败:改写第一步结果,继续建目录
1461
+ targets[targets.length - 1] = { ...targets[targets.length - 1], ok: true, error: '' };
1462
+ } else if (!connect.ok) {
1463
+ return { targets };
1464
+ }
1465
+ // ② 目录不存在则逐段 MKCOL(已存在段 MKCOL 多半回 405,忽略),再 PROPFIND 验证
1466
+ const dir = await probe('dir', async () => {
1467
+ let r = connect.resp;
1468
+ if (r.httpCode === 404 || r.httpCode === 409) {
1469
+ try {
1470
+ const u = new URL(baseDir);
1471
+ let cur = u.origin;
1472
+ for (const s of u.pathname.split('/').filter(Boolean)) {
1473
+ cur += '/' + s;
1474
+ await webdavRequest({ method: 'MKCOL', url: cur, ...auth, timeoutMs: 15000 });
1475
+ }
1476
+ } catch { /* URL 解析失败:交给下面的 PROPFIND 报错 */ }
1477
+ r = await webdavRequest({ method: 'PROPFIND', url: baseDir, ...auth, timeoutMs: 20000, headers: ['Depth: 0'] });
1478
+ }
1479
+ return r;
1480
+ });
1481
+ if (!dir.ok) return { targets };
1482
+ // ③ 列文件(Depth 1),数出同步目录里的备份文件个数
1483
+ const list = await probe('list', () => webdavRequest({ method: 'PROPFIND', url: baseDir, ...auth, timeoutMs: 30000, headers: ['Depth: 1'] }));
1484
+ if (!list.ok) return { targets };
1485
+ const fileCount = parseWebdavListing(list.resp.body).filter((e) => SYNC_VERSION_RE.test(e.name)).length;
1486
+ return { targets, fileCount };
1487
+ }
1488
+
1489
+ // PROPFIND 云端同步目录,列出全部备份版本(固定名 + 带时间戳的历史):
1490
+ // 按修改时间从新到旧(时间缺失的按文件名排后);目录还不存在视为空列表
1491
+ async function listSyncVersions(sync, proxyUrl) {
1492
+ const r = await webdavRequest({ method: 'PROPFIND', url: syncBaseDir(sync), login: sync.login, password: sync.password, proxyUrl, timeoutMs: 30000, headers: ['Depth: 1'] });
1493
+ if (!r.ok) return { error: r.error, code: 'sync-request', status: 502 };
1494
+ if (r.httpCode === 404 || r.httpCode === 409) return { files: [] };
1495
+ if (r.httpCode < 200 || r.httpCode >= 300) return { error: webdavHttpText(r.httpCode), code: 'sync-request', status: 502 };
1496
+ const files = parseWebdavListing(r.body)
1497
+ .filter((e) => SYNC_VERSION_RE.test(e.name))
1498
+ .map((e) => ({ name: e.name, size: e.size, lastModified: e.lastModified }));
1499
+ files.sort((a, b) => ((b.lastModified || 0) - (a.lastModified || 0)) || b.name.localeCompare(a.name));
1500
+ return { files };
1501
+ }
1502
+
995
1503
  // 别名表清洗(同步下载用):只收 路径字符串 → 短字符串 的条目,数量限幅
996
1504
  function sanitizeAliases(raw) {
997
1505
  if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null;
@@ -1006,6 +1514,26 @@ function sanitizeAliases(raw) {
1006
1514
  return out;
1007
1515
  }
1008
1516
 
1517
+ // 别名表合并(同步上传/下载共用):取两边的并集,配过别名的条目优先——同一路径
1518
+ // 两边都配了别名时用本机的;只有一边配过时保留配过的那个。所以在一台机器上清除
1519
+ // 别名后,只要别的机器还配着,下次同步还会把这条带回来,两边都清掉才会真正消失。
1520
+ // 导出以便测试脚本直接驱动。
1521
+ export function mergeAliases(localRaw, remoteRaw) {
1522
+ const out = {};
1523
+ const put = (k, v) => {
1524
+ if (typeof k !== 'string' || k.length > 500 || k.includes('\0')) return;
1525
+ if (typeof v !== 'string' || !v || v.length > 200 || /[\r\n\0]/.test(v)) return;
1526
+ out[k] = v;
1527
+ };
1528
+ const entries = (raw) => (raw && typeof raw === 'object' && !Array.isArray(raw)) ? Object.entries(raw) : [];
1529
+ for (const [k, v] of entries(remoteRaw)) put(k, v);
1530
+ for (const [k, v] of entries(localRaw)) {
1531
+ // 本机配过的覆盖云端同路径条目;本机是空值(视为没配过)时不覆盖,云端配过的留下
1532
+ if (v) put(k, v);
1533
+ }
1534
+ return out;
1535
+ }
1536
+
1009
1537
  // features 部分更新(POST /config 与同步下载共用):只收已知开关的布尔值
1010
1538
  function applyFeaturesPartial(base, partial) {
1011
1539
  const out = (base && typeof base === 'object') ? base : {};
@@ -1232,7 +1760,7 @@ function applyStylesPartial(base, partial) {
1232
1760
  else if (typeof v === 'number' && Number.isFinite(v) && v >= 0 && v <= 96) out[key] = Math.round(v);
1233
1761
  }
1234
1762
  }
1235
- for (const key of ['lineHeight', 'codeLineHeight', 'userLineHeight']) {
1763
+ for (const key of ['lineHeight', 'thinkingLineHeight', 'codeLineHeight', 'userLineHeight']) {
1236
1764
  if (key in partial) {
1237
1765
  const v = partial[key];
1238
1766
  if (v === null) out[key] = null;
@@ -1266,33 +1794,290 @@ function sanitizeFontStack(v) {
1266
1794
  return s;
1267
1795
  }
1268
1796
 
1269
- // 跟随供应商下拉取数:合并 ~/.zcode/v2/config.json 的 provider(优先)与
1270
- // ~/.zcode/v2/provider_config.json 的 providerRules(同 id 时规则表的名字保留为别名);只回 id/名称/别名,不含密钥。
1797
+ // 视觉代理「跟随供应商」下拉取数:与智能起名共用同一套供应商列表和过滤规则
1798
+ //(见 listTitleGenProviders),只回 id/名称/别名,不含密钥。
1271
1799
  // 导出以便测试脚本直接驱动。
1272
1800
  export function listVisionProviders() {
1273
- const list = [];
1801
+ return listTitleGenProviders().map((p) => ({ id: p.id, name: p.name, aliases: p.aliases || [] }));
1802
+ }
1803
+
1804
+ // —— 智能起名:供应商取数、模型调用与标题清洗 ——
1805
+
1806
+ // 内置默认提示词(用户可在设置弹窗覆盖),按界面语言取中文或英文版。
1807
+ // 英文版的词数上限比中文版紧:英文单词更长,3~15 个词在会话列表里放不下
1808
+ const TITLE_GEN_DEFAULT_PROMPTS = {
1809
+ zh: [
1810
+ '为下面的会话生成一个简短的标题。',
1811
+ '要求:',
1812
+ '- 使用用户消息的主要语言(中文消息用中文标题)',
1813
+ '- 概括用户要做的主要事情或主题,不要给出答案或结果',
1814
+ '- 一般3~15个词,在会话列表里能一眼认出',
1815
+ '- 保留重要的专有名词(文件名、产品名、技术名词等,如有)',
1816
+ '- 不要用`用户请求`、`新任务`、`提问`这类泛泛的标题',
1817
+ '- 不要直接复述或截取用户消息原文当标题',
1818
+ '- 只输出标题本身:不要解释、不要引号、不要末尾标点、不要markdown',
1819
+ ].join('\n'),
1820
+ en: [
1821
+ 'Generate a short title for the session below.',
1822
+ 'Requirements:',
1823
+ "- Use the primary language of the user's message (an English message gets an English title)",
1824
+ '- Summarize the user\'s main task or topic; do not give the answer or result',
1825
+ '- Usually 3-8 words, easy to recognize in a session list',
1826
+ '- Keep important proper nouns (file names, product names, technical terms, if any)',
1827
+ '- Avoid generic titles such as `User Request`, `New Task`, or `Question`',
1828
+ '- Do not copy or truncate the user\'s message as the title',
1829
+ '- Output only the title itself: no explanation, no quotes, no trailing punctuation, no markdown',
1830
+ ].join('\n'),
1831
+ };
1832
+
1833
+ // 界面语言:桌面设置(v2/setting.json 的 localePreference)为 en-US / zh-CN 时
1834
+ // 直接采用;system 或未设置时按系统环境语言判断(中文环境 → 中文,其余 → 英文)
1835
+ function titleGenLocale() {
1836
+ const s = readJsonFile(join(homedir(), '.zcode', 'v2', 'setting.json'));
1837
+ const pref = String((s && s.localePreference) || '').toLowerCase();
1838
+ if (pref.startsWith('en')) return 'en';
1839
+ if (pref.startsWith('zh')) return 'zh';
1840
+ const lang = String(process.env.LC_ALL || process.env.LC_MESSAGES || process.env.LANG || '').toLowerCase();
1841
+ return lang.startsWith('zh') ? 'zh' : 'en';
1842
+ }
1843
+
1844
+ function titleGenDefaultPrompt() {
1845
+ return TITLE_GEN_DEFAULT_PROMPTS[titleGenLocale()] || TITLE_GEN_DEFAULT_PROMPTS.en;
1846
+ }
1847
+
1848
+ const TITLE_GEN_MAX_TEXT = 1200; // 用户消息送入模型的长度上限(与官方起名一致)
1849
+ const TITLE_GEN_MAX_TITLE = 64; // 生成标题的长度上限(按字符计)
1850
+ const TITLE_GEN_TIMEOUT_MS = 60000; // 单次模型调用超时(与官方起名一致;推理模型思考占时较长)
1851
+
1852
+ // 内置套餐显示名:与 ZCode 界面的叫法一致——条目原始名区分度差
1853
+ //(coding-plan 与 start-plan 都叫 "Coding Plan"),界面里按套餐类型显示。
1854
+ // coding-plan 的个人/团队是动态的:从桌面设置(v2/setting.json 的
1855
+ // providerFamilyConnectionSelections)读当前登录的连接方式(kind 含
1856
+ // individual → 个人、team → 团队),读不到时退回「个人/团队」
1857
+ function codingPlanKindByFamily() {
1858
+ const s = readJsonFile(join(homedir(), '.zcode', 'v2', 'setting.json'));
1859
+ const sel = (s && s.providerFamilyConnectionSelections && typeof s.providerFamilyConnectionSelections === 'object')
1860
+ ? s.providerFamilyConnectionSelections : {};
1861
+ const out = {};
1862
+ for (const [family, v] of Object.entries(sel)) {
1863
+ const kind = String(v?.kind || '');
1864
+ if (/individual/i.test(kind)) out[family] = '个人';
1865
+ else if (/team/i.test(kind)) out[family] = '团队';
1866
+ }
1867
+ return out;
1868
+ }
1869
+
1870
+ function titleGenProviderDisplayName(id, name, planKinds = {}) {
1871
+ const m = /^builtin:(zai|bigmodel)-coding-plan$/.exec(id);
1872
+ if (m) {
1873
+ const fam = m[1] === 'zai' ? 'Z.ai' : 'BigModel';
1874
+ return `${fam}(${planKinds[m[1]] || '个人/团队'})`;
1875
+ }
1876
+ if (/^builtin:(zai|bigmodel)-start-plan$/.test(id)) return 'Start Plan';
1877
+ return (name && String(name).trim()) || id;
1878
+ }
1879
+
1880
+ // 供应商条目(智能起名与视觉代理共用),列表范围与 ZCode 界面的供应商列表
1881
+ // 保持一致:
1882
+ // - 内置套餐(builtin:*):有地址、密钥才列出(无密钥的 helper 无法在会话外
1883
+ // 调用,如未配 key 的 API Key 型条目);
1884
+ // - 自定义供应商:以 provider_config.json 的 providerOrder 为准(ZCode 界面的
1885
+ // 自定义列表就来自它),provider 表里不在其中的残留条目(已弃用的中转等)
1886
+ // 不列出;
1887
+ // - 协议:api.type / kind 缺失时按地址推断(含 anthropic → anthropic,含
1888
+ // openai 或以 /v\d 结尾 → openai),两个都推不出才放弃该条目。
1889
+ // 两张来源表同 id 合并(模型并集、原始名收进别名)。models 为该供应商的
1890
+ // 可选模型(下拉用,可为空)。导出以便测试
1891
+ export function listTitleGenProviders() {
1892
+ const out = [];
1274
1893
  const byId = new Map();
1275
- const v2 = readJsonFile(join(homedir(), '.zcode', 'v2', 'config.json'));
1276
- for (const [id, p] of Object.entries((v2 && typeof v2.provider === 'object' && v2.provider) || {})) {
1277
- const entry = { id, name: (p && typeof p.name === 'string' && p.name.trim()) || id, aliases: [] };
1278
- list.push(entry);
1894
+ const planKinds = codingPlanKindByFamily();
1895
+ const add = (id, { name, aliases = [], kind, baseUrl, apiKey, models = [] }) => {
1896
+ baseUrl = typeof baseUrl === 'string' ? baseUrl.trim() : '';
1897
+ apiKey = typeof apiKey === 'string' ? apiKey.trim() : '';
1898
+ if (!id || !baseUrl || !apiKey) return;
1899
+ let format = /anthropic/i.test(String(kind)) ? 'anthropic' : /openai/i.test(String(kind)) ? 'openai' : '';
1900
+ if (!format) format = /anthropic/i.test(baseUrl) ? 'anthropic'
1901
+ : (/openai/i.test(baseUrl) || /\/v\d+$/.test(baseUrl)) ? 'openai' : '';
1902
+ if (!format) return;
1903
+ models = models.filter(Boolean);
1904
+ const known = byId.get(id);
1905
+ if (known) {
1906
+ known.models = [...new Set([...known.models, ...models])];
1907
+ for (const a of aliases) if (a && !known.aliases.includes(a)) known.aliases.push(a);
1908
+ return;
1909
+ }
1910
+ const shown = titleGenProviderDisplayName(id, name, planKinds);
1911
+ const entry = {
1912
+ id, name: shown, aliases: [...new Set(aliases.filter((a) => a && a !== shown))],
1913
+ format, baseUrl, apiKey, models: [...new Set(models)],
1914
+ };
1279
1915
  byId.set(id, entry);
1280
- }
1916
+ out.push(entry);
1917
+ };
1918
+
1919
+ const v2 = readJsonFile(join(homedir(), '.zcode', 'v2', 'config.json'));
1920
+ const providers = (v2 && typeof v2.provider === 'object' && v2.provider) || {};
1281
1921
  const rulesDoc = readJsonFile(join(homedir(), '.zcode', 'v2', 'provider_config.json'));
1282
1922
  const rules = (rulesDoc && rulesDoc.config?.providerConfigRules?.providerRules) || [];
1283
- for (const r of rules) {
1284
- if (!r || typeof r.providerId !== 'string' || !r.providerId) continue;
1285
- const existing = byId.get(r.providerId);
1286
- if (existing) {
1287
- const alias = (typeof r.providerName === 'string' && r.providerName.trim()) || r.providerId;
1288
- if (alias !== existing.name && !existing.aliases.includes(alias)) existing.aliases.push(alias);
1289
- continue;
1923
+ const ruleById = new Map(rules.filter((r) => r && typeof r.providerId === 'string').map((r) => [r.providerId, r]));
1924
+ // 同一 id 在两张表的字段并起来(provider 表优先:地址/密钥以桌面实际在用的为准)
1925
+ const addMerged = (id) => {
1926
+ const p = providers[id];
1927
+ if (p !== undefined && (p === null || typeof p !== 'object')) return;
1928
+ const r = ruleById.get(id) || {};
1929
+ const c = r.config || {};
1930
+ add(id, {
1931
+ name: (p && p.name) || r.providerName,
1932
+ aliases: [r.providerName, p && p.name],
1933
+ kind: (p && p.kind) || c.api?.type,
1934
+ baseUrl: (p && p.options?.baseURL) || c.api?.baseUrl,
1935
+ apiKey: (p && p.options?.apiKey) || c.access?.apiKey,
1936
+ models: [...Object.keys((p && p.models) || {}), ...(c.modelOrder || []), ...(c.personalModelIds || [])],
1937
+ });
1938
+ };
1939
+
1940
+ // 内置套餐在前(BigModel(个人/团队)→ Start Plan 的顺序)
1941
+ for (const id of Object.keys(providers)) {
1942
+ if (id.startsWith('builtin:')) addMerged(id);
1943
+ }
1944
+ // 自定义供应商按界面顺序(providerOrder)
1945
+ const order = (rulesDoc && Array.isArray(rulesDoc.config?.providerOrder) ? rulesDoc.config.providerOrder : [])
1946
+ .filter((id) => typeof id === 'string');
1947
+ for (const id of order) addMerged(id);
1948
+ return out;
1949
+ }
1950
+
1951
+ // Anthropic Messages 文本调用(无系统提示词,单条用户消息)。max_tokens 给足
1952
+ // 2048:glm 系列等推理模型先输出 thinking 块再出正文,给小了会全部耗在
1953
+ // 思考上(stop_reason=max_tokens 且没有任何 text 块),起名调用不在意这点额度
1954
+ async function callAnthropicText(baseUrl, apiKey, model, content) {
1955
+ const url = baseUrl.replace(/\/+$/, '') + '/v1/messages';
1956
+ const ctrl = new AbortController();
1957
+ const timer = setTimeout(() => ctrl.abort(), TITLE_GEN_TIMEOUT_MS);
1958
+ try {
1959
+ const resp = await fetch(url, {
1960
+ method: 'POST', signal: ctrl.signal,
1961
+ headers: { 'content-type': 'application/json', 'x-api-key': apiKey, 'anthropic-version': '2023-06-01' },
1962
+ body: JSON.stringify({ model, max_tokens: 2048, messages: [{ role: 'user', content }] }),
1963
+ });
1964
+ const body = await resp.text();
1965
+ if (!resp.ok) throw new Error(`HTTP ${resp.status}: ${body.slice(0, 300)}`);
1966
+ const doc = JSON.parse(body);
1967
+ const text = Array.isArray(doc.content)
1968
+ ? doc.content.filter((b) => b && b.type === 'text').map((b) => b.text || '').join('') : '';
1969
+ if (!text) throw new Error(doc.stop_reason === 'max_tokens'
1970
+ ? '模型的思考用完了输出上限,仍未给出标题' : '响应中没有文本内容');
1971
+ return text;
1972
+ } catch (err) {
1973
+ if (err && err.name === 'AbortError') throw new Error(`请求超时(${Math.round(TITLE_GEN_TIMEOUT_MS / 1000)} 秒)`);
1974
+ throw err;
1975
+ } finally { clearTimeout(timer); }
1976
+ }
1977
+
1978
+ // OpenAI Chat Completions 文本调用;baseURL 已含 /v1 时不重复拼
1979
+ async function callOpenAiText(baseUrl, apiKey, model, content) {
1980
+ const base = baseUrl.replace(/\/+$/, '');
1981
+ const url = /\/v\d+$/.test(base) ? base + '/chat/completions' : base + '/v1/chat/completions';
1982
+ const ctrl = new AbortController();
1983
+ const timer = setTimeout(() => ctrl.abort(), TITLE_GEN_TIMEOUT_MS);
1984
+ try {
1985
+ const resp = await fetch(url, {
1986
+ method: 'POST', signal: ctrl.signal,
1987
+ headers: { 'content-type': 'application/json', authorization: `Bearer ${apiKey}` },
1988
+ body: JSON.stringify({ model, max_tokens: 2048, messages: [{ role: 'user', content }] }),
1989
+ });
1990
+ const body = await resp.text();
1991
+ if (!resp.ok) throw new Error(`HTTP ${resp.status}: ${body.slice(0, 300)}`);
1992
+ const doc = JSON.parse(body);
1993
+ const text = doc.choices?.[0]?.message?.content || '';
1994
+ if (typeof text !== 'string' || !text.trim()) throw new Error('响应中没有文本内容');
1995
+ return text;
1996
+ } catch (err) {
1997
+ if (err && err.name === 'AbortError') throw new Error(`请求超时(${Math.round(TITLE_GEN_TIMEOUT_MS / 1000)} 秒)`);
1998
+ throw err;
1999
+ } finally { clearTimeout(timer); }
2000
+ }
2001
+
2002
+ // 生成标题。provider 三种取值:
2003
+ // - 'session':跟随当前会话——从任务索引解析该会话正在用的供应商与模型
2004
+ // (model 已填时用填写的),供应商需在可直连列表内;
2005
+ // - '':不跟随,用配置里的 baseUrl/apiKey/format 直连;
2006
+ // - 供应商 id:按列表解析直连;id 已被删时退回保存时留下的地址/密钥副本。
2007
+ // 输出统一清洗为单行短标题。导出以便测试
2008
+ export async function generateTitleWith(cfg, rawText, dataRoot = '', sessId = '') {
2009
+ const text = String(rawText || '').trim().replace(/\s+/g, ' ').slice(0, TITLE_GEN_MAX_TEXT);
2010
+ const prompt = (cfg.prompt && cfg.prompt.trim()) || titleGenDefaultPrompt();
2011
+ const userContent = `${prompt}\n\n会话的用户消息:\n${text}`;
2012
+ const providerId = (cfg.provider || '').trim();
2013
+ const model = (cfg.model || '').trim();
2014
+ const conn = await resolveTitleGenConnection(cfg, providerId, model, dataRoot, sessId);
2015
+ const raw = conn.format === 'anthropic'
2016
+ ? await callAnthropicText(conn.baseUrl, conn.apiKey, conn.model, userContent)
2017
+ : await callOpenAiText(conn.baseUrl, conn.apiKey, conn.model, userContent);
2018
+ const title = cleanGeneratedTitle(raw);
2019
+ if (!title) throw new Error('模型未返回可用标题');
2020
+ return title;
2021
+ }
2022
+
2023
+ // 直连前的连接解析(见 generateTitleWith 的取值说明);解析不出抛明确错误
2024
+ async function resolveTitleGenConnection(cfg, providerId, model, dataRoot, sessId) {
2025
+ if (providerId === 'session') {
2026
+ if (!sessId || !dataRoot) {
2027
+ throw new Error('跟随当前会话需要会话信息(测试按钮请选择具体供应商或“不跟随”)');
2028
+ }
2029
+ const task = await getTaskProviderModel(dataRoot, sessId);
2030
+ if (!task || !task.providerId) throw new Error('索引中查不到该会话的供应商信息');
2031
+ const entry = listTitleGenProviders().find((p) => p.id === task.providerId);
2032
+ if (!entry) throw new Error(`会话供应商 ${task.providerId} 无法在会话外直连(无密钥或协议未知)`);
2033
+ const useModel = model || task.modelId;
2034
+ if (!useModel) throw new Error('未能确定会话当前模型,请手动填写模型');
2035
+ return { format: entry.format, baseUrl: entry.baseUrl, apiKey: entry.apiKey, model: useModel };
2036
+ }
2037
+ if (providerId) {
2038
+ const entry = listTitleGenProviders().find((p) => p.id === providerId);
2039
+ if (entry) {
2040
+ if (!model) throw new Error('已选择供应商,但未填写模型');
2041
+ return { format: entry.format, baseUrl: entry.baseUrl, apiKey: entry.apiKey, model };
2042
+ }
2043
+ // 供应商已被删:退回保存时的地址/密钥副本
2044
+ if (cfg.baseUrl && cfg.apiKey && cfg.format) {
2045
+ if (!model) throw new Error('已删除的供应商需手动填写模型');
2046
+ return { format: cfg.format, baseUrl: cfg.baseUrl, apiKey: cfg.apiKey, model };
1290
2047
  }
1291
- const entry = { id: r.providerId, name: (typeof r.providerName === 'string' && r.providerName.trim()) || r.providerId, aliases: [] };
1292
- list.push(entry);
1293
- byId.set(r.providerId, entry);
2048
+ throw new Error(`供应商 ${providerId} 已不可用(未保存地址/密钥副本),请重新选择`);
2049
+ }
2050
+ // 不跟随(自填地址)
2051
+ if (!cfg.baseUrl || !cfg.apiKey || !cfg.format) {
2052
+ throw new Error('不跟随(自填地址)需要填写接口地址、API Key 与协议');
2053
+ }
2054
+ if (!model) throw new Error('不跟随(自填地址)需要填写模型');
2055
+ return { format: cfg.format, baseUrl: cfg.baseUrl, apiKey: cfg.apiKey, model };
2056
+ }
2057
+
2058
+ // 清洗模型输出:去思考块/代码围栏/首尾引号/序号/末尾标点,取首个非空行并限长。
2059
+ // 模型可能沿用官方起名的习惯回 JSON 对象,此时取其 title 字段。导出以便测试
2060
+ export function cleanGeneratedTitle(text) {
2061
+ let t = String(text || '').replace(/<think>[\s\S]*?<\/think>/gi, '').trim();
2062
+ // 整段包在代码围栏里时先去掉围栏标记(先取行会把「```json」当成标题内容)
2063
+ t = t.replace(/^```[a-zA-Z]*\s*\n?/, '').replace(/\n?```\s*$/, '').trim();
2064
+ let line = t.split(/\r?\n/).map((s) => s.trim()).find(Boolean) || '';
2065
+ line = line.replace(/^```[a-zA-Z]*\s*/, '').replace(/```\s*$/, '');
2066
+ try {
2067
+ const doc = JSON.parse(line);
2068
+ if (doc && typeof doc.title === 'string') line = doc.title;
2069
+ } catch { /* 不是 JSON,按纯文本处理 */ }
2070
+ line = line.replace(/^["'「『“]+/, '').replace(/["'」』”]+$/, '');
2071
+ line = line.replace(/^\d+[.、)\]]\s*/, '');
2072
+ line = line.replace(/[.。!!??,,;;::]\s*$/, '');
2073
+ line = line.replace(/\s+/g, ' ').trim();
2074
+ const chars = [...line];
2075
+ if (chars.length > TITLE_GEN_MAX_TITLE) {
2076
+ line = chars.slice(0, TITLE_GEN_MAX_TITLE).join('');
2077
+ // 截断落在英文单词中间时退到词边界,避免出现半个单词
2078
+ if (line.includes(' ')) line = line.slice(0, line.lastIndexOf(' '));
1294
2079
  }
1295
- return list;
2080
+ return line.trim();
1296
2081
  }
1297
2082
 
1298
2083
  // zcode-vision 配置校验:只保留已知字段并规范化;出错时返回中文原因。
@@ -1337,7 +2122,11 @@ export function validateVisionConfig(raw) {
1337
2122
  names.add(name);
1338
2123
  proxy.name = name;
1339
2124
  if (!(proxy.baseUrl || '').trim() && !(proxy.useProvider || '').trim()) return [null, `代理 ${name} 缺少 baseUrl(或设置 useProvider 跟随供应商)`];
1340
- if (!(proxy.model || '').trim()) return [null, `代理 ${name} 缺少 model`];
2125
+ // 跟随会话(useProvider=session)时允许 model 留空:留空 = 用会话当前模型
2126
+ //(zcode-vision ≥1.6.1 支持);其余情况必须填写
2127
+ if (!(proxy.model || '').trim() && proxy.useProvider !== 'session') {
2128
+ return [null, `代理 ${name} 缺少 model`];
2129
+ }
1341
2130
  if (proxy.baseUrl) proxy.baseUrl = proxy.baseUrl.trim();
1342
2131
  if (proxy.model) proxy.model = proxy.model.trim();
1343
2132
  if (proxy.useProvider) proxy.useProvider = proxy.useProvider.trim();
@@ -1447,16 +2236,22 @@ function readRtkBuiltinWhitelist() {
1447
2236
  // —— rtk 本体(独立二进制)的检查更新与升级:GitHub Releases 下载 → sha256 校验 → 原子替换 ——
1448
2237
 
1449
2238
  function rtkFindBin() {
2239
+ // 兜底候选按平台给全扩展名:全新安装装到 ~/.local/bin 后 PATH 对运行中的 helper
2240
+ // 不可见(需注销重登),必须能按固定位置直接找到,否则面板误报“未安装”
1450
2241
  return findBinFile(
1451
- process.platform === 'win32' ? ['rtk.exe', 'rtk.cmd'] : ['rtk'],
1452
- [join(homedir(), '.local', 'bin', 'rtk'), join(homedir(), 'miniforge3', 'bin', 'rtk'),
1453
- '/usr/local/bin/rtk', '/usr/bin/rtk']);
1454
- }
1455
-
1456
- // 最新版本探测(两条互补通道,任一成功即用):
1457
- // ① releases/latest 的 302 重定向拿 tag——不受 API 速率限制,但 github.com 网页在
1458
- // 部分网络直连不通(超时);② 匿名 GitHub API——直连常可用,但走代理时出口 IP
1459
- // 常被限流(403 无 tag_name)。资产与 checksums 的下载地址按官方命名规则构造。
2242
+ process.platform === 'win32' ? ['rtk.exe'] : ['rtk'],
2243
+ process.platform === 'win32'
2244
+ ? [join(homedir(), '.local', 'bin', 'rtk.exe'), join(homedir(), '.cargo', 'bin', 'rtk.exe'),
2245
+ join(homedir(), 'miniforge3', 'Scripts', 'rtk.exe'), join(homedir(), 'miniconda3', 'Scripts', 'rtk.exe')]
2246
+ : [join(homedir(), '.local', 'bin', 'rtk'), join(homedir(), 'miniforge3', 'bin', 'rtk'),
2247
+ '/usr/local/bin/rtk', '/usr/bin/rtk']);
2248
+ }
2249
+
2250
+ // 最新版本探测。两条互补通道,任一成功即用:① 匿名 GitHub API——直连常可用,但
2251
+ // 走代理时出口 IP 常被限流;② releases/latest 302 的 Location 头——不受速率限制,
2252
+ // 但 github.com 网页在部分网络直连不通。资产与 checksums 的下载地址按官方命名规则
2253
+ // 构造。请求复用 webdavRequest(curl 优先、按 proxyScopes 注入代理;无 curl 的精简
2254
+ // Windows 回退 fetch 直连,代理在那条路径不生效)。
1460
2255
  async function rtkLatestInfo() {
1461
2256
  const assetName = rtkAssetName();
1462
2257
  if (!assetName) return { code: 'rtk-update', error: '当前平台不支持自动升级' };
@@ -1464,25 +2259,23 @@ async function rtkLatestInfo() {
1464
2259
  const base = `https://github.com/rtk-ai/rtk/releases/download/v${tag}`;
1465
2260
  return { tag, assetName, assetUrl: `${base}/${assetName}`, sumsUrl: `${base}/checksums.txt` };
1466
2261
  };
1467
- let redirErr = '';
1468
- const redir = await execCapture('curl', ['-sS', '-o', '/dev/null', '-w', '%{url_effective}', '-L', '--max-time', '6',
1469
- 'https://github.com/rtk-ai/rtk/releases/latest'], 8000);
1470
- if (redir.ok) {
1471
- const m = /\/tag\/v?(\d[\w.+-]*)\s*$/.exec(redir.output.trim());
1472
- if (m) return mkInfo(m[1]);
1473
- redirErr = '重定向未携带版本';
1474
- } else {
1475
- redirErr = (redir.output || redir.error || '').split('\n').slice(-1)[0];
1476
- }
1477
- const api = await execCapture('curl', ['-s', '--max-time', '8',
1478
- 'https://api.github.com/repos/rtk-ai/rtk/releases/latest'], 10000);
1479
- if (api.ok) {
2262
+ const proxyUrl = proxyUrlFor('deps');
2263
+ const api = await webdavRequest({
2264
+ method: 'GET', url: 'https://api.github.com/repos/rtk-ai/rtk/releases/latest',
2265
+ proxyUrl, timeoutMs: 12000, headers: ['User-Agent: zcode-pro'],
2266
+ });
2267
+ if (api.ok && api.httpCode === 200) {
1480
2268
  try {
1481
- const tag = String((JSON.parse(api.output) || {}).tag_name || '').replace(/^v/, '');
2269
+ const tag = String(JSON.parse(api.body).tag_name || '').replace(/^v/, '');
1482
2270
  if (tag) return mkInfo(tag);
1483
- } catch { /* 落到统一报错 */ }
2271
+ } catch { /* body 不是 JSON:换重定向通道 */ }
1484
2272
  }
1485
- return { code: 'rtk-update', error: '查询 GitHub Releases 失败(可尝试在“代理”标签页设置代理)。重定向: ' + redirErr };
2273
+ const redir = await webdavRequest({
2274
+ method: 'GET', url: 'https://github.com/rtk-ai/rtk/releases/latest', proxyUrl, timeoutMs: 10000,
2275
+ });
2276
+ const m = /\/tag\/v?(\d[\w.+-]*)\s*$/.exec(redir.redirectUrl || '');
2277
+ if (m) return mkInfo(m[1]);
2278
+ return { code: 'rtk-update', error: '查询 GitHub Releases 失败(网络不通或需代理)' };
1486
2279
  }
1487
2280
 
1488
2281
  // 按平台/架构选资产文件名(与官方 Release 命名一致)
@@ -1499,72 +2292,92 @@ export function rtkUpgradeSnapshot() { return upgradeJobSnapshot(rtkUpgradeJob);
1499
2292
 
1500
2293
  function cancelRtkUpgrade() { return cancelUpgradeJob(rtkUpgradeJob); }
1501
2294
 
1502
- // 启动 rtk 本体升级(后台任务,面板经 GET /rtk/upgrade 轮询):
1503
- // 下载资产与 checksums → sha256 校验 → 解压 → 试运行 → 原子替换旧二进制
2295
+ // 启动 rtk 本体升级或安装(后台任务,面板经 GET /rtk/upgrade 轮询):
2296
+ // 下载资产与 checksums → sha256 校验 → 解压 → 试运行 → 原子替换旧二进制;
2297
+ // 未安装时装到 ~/.local/bin(钩子的 rtk 查找清单含该位置,装完钩子立即可用;
2298
+ // Windows 资产是 zip,用系统自带 bsdtar 解压、PowerShell Get-FileHash 校验,
2299
+ // 全新安装后把 ~/.local/bin 注册进用户级 PATH,运行中的进程读不到——重启 ZCode 生效)
1504
2300
  function startRtkUpgrade() {
1505
2301
  if (rtkUpgradeJob && rtkUpgradeJob.running) return rtkUpgradeSnapshot();
1506
2302
  const j = newUpgradeJob();
1507
2303
  rtkUpgradeJob = j;
2304
+ const WIN = process.platform === 'win32';
1508
2305
  void (async () => {
1509
2306
  let dir = null;
1510
2307
  let bin = null;
1511
2308
  try {
1512
- if (process.platform === 'win32') throw new Error('Windows 暂不支持自动升级,请手动下载 Release 覆盖安装');
1513
- bin = rtkFindBin();
1514
- if (!bin) throw new Error('未找到 rtk 程序');
2309
+ const freshInstall = !rtkFindBin();
2310
+ bin = rtkFindBin() || join(homedir(), '.local', 'bin', WIN ? 'rtk.exe' : 'rtk');
1515
2311
  const info = await rtkLatestInfo();
1516
2312
  if (info.code) throw new Error(info.error);
1517
2313
  dir = await mkdtemp(join(tmpdir(), 'rtk-upgrade-'));
1518
2314
  j.output += `下载 ${info.assetName}…\n`;
1519
2315
  const archPath = join(dir, info.assetName);
1520
- await jobRun(j, 'curl', ['-L', '--fail', '--progress-bar', '-o', archPath, info.assetUrl], 600000);
2316
+ await downloadToFile(j, info.assetUrl, archPath, 600000);
1521
2317
  // sha256 校验(checksums.txt 与本机摘要工具;失败即中止,不替换)
1522
2318
  if (info.sumsUrl) {
1523
2319
  const sumPath = join(dir, 'checksums.txt');
1524
- await jobRun(j, 'curl', ['-sL', '--fail', '-o', sumPath, info.sumsUrl], 30000);
2320
+ await downloadToFile(j, info.sumsUrl, sumPath, 30000);
1525
2321
  const want = readFileSync(sumPath, 'utf8').split('\n')
1526
2322
  .find((l) => l.trim().endsWith(info.assetName))?.trim().split(/\s+/)[0];
1527
- const sumCmd = process.platform === 'darwin' ? 'shasum' : 'sha256sum';
1528
- const sumArgs = process.platform === 'darwin' ? ['-a', '256', archPath] : [archPath];
1529
- const local = await execCapture(sumCmd, sumArgs, 30000);
1530
- const got = local.ok ? (local.output.split(/\s+/)[0] || '') : '';
2323
+ let got = '';
2324
+ if (WIN) {
2325
+ const local = await execCapture('powershell.exe',
2326
+ ['-NoProfile', '-Command', `(Get-FileHash -Algorithm SHA256 -LiteralPath "${archPath}").Hash`], 60000);
2327
+ got = (/[0-9a-fA-F]{64}/.exec(local.output || '') || [''])[0];
2328
+ } else {
2329
+ const sumCmd = process.platform === 'darwin' ? 'shasum' : 'sha256sum';
2330
+ const sumArgs = process.platform === 'darwin' ? ['-a', '256', archPath] : [archPath];
2331
+ const local = await execCapture(sumCmd, sumArgs, 30000);
2332
+ got = local.ok ? (local.output.split(/\s+/)[0] || '') : '';
2333
+ }
1531
2334
  if (!want || !got || want.toLowerCase() !== got.toLowerCase()) {
1532
2335
  throw new Error(`sha256 校验失败(期望 ${want || '?'},实际 ${got || '?'})`);
1533
2336
  }
1534
2337
  j.output += 'sha256 校验通过\n';
1535
2338
  }
1536
- // 解压并定位二进制(包内根目录或一级子目录)
1537
- await jobRun(j, 'tar', ['-xzf', archPath, '-C', dir], 60000);
1538
- let newBin = join(dir, 'rtk');
2339
+ // 解压并定位二进制(包内根目录或一级子目录)。Windows 资产是 zip,
2340
+ // 系统自带 bsdtar(tar.exe)可直接解,不加 -z
2341
+ await jobRun(j, 'tar', WIN ? ['-xf', archPath, '-C', dir] : ['-xzf', archPath, '-C', dir], 60000);
2342
+ const exeName = WIN ? 'rtk.exe' : 'rtk';
2343
+ let newBin = join(dir, exeName);
1539
2344
  if (!existsSync(newBin)) {
1540
2345
  for (const ent of readdirSync(dir, { withFileTypes: true })) {
1541
- const cand = join(dir, ent.name, 'rtk');
2346
+ const cand = join(dir, ent.name, exeName);
1542
2347
  if (ent.isDirectory() && existsSync(cand)) { newBin = cand; break; }
1543
2348
  }
1544
2349
  }
1545
2350
  if (!existsSync(newBin)) throw new Error('解压后未找到 rtk 二进制');
1546
- chmodSync(newBin, 0o755);
2351
+ if (!WIN) chmodSync(newBin, 0o755);
1547
2352
  const v = await binVersionOf(newBin);
1548
2353
  if (!v) throw new Error('新版本无法运行(--version 无输出)');
1549
- // 原子替换:旧文件先挪走,失败可回滚
2354
+ // 放置:全新安装建目录直接放;已有旧文件则原子替换(先挪走,失败可回滚)
2355
+ if (freshInstall) mkdirSync(dirname(bin), { recursive: true });
1550
2356
  const backup = bin + '.rtk-old';
1551
2357
  try { rmSync(backup, { force: true }); } catch { /* ignore */ }
1552
- renameSync(bin, backup);
2358
+ const hadOld = existsSync(bin);
2359
+ if (hadOld) renameSync(bin, backup);
1553
2360
  try {
1554
2361
  try {
1555
2362
  renameSync(newBin, bin);
1556
2363
  } catch (renameErr) {
1557
2364
  // 跨文件系统(EXDEV)等 rename 失败:退化为复制(复制再失败由外层 catch 回滚)
1558
2365
  copyFileSync(newBin, bin);
1559
- chmodSync(bin, 0o755);
2366
+ if (!WIN) chmodSync(bin, 0o755);
1560
2367
  }
1561
2368
  } catch (err) {
1562
- renameSync(backup, bin);
2369
+ if (hadOld && existsSync(backup)) { try { renameSync(backup, bin); } catch { /* ignore */ } }
1563
2370
  throw err;
1564
2371
  }
1565
- try { rmSync(backup, { force: true }); } catch { /* ignore */ }
2372
+ try { if (existsSync(backup)) rmSync(backup, { force: true }); } catch { /* ignore */ }
1566
2373
  j.version = v;
1567
- j.output += `已升级到 ${v}\n`;
2374
+ if (WIN && freshInstall) {
2375
+ const inPath = await ensureUserPathEntry(dirname(bin));
2376
+ j.output += `已安装 rtk ${v}(${bin};PATH 需注销重登后对会话生效,插件钩子即时生效)\n`;
2377
+ if (!inPath) j.output += '注意:把 ~/.local/bin 写入用户 PATH 失败,钩子不受影响,会话里直接跑 rtk 需手动加 PATH\n';
2378
+ } else {
2379
+ j.output += freshInstall ? `已安装 rtk ${v}(${bin})\n` : `已升级到 ${v}\n`;
2380
+ }
1568
2381
  } catch (err) {
1569
2382
  if (!j.canceled) j.error = j.timedOut ? '升级超时已终止' : String(err?.stack || err?.message || err);
1570
2383
  } finally {
@@ -1577,26 +2390,39 @@ function startRtkUpgrade() {
1577
2390
  return rtkUpgradeSnapshot();
1578
2391
  }
1579
2392
 
1580
- // 在插件缓存里找市场插件的钩子脚本(取版本号最大的目录,按数值比较,0.10.0 > 0.9.0)。
1581
- // 只认纯数字版本目录:手动安装/升级的残留(如 1.0.2.bak-1.0.2)内容不全,不能选中。
2393
+ // 把目录追加进用户级 PATH(HKCU\Environment,Windows):保持原值类型不展开(REG_EXPAND_SZ
2394
+ // 里的 %VAR% 引用原样保留),已含该目录则不动。不广播环境变更——运行中的进程读不到,
2395
+ // 重启 ZCode 后新会话生效(rtk 钩子按固定清单找 rtk,不受 PATH 影响)。
2396
+ async function ensureUserPathEntry(dir) {
2397
+ const q = await execCapture('reg', ['query', 'HKCU\\Environment', '/v', 'PATH'], 15000);
2398
+ if (!q.ok) {
2399
+ const add = await execCapture('reg', ['add', 'HKCU\\Environment', '/v', 'PATH', '/t', 'REG_EXPAND_SZ', '/d', `%USERPROFILE%\\.local\\bin`, '/f'], 15000);
2400
+ return add.ok;
2401
+ }
2402
+ const m = /PATH\s+(REG_SZ|REG_EXPAND_SZ)\s+(.*)/i.exec(q.output.replace(/\r?\n/g, ' ').trim());
2403
+ if (!m) return false;
2404
+ const cur = m[2].trim();
2405
+ const norm = (s) => s.replace(/[/\\]+/g, '\\').replace(/\\+$/, '').toLowerCase();
2406
+ const expanded = cur.replaceAll('%USERPROFILE%', homedir());
2407
+ if (expanded.split(';').some((p) => p && norm(p) === norm(dir))) return true;
2408
+ const next = cur ? `${cur};${dir}` : dir;
2409
+ const r = await execCapture('reg', ['add', 'HKCU\\Environment', '/v', 'PATH', '/t', m[1], '/d', next, '/f'], 15000);
2410
+ return r.ok;
2411
+ }
2412
+
2413
+ // 在插件缓存里找市场插件的钩子脚本:只认 installed_plugins.json 登记的 installPath。
2414
+ // 不能按目录扫描:卸载只删「当前指向」的版本目录,缓存里的历史版本目录会残留
2415
+ // (升级留旧版、GitHub/本地源先后装过等),扫目录会把已卸载的插件误判为已装,
2416
+ // 面板就不显示安装卡了。本地开发流(install-plugin-local.sh)同样会更新安装记录,兼容。
1582
2417
  // 注意只认正式市场 duanluan-zcode-plugins:本地临时市场(如 duanluan-local)装的副本不在查找范围,
1583
2418
  // 属预期限制——正式发布从 duanluan-zcode-plugins 安装后即可用。
1584
2419
  function findMarketplaceHook(pluginName, hookRelPath) {
1585
- const base = join(cliPluginsDir(), 'cache', ZCODE_PLUGINS_MARKETPLACE, pluginName);
1586
- try {
1587
- const versions = readdirSync(base)
1588
- .filter((d) => /^\d+(\.\d+){0,3}$/.test(d))
1589
- .sort((a, b) => {
1590
- const pa = a.split('.').map(Number);
1591
- const pb = b.split('.').map(Number);
1592
- for (let i = 0; i < 3; i++) if ((pa[i] || 0) !== (pb[i] || 0)) return (pa[i] || 0) - (pb[i] || 0);
1593
- return 0;
1594
- });
1595
- for (let i = versions.length - 1; i >= 0; i--) {
1596
- const hook = join(base, versions[i], hookRelPath);
1597
- if (existsSync(hook)) return hook;
1598
- }
1599
- } catch { /* ignore */ }
2420
+ const installedRaw = readJsonFile(join(cliPluginsDir(), 'installed_plugins.json'));
2421
+ for (const rec of (installedRaw?.plugins || [])) {
2422
+ if (rec?.name !== pluginName || rec?.marketplace !== ZCODE_PLUGINS_MARKETPLACE || !rec.installPath) continue;
2423
+ const hook = join(rec.installPath, hookRelPath);
2424
+ if (existsSync(hook)) return hook;
2425
+ }
1600
2426
  return null;
1601
2427
  }
1602
2428
 
@@ -1604,6 +2430,13 @@ function findVisionHook() {
1604
2430
  return findMarketplaceHook('zcode-vision', join('hooks', 'vision-hook.mjs'));
1605
2431
  }
1606
2432
 
2433
+ // 读市场插件在 installed_plugins.json 里登记的版本(面板信息卡显示「插件 x.y.z」用)
2434
+ function installedPluginVersion(pluginName) {
2435
+ const installedRaw = readJsonFile(join(cliPluginsDir(), 'installed_plugins.json'));
2436
+ const rec = (installedRaw?.plugins || []).find((p) => p?.name === pluginName && p?.marketplace === ZCODE_PLUGINS_MARKETPLACE);
2437
+ return rec?.version || null;
2438
+ }
2439
+
1607
2440
  // —— headroom 配置读写与状态解析(与 headroom-lib.sh 共用 ~/.zcode/headroom.json)——
1608
2441
 
1609
2442
  // 读配置并按插件默认值归一(文件缺失/键非法 → 默认)。导出以便测试脚本直接驱动。
@@ -1675,11 +2508,11 @@ function cancelUpgradeJob(job) {
1675
2508
  return upgradeJobSnapshot(job);
1676
2509
  }
1677
2510
 
1678
- // 升级任务的可取消子进程执行:输出喂给任务对象供面板轮询,环境注入代理
1679
- function jobRun(j, cmd, args, timeoutMs) {
2511
+ // 升级任务的可取消子进程执行:输出喂给任务对象供面板轮询,环境按用途注入代理
2512
+ function jobRun(j, cmd, args, timeoutMs, scope = 'deps') {
1680
2513
  return new Promise((resolveRun, rejectRun) => {
1681
2514
  if (j.canceled) { rejectRun(new Error('canceled')); return; }
1682
- const child = spawn(cmd, args, { env: { ...process.env, ...proxyEnv() }, windowsHide: true });
2515
+ const child = spawn(cmd, args, { env: { ...process.env, ...proxyEnv({}, scope) }, windowsHide: true });
1683
2516
  j.kill = () => { try { child.kill('SIGKILL'); } catch { /* ignore */ } };
1684
2517
  const feed = (d) => { j.output = (j.output + d.toString()).slice(-65536); };
1685
2518
  child.stdout.on('data', feed);
@@ -1698,10 +2531,62 @@ function jobRun(j, cmd, args, timeoutMs) {
1698
2531
  });
1699
2532
  }
1700
2533
 
2534
+ // rtk 资产/校验和下载:有 curl 时经子进程下载(jobRun 注入 deps 代理并提供取消
2535
+ // 与超时;-f 让 HTTP 错误直接失败,不会把错误页写成文件,此路径无百分比进度)。
2536
+ // 无 curl 的精简 Windows 用 node fetch 流式写文件兜底:进度按 5% 一档喂给升级
2537
+ // 任务,j.canceled 时立即中止(fetch 不读代理环境变量,配了代理又无 curl 的机器
2538
+ // 在这条路径只能直连——与 WebDAV 同步的 fetch 兜底同一限制)
2539
+ async function downloadToFile(j, url, dest, timeoutMs = 600000) {
2540
+ const curl = findCurl();
2541
+ if (curl) {
2542
+ await jobRun(j, curl,
2543
+ ['-fsSL', '--connect-timeout', '20', '--max-time', String(Math.ceil(timeoutMs / 1000)), '-o', dest, url],
2544
+ timeoutMs);
2545
+ return;
2546
+ }
2547
+ const res = await fetch(url, { signal: AbortSignal.timeout(timeoutMs) });
2548
+ if (!res.ok || !res.body) throw new Error(`下载失败 HTTP ${res.status}`);
2549
+ const total = Number(res.headers.get('content-length')) || 0;
2550
+ const out = createWriteStream(dest);
2551
+ let got = 0;
2552
+ let lastPct = -1;
2553
+ try {
2554
+ for await (const chunk of res.body) {
2555
+ if (j.canceled) throw new Error('canceled');
2556
+ if (!out.write(chunk)) await new Promise((r) => out.once('drain', r));
2557
+ got += chunk.length;
2558
+ if (total > 0) {
2559
+ const pct = Math.floor((got / total) * 100);
2560
+ if (pct >= lastPct + 5) { lastPct = pct; j.output += `下载中 ${pct}%\n`; }
2561
+ }
2562
+ }
2563
+ await new Promise((resolve, reject) => { out.end(resolve); out.once('error', reject); });
2564
+ } catch (err) {
2565
+ try { out.destroy(); } catch { /* ignore */ }
2566
+ throw err;
2567
+ }
2568
+ }
2569
+
1701
2570
  // headroom 程序探测:镜像钩子 hr_find_headroom 的查找清单(PATH + 常见安装位置)
1702
2571
  function headroomFindBin() {
1703
- return findBinFile(
1704
- process.platform === 'win32' ? ['headroom.exe', 'headroom.cmd'] : ['headroom'],
2572
+ // Windows 兜底:pip 的 console script 落在各 Python 环境的 Scripts 目录(.exe),
2573
+ // Scripts 目录不一定在桌面应用的 PATH 里
2574
+ if (process.platform === 'win32') {
2575
+ const home = homedir();
2576
+ const pfRoots = [...new Set([process.env.PROGRAMFILES || 'C:\\Program Files', 'C:\\Program Files', 'C:\\ProgramFiles'])];
2577
+ const extra = [
2578
+ join(home, '.local', 'bin', 'headroom.exe'),
2579
+ join(home, 'miniforge3', 'Scripts', 'headroom.exe'),
2580
+ join(home, 'miniconda3', 'Scripts', 'headroom.exe'),
2581
+ join(home, 'anaconda3', 'Scripts', 'headroom.exe'),
2582
+ ];
2583
+ for (const pf of pfRoots) {
2584
+ extra.push(join(pf, 'miniforge3', 'Scripts', 'headroom.exe'), join(pf, 'miniconda3', 'Scripts', 'headroom.exe'));
2585
+ for (const v of ['313', '312', '311']) extra.push(join(pf, `Python${v}`, 'Scripts', 'headroom.exe'));
2586
+ }
2587
+ return findBinFile(['headroom.exe'], extra);
2588
+ }
2589
+ return findBinFile(['headroom'],
1705
2590
  [join(homedir(), '.local', 'bin', 'headroom'), join(homedir(), 'miniforge3', 'bin', 'headroom'),
1706
2591
  join(homedir(), '.cargo', 'bin', 'headroom'), '/usr/local/bin/headroom', '/usr/bin/headroom']);
1707
2592
  }
@@ -1712,10 +2597,11 @@ async function headroomBinaryInfo() {
1712
2597
  return { installed: true, binPath, version: await binVersionOf(binPath) };
1713
2598
  }
1714
2599
 
1715
- // 通用命令执行(headroom 本体的 pip 检查/升级用),输出合并 stdout+stderr
1716
- function execCapture(cmd, args, timeoutMs = 30000) {
2600
+ // 通用命令执行(headroom 本体的 pip 检查/升级等),输出合并 stdout+stderr,
2601
+ // 环境按用途注入代理(默认 deps:headroom/rtk;市场路径不走这里)
2602
+ function execCapture(cmd, args, timeoutMs = 30000, scope = 'deps') {
1717
2603
  return new Promise((resolveRun) => {
1718
- execFile(cmd, args, { timeout: timeoutMs, env: { ...process.env, ...proxyEnv() }, windowsHide: true }, (err, stdout, stderr) => {
2604
+ execFile(cmd, args, { timeout: timeoutMs, env: { ...process.env, ...proxyEnv({}, scope) }, windowsHide: true }, (err, stdout, stderr) => {
1719
2605
  resolveRun({
1720
2606
  ok: !err,
1721
2607
  code: err ? (err.code ?? 'error') : 0,
@@ -1729,6 +2615,19 @@ function execCapture(cmd, args, timeoutMs = 30000) {
1729
2615
  // headroom 本体(pip 安装)的升级通道:控制台脚本 shebang 指向安装它的 Python
1730
2616
  // 解释器,检查更新与升级都通过该环境的 pip 执行(尊重用户 pip 源配置)。
1731
2617
  function headroomPythonOf(binPath) {
2618
+ // Windows:pip console script 是 .exe(无 shebang),从所在目录推导——
2619
+ // Scripts\headroom.exe → 同环境根目录的 python.exe
2620
+ if (process.platform === 'win32') {
2621
+ const dir = dirname(binPath);
2622
+ if (basename(dir).toLowerCase() === 'scripts') {
2623
+ const py = join(dirname(dir), 'python.exe');
2624
+ if (existsSync(py)) return py;
2625
+ }
2626
+ const sibling = join(dir, 'python.exe');
2627
+ if (existsSync(sibling)) return sibling;
2628
+ return headroomFallbackPython();
2629
+ }
2630
+ // POSIX:读脚本的 shebang 行
1732
2631
  try {
1733
2632
  const first = (readFileSync(binPath, 'utf8').split('\n')[0] || '').trim();
1734
2633
  const m = /^#!\s*(.*python\S*)/.exec(first);
@@ -1747,9 +2646,35 @@ const HEADROOM_DIST_PY = [
1747
2646
  " print(n + '\\t' + d.version); break",
1748
2647
  ].join('\n');
1749
2648
 
2649
+ // 本体未装时的全新安装通道:找可用的 Python(PATH + 常见安装位置),
2650
+ // 用官方发行版 headroom-ai[all] 走同一套 pip 升级任务
2651
+ function headroomFallbackPython() {
2652
+ const WIN = process.platform === 'win32';
2653
+ const home = homedir();
2654
+ if (WIN) {
2655
+ const pfRoots = [...new Set([process.env.PROGRAMFILES || 'C:\\Program Files', 'C:\\Program Files', 'C:\\ProgramFiles'])];
2656
+ const extra = [
2657
+ join(home, 'miniforge3', 'python.exe'),
2658
+ join(home, 'miniconda3', 'python.exe'),
2659
+ join(home, 'anaconda3', 'python.exe'),
2660
+ ];
2661
+ for (const pf of pfRoots) {
2662
+ extra.push(join(pf, 'miniforge3', 'python.exe'), join(pf, 'miniconda3', 'python.exe'));
2663
+ for (const v of ['313', '312', '311']) extra.push(join(pf, `Python${v}`, 'python.exe'));
2664
+ }
2665
+ return findBinFile(['python.exe'], extra);
2666
+ }
2667
+ return findBinFile(['python3', 'python'],
2668
+ ['/usr/bin/python3', '/usr/local/bin/python3', join(home, '.local', 'bin', 'python3')]);
2669
+ }
2670
+
1750
2671
  async function headroomPipChannel() {
1751
2672
  const bin = headroomFindBin();
1752
- if (!bin) return { code: 'headroom-update', error: '未找到 headroom 程序' };
2673
+ if (!bin) {
2674
+ const py = headroomFallbackPython();
2675
+ if (!py) return { code: 'headroom-update', error: '未找到 headroom 程序,也未找到可用的 Python(全新安装需要 Python ≥ 3.10)' };
2676
+ return { py, dist: 'headroom-ai[all]', current: null, freshInstall: true };
2677
+ }
1753
2678
  const py = headroomPythonOf(bin);
1754
2679
  if (!py) return { code: 'headroom-update', error: '无法确定 headroom 的 Python 安装来源(不是 pip 控制台脚本)' };
1755
2680
  const r = await execCapture(py, ['-c', HEADROOM_DIST_PY], 20000);
@@ -1791,15 +2716,27 @@ function startHeadroomUpgrade(channel) {
1791
2716
  return headroomUpgradeSnapshot();
1792
2717
  }
1793
2718
 
1794
- // 运行 headroom 插件钩子(ensure-proxy.sh,POSIX sh):backend/power 写配置并立即
1795
- // 生效,status/start/stop/restart 管理代理生命周期
1796
- function runHeadroomHook(args, timeoutMs = 20000) {
2719
+ // 运行 headroom 插件钩子:新版插件(≥1.2.0)是跨平台 node 入口 ensure-proxy.mjs
2720
+ // (Windows 原生实现、POSIX 内部委托 ensure-proxy.sh),经 runNodeScript 跑(自动
2721
+ // 处理 node/内嵌 node 回退);旧版只有 .sh 时用 sh 直跑(仅 POSIX)。
2722
+ // backend/power 写配置并立即生效,status/start/stop/restart 管理代理生命周期。
2723
+ // 钩子运行注意两点:
2724
+ // 1. 不注入下载代理(scope 'none')——钩子不是安装/升级下载,代理范围开关不管它;
2725
+ // 2. localhost 强制不走代理——钩子里 curl 探测 http://127.0.0.1:<端口> 判断代理
2726
+ // 是否在跑,环境里残留的 http_proxy(包括 zcode-pro 自己注入的下载代理)会把
2727
+ // 探测请求送进代理服务器,代理停了就把在跑的 headroom 误判成未运行,面板一直
2728
+ // 卡在“正在启动”。
2729
+ async function runHeadroomHook(args, timeoutMs = 30000) {
2730
+ const noProxy = ['127.0.0.1', 'localhost', process.env.NO_PROXY || process.env.no_proxy || ''].filter(Boolean).join(',');
2731
+ const hookEnv = { NO_PROXY: noProxy, no_proxy: noProxy };
2732
+ const mjs = findMarketplaceHook('headroom', join('hooks', 'ensure-proxy.mjs'));
2733
+ if (mjs) return runNodeScript([mjs, ...args], timeoutMs, 'none', hookEnv);
1797
2734
  const hook = findMarketplaceHook('headroom', join('hooks', 'ensure-proxy.sh'));
1798
2735
  if (!hook) {
1799
- return Promise.resolve({ ok: false, code: 'headroom-plugin', error: '未找到 headroom 插件(先在插件市场安装)' });
2736
+ return { ok: false, code: 'headroom-plugin', error: '未找到 headroom 插件(先在插件市场安装)' };
1800
2737
  }
1801
2738
  return new Promise((resolveRun) => {
1802
- execFile('sh', [hook, ...args], { timeout: timeoutMs, windowsHide: true }, (err, stdout, stderr) => {
2739
+ execFile('sh', [hook, ...args], { timeout: timeoutMs, windowsHide: true, env: { ...process.env, ...hookEnv } }, (err, stdout, stderr) => {
1803
2740
  if (err && err.code === 'ENOENT') {
1804
2741
  resolveRun({ ok: false, code: 'headroom-sh', error: '未找到 sh,无法运行 headroom 插件脚本' });
1805
2742
  return;
@@ -1840,11 +2777,102 @@ export function parseHeadroomStatus(text) {
1840
2777
  };
1841
2778
  }
1842
2779
 
2780
+ // —— headroom 启用开关(设置弹窗“Headroom”标签页右上角):只切换 ZCode 供应商配置
2781
+ // 里指向本地压缩代理(http://127.0.0.1:<端口>)的条目与直连上游之间的地址,绝不碰
2782
+ // 代理进程——headroom 是共享程序,其他工具(如 codex)可能同时在用。
2783
+ // 关闭时把条目改回直连地址(headroom 的默认转发上游),并在 zcodepro.json 的
2784
+ // headroomRoutes 里记下 { 供应商 id → { proxy, direct } };开启时按记录切回代理。
2785
+ const HEADROOM_DIRECT_URL = 'https://open.bigmodel.cn/api/anthropic';
2786
+ const HEADROOM_LOCAL_URL_RE = /^http:\/\/127\.0\.0\.1:\d+\/?$/;
2787
+
2788
+ // 扫描 v2/config.json(provider.<id>.options.baseURL)与 v2/provider_config.json
2789
+ // (providerRules[].config.api.baseUrl),统计当前走本地代理的条目数
2790
+ export function headroomUsageScan(dataRoot) {
2791
+ let via = 0;
2792
+ const v2 = readJsonFile(join(dataRoot, 'v2', 'config.json'));
2793
+ for (const p of Object.values((v2 && typeof v2.provider === 'object' && v2.provider) || {})) {
2794
+ const u = typeof p?.options?.baseURL === 'string' ? p.options.baseURL.trim() : '';
2795
+ if (HEADROOM_LOCAL_URL_RE.test(u)) via += 1;
2796
+ }
2797
+ const pc = readJsonFile(join(dataRoot, 'v2', 'provider_config.json'));
2798
+ for (const r of pc?.config?.providerConfigRules?.providerRules || []) {
2799
+ const u = typeof r?.config?.api?.baseUrl === 'string' ? r.config.api.baseUrl.trim() : '';
2800
+ if (HEADROOM_LOCAL_URL_RE.test(u)) via += 1;
2801
+ }
2802
+ return { enabled: via > 0, entries: via };
2803
+ }
2804
+
2805
+ // 切换启用状态:enable=false 时 127.0.0.1 条目 → 直连(并记录);enable=true 时按
2806
+ // 记录把直连条目切回代理。改写前各留 .zcodepro-backup 备份,与同步导入同款。
2807
+ export function setHeadroomUsage(dataRoot, configFile, enable) {
2808
+ const backupFile = (p) => {
2809
+ try { if (existsSync(p)) copyFileSync(p, p + '.zcodepro-backup'); } catch { /* 备份失败不阻塞 */ }
2810
+ };
2811
+ const atomicWrite = (p, obj) => {
2812
+ const tmp = p + '.zcodepro-tmp';
2813
+ writeFileSync(tmp, JSON.stringify(obj, null, 2) + '\n', 'utf8');
2814
+ renameSync(tmp, p);
2815
+ };
2816
+ const v2File = join(dataRoot, 'v2', 'config.json');
2817
+ const pcFile = join(dataRoot, 'v2', 'provider_config.json');
2818
+ const v2 = readJsonFile(v2File);
2819
+ const pc = readJsonFile(pcFile);
2820
+ if (!v2 && !pc) return { ok: false, code: 'headroom-provider-config', error: '未找到 ZCode 供应商配置(v2/config.json)' };
2821
+
2822
+ const config = loadConfig(configFile);
2823
+ const routes = { ...(config.headroomRoutes || {}) };
2824
+ let changed = 0;
2825
+ // 供应商 id 来自配置文件,跳过保留键:对普通对象按 ["__proto__"] 赋值改的是原型,
2826
+ // 不是自有属性,记录会悄悄丢失
2827
+ const reserved = (id) => id === '__proto__' || id === 'constructor' || id === 'prototype';
2828
+ const apply = (get, set, id) => {
2829
+ const u = get();
2830
+ if (!enable && HEADROOM_LOCAL_URL_RE.test(u)) {
2831
+ routes[id] = { proxy: u, direct: HEADROOM_DIRECT_URL };
2832
+ set(HEADROOM_DIRECT_URL);
2833
+ changed += 1;
2834
+ return true;
2835
+ }
2836
+ if (enable && routes[id] && u === routes[id].direct) {
2837
+ set(routes[id].proxy);
2838
+ changed += 1;
2839
+ return true;
2840
+ }
2841
+ return false;
2842
+ };
2843
+
2844
+ if (v2 && v2.provider && typeof v2.provider === 'object') {
2845
+ let touched = false;
2846
+ for (const [id, p] of Object.entries(v2.provider)) {
2847
+ if (reserved(id) || !p || typeof p !== 'object' || !p.options || typeof p.options !== 'object') continue;
2848
+ const u = typeof p.options.baseURL === 'string' ? p.options.baseURL.trim() : '';
2849
+ if (apply(() => u, (v) => { p.options.baseURL = v; }, id)) touched = true;
2850
+ }
2851
+ if (touched) { backupFile(v2File); atomicWrite(v2File, v2); }
2852
+ }
2853
+ if (Array.isArray(pc?.config?.providerConfigRules?.providerRules)) {
2854
+ let touched = false;
2855
+ for (const r of pc.config.providerConfigRules.providerRules) {
2856
+ const id = typeof r?.providerId === 'string' ? r.providerId : '';
2857
+ const api = r?.config?.api;
2858
+ if (!id || reserved(id) || !api || typeof api.baseUrl !== 'string') continue;
2859
+ const u = api.baseUrl.trim();
2860
+ if (apply(() => u, (v) => { api.baseUrl = v; }, id)) touched = true;
2861
+ }
2862
+ if (touched) { backupFile(pcFile); atomicWrite(pcFile, pc); }
2863
+ }
2864
+ config.headroomRoutes = routes;
2865
+ saveConfig(configFile, config);
2866
+ return { ok: true, changed };
2867
+ }
2868
+
1843
2869
  // 用 node 跑脚本:优先 PATH 里的 node;桌面环境没有时用自身进程
1844
2870
  // (zcode-pro 本就跑在 ELECTRON_RUN_AS_NODE=1 的 ZCode 二进制上)。导出以便测试。
1845
- export function runNodeScript(scriptArgs, timeoutMs = 120000) {
1846
- const attempt = (cmd, extraEnv) => new Promise((resolveRun) => {
1847
- execFile(cmd, scriptArgs, { timeout: timeoutMs, env: { ...process.env, ...proxyEnv(), ...extraEnv }, windowsHide: true }, (err, stdout, stderr) => {
2871
+ // scope 默认 deps(headroom、rtk 的安装与升级);插件市场的 zcode CLI 传 plugins;
2872
+ // extraEnv 是调用方要额外覆盖的环境变量(如 NO_PROXY)
2873
+ export function runNodeScript(scriptArgs, timeoutMs = 120000, scope = 'deps', extraEnv = {}) {
2874
+ const attempt = (cmd, runEnv) => new Promise((resolveRun) => {
2875
+ execFile(cmd, scriptArgs, { timeout: timeoutMs, env: { ...process.env, ...proxyEnv({}, scope), ...extraEnv, ...runEnv }, windowsHide: true }, (err, stdout, stderr) => {
1848
2876
  if (err && err.code === 'ENOENT') { resolveRun(null); return; }
1849
2877
  resolveRun({
1850
2878
  ok: !err,
@@ -1857,25 +2885,35 @@ export function runNodeScript(scriptArgs, timeoutMs = 120000) {
1857
2885
  return attempt('node', {}).then((r) => r || attempt(process.execPath, { ELECTRON_RUN_AS_NODE: '1' }));
1858
2886
  }
1859
2887
 
1860
- // ZCode CLI bundle(plugins 子命令入口):按常见安装位置查找,可用环境变量覆盖
2888
+ // ZCode CLI bundle(plugins 子命令入口):优先从定位到的 ZCode 可执行文件推导
2889
+ // (paths.mjs 覆盖 --zcode-path/环境变量/平台默认位置/Windows 注册表,含
2890
+ // C:\Program Files\ZCode 安装),再退回常见安装位置,可用环境变量覆盖
1861
2891
  function zcodeCliBundleCandidates() {
2892
+ const fromExe = [];
2893
+ try {
2894
+ const exe = resolveZcodeExecutable();
2895
+ if (exe) fromExe.push(join(dirname(exe), 'resources', 'glm', 'zcode.cjs'));
2896
+ } catch { /* 未找到 ZCode 可执行文件时走静态候选 */ }
1862
2897
  return [
1863
2898
  process.env.ZCODEPRO_ZCODE_CLI,
2899
+ ...fromExe,
1864
2900
  '/opt/ZCode/resources/glm/zcode.cjs',
1865
2901
  '/usr/lib/zcode/resources/glm/zcode.cjs',
1866
2902
  '/usr/share/zcode/resources/glm/zcode.cjs',
1867
2903
  '/Applications/ZCode.app/Contents/Resources/glm/zcode.cjs',
1868
2904
  process.env.LOCALAPPDATA ? join(process.env.LOCALAPPDATA, 'Programs', 'ZCode', 'resources', 'glm', 'zcode.cjs') : null,
2905
+ process.env.PROGRAMFILES ? join(process.env.PROGRAMFILES, 'ZCode', 'resources', 'glm', 'zcode.cjs') : null,
1869
2906
  ].filter(Boolean);
1870
2907
  }
1871
2908
 
1872
- // 运行 zcode CLI 子命令(plugins …)
1873
- function runZcodeCli(subArgs, timeoutMs = 120000) {
2909
+ // 运行 zcode CLI 子命令(plugins … 默认走 plugins 代理范围;智能起名的
2910
+ // 无界面提问传 none——模型 API 直连,不走下载代理)
2911
+ function runZcodeCli(subArgs, timeoutMs = 120000, scope = 'plugins') {
1874
2912
  const bundle = zcodeCliBundleCandidates().find((c) => existsSync(c));
1875
2913
  if (!bundle) {
1876
2914
  return Promise.resolve({ ok: false, code: 'cli-not-found', error: '未找到 ZCode CLI(resources/glm/zcode.cjs)' });
1877
2915
  }
1878
- return runNodeScript([bundle, ...subArgs], timeoutMs);
2916
+ return runNodeScript([bundle, ...subArgs], timeoutMs, scope);
1879
2917
  }
1880
2918
 
1881
2919
  // 比对市场清单与已装清单:每个插件的已装版本与最新版本。导出以便测试。
@@ -1896,6 +2934,21 @@ export function pluginUpdateStatus() {
1896
2934
  return { plugins, updates: plugins.filter((e) => e.hasUpdate) };
1897
2935
  }
1898
2936
 
2937
+ // 市场未添加时自动补装(新机器上设置面板的“安装/更新”按钮一键可用):
2938
+ // 读 known_marketplaces.json 判断,缺失则跑 plugins marketplace add。
2939
+ // 失败 30 分钟内不重试(add 是 git clone,GitHub 不通时每次面板请求都会白等
2940
+ // 最长 120 秒);返回 null = 已存在无需处理,{ ok, cached } = 执行或跳过了 add。
2941
+ let lastMarketplaceAddFail = 0;
2942
+ async function ensureZcodePluginsMarketplace() {
2943
+ const known = readJsonFile(join(cliPluginsDir(), 'known_marketplaces.json'));
2944
+ const ids = new Set((known?.marketplaces || []).map((m) => m?.id).filter(Boolean));
2945
+ if (ids.has(ZCODE_PLUGINS_MARKETPLACE)) return null;
2946
+ if (Date.now() - lastMarketplaceAddFail < 30 * 60 * 1000) return { ok: false, cached: true, error: '上次添加市场失败,30 分钟内不再自动重试' };
2947
+ const r = await runZcodeCli(['plugins', 'marketplace', 'add', ZCODE_PLUGINS_MARKETPLACE_SOURCE], 120000);
2948
+ if (!r.ok) lastMarketplaceAddFail = Date.now();
2949
+ return { ok: r.ok, output: r.output, error: r.error };
2950
+ }
2951
+
1899
2952
  // 读全局提示词(~/.zcode/AGENTS.md);文件不存在视为空内容。
1900
2953
  export function readAgents(agentsFile) {
1901
2954
  try {
@@ -1931,12 +2984,30 @@ export function writeAgents(body, agentsFile) {
1931
2984
  return [200, { ok: true, content: body.content }];
1932
2985
  }
1933
2986
 
2987
+ // 路径不存在时的还原尝试。宿主把消息里的反斜杠路径渲染成文件 chip 时,markdown
2988
+ // 会把 “\.” 当转义序列吃掉反斜杠(Administrator\.zcode 变成 Administrator.zcode),
2989
+ // 其余分隔符再统一显示为 /,chip 上拿到的就是这种坏路径。把每个“段名.点开头后缀”
2990
+ // 形式的段试着拆回两级(Administrator.zcode → Administrator/.zcode),哪个变体
2991
+ // 真实存在就用哪个;都不存在返回 null,按原路径走 not-found。导出以便测试脚本直接驱动。
2992
+ export function recoverSwallowedSeparator(rawPath) {
2993
+ const segs = String(rawPath).split(/[\\/]/);
2994
+ for (let i = 0; i < segs.length; i++) {
2995
+ const dot = segs[i].indexOf('.');
2996
+ if (dot <= 0) continue; // 段里没有点,或本身就是 .zcode 这类点开头目录
2997
+ const candidate = [...segs.slice(0, i), segs[i].slice(0, dot), segs[i].slice(dot), ...segs.slice(i + 1)].join(sep);
2998
+ try {
2999
+ if (existsSync(candidate)) return candidate;
3000
+ } catch { /* ignore */ }
3001
+ }
3002
+ return null;
3003
+ }
3004
+
1934
3005
  // 在系统文件管理器中定位文件:
1935
3006
  // Linux 走 freedesktop FileManager1.ShowItems(Dolphin/Nautilus 均支持),无会话服务时回退打开所在目录;
1936
3007
  // macOS 用 open -R;Windows 用 explorer /select。导出以便测试脚本直接驱动。
1937
3008
  export function revealInFileManager(rawPath) {
1938
3009
  return new Promise((resolveReveal) => {
1939
- const p = typeof rawPath === 'string' ? rawPath.trim() : '';
3010
+ let p = typeof rawPath === 'string' ? rawPath.trim() : '';
1940
3011
  if (!p || !isAbsolute(p)) {
1941
3012
  resolveReveal([400, { ok: false, code: 'invalid-path', error: '无效的路径' }]);
1942
3013
  return;
@@ -1991,25 +3062,46 @@ export function openFolder(rawPath) {
1991
3062
  });
1992
3063
  }
1993
3064
 
1994
- // 用系统默认应用打开文件(或文件夹):macOS 用 open;Windows 用 explorer;
1995
- // Linux 用 xdg-open。导出以便测试脚本直接驱动。
3065
+ // 用系统默认应用打开文件(或文件夹):macOS 用 open;Windows 走 rundll32 的
3066
+ // FileProtocolHandler(等价 ShellExecute,启动失败时退回 cmd start);Linux 用
3067
+ // xdg-open。导出以便测试脚本直接驱动。Windows 上不能用 explorer.exe <文件路径>:
3068
+ // 那只会在资源管理器里定位该文件(打开所在目录并选中),不会启动默认关联应用。
1996
3069
  export function openWithDefaultApp(rawPath) {
1997
3070
  return new Promise((resolveOpen) => {
1998
- const p = typeof rawPath === 'string' ? rawPath.trim() : '';
3071
+ let p = typeof rawPath === 'string' ? rawPath.trim() : '';
1999
3072
  if (!p || !isAbsolute(p)) {
2000
3073
  resolveOpen([400, { ok: false, code: 'invalid-path', error: '无效的路径' }]);
2001
3074
  return;
2002
3075
  }
2003
3076
  if (!existsSync(p)) {
2004
- resolveOpen([400, { ok: false, code: 'not-found', error: '文件不存在' }]);
2005
- return;
3077
+ // chip 带来的路径可能被宿主渲染弄坏(见 recoverSwallowedSeparator),先试还原
3078
+ const recovered = recoverSwallowedSeparator(p);
3079
+ if (!recovered) {
3080
+ resolveOpen([400, { ok: false, code: 'not-found', error: '文件不存在' }]);
3081
+ return;
3082
+ }
3083
+ p = recovered;
2006
3084
  }
2007
3085
  const done = (err) => resolveOpen(err ? [500, { ok: false, error: err.message }] : [200, { ok: true }]);
2008
3086
  if (process.platform === 'darwin') execFile('open', [p], { timeout: 5000 }, done);
2009
3087
  else if (process.platform === 'win32') {
2010
- const child = spawn('explorer.exe', [p], { detached: true, stdio: 'ignore' });
2011
- child.on('error', done);
2012
- child.on('close', () => done(null));
3088
+ // rundll32 的参数不经 shell 解释,路径里的空格、%、& 等字符都安全;
3089
+ // start 的第一个引号参数是窗口标题,兜底时必须先给空标题再给路径。
3090
+ // 进程拉起即算成功:系统里没有该类型的关联应用时 rundll32 会弹
3091
+ // “你要如何打开这个文件”对话框并一直挂着,不能等它退出才回话
3092
+ let settled = false;
3093
+ const finish = (err) => {
3094
+ if (settled) return;
3095
+ settled = true;
3096
+ done(err);
3097
+ };
3098
+ const child = spawn('rundll32.exe', ['url.dll,FileProtocolHandler', p], { detached: true, stdio: 'ignore', windowsHide: true });
3099
+ child.on('spawn', () => finish(null));
3100
+ child.on('error', () => {
3101
+ const viaCmd = spawn('cmd.exe', [`/d /s /c start "" "${p}"`], { windowsVerbatimArguments: true, detached: true, stdio: 'ignore', windowsHide: true });
3102
+ viaCmd.on('spawn', () => finish(null));
3103
+ viaCmd.on('error', finish);
3104
+ });
2013
3105
  } else execFile('xdg-open', [p], { timeout: 5000 }, done);
2014
3106
  });
2015
3107
  }