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.
- package/README.en.md +1 -0
- package/README.md +1 -0
- package/dist/inject.js +1368 -216
- package/package.json +1 -1
- package/src/host/helper.mjs +1248 -156
- package/src/host/main.mjs +11 -0
- package/src/host/pickFolder.mjs +48 -3
- package/src/host/setup.mjs +34 -9
- package/src/host/taskIndex.mjs +83 -0
- package/src/host/webdav.mjs +128 -12
- package/src/inject/core.js +190 -42
- package/src/inject/features/file-menu.js +9 -2
- package/src/inject/features/plugin-updates.js +1 -1
- package/src/inject/features/settings-dialog.js +821 -118
- package/src/inject/features/styles.js +15 -1
- package/src/inject/features/title-gen.js +169 -0
- package/src/inject/features/title-lock.js +14 -0
- package/src/inject/features/toolbar-icons.js +38 -4
- package/src/inject/index.js +14 -2
- package/src/inject/provider-fields.js +71 -0
- package/src/inject/ui.js +23 -7
package/src/host/helper.mjs
CHANGED
|
@@ -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.
|
|
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
|
|
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
|
-
//
|
|
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
|
-
|
|
194
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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 &&
|
|
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
|
-
|
|
765
|
-
|
|
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
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
}
|
|
867
|
-
return {
|
|
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
|
-
|
|
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
|
|
916
|
-
url:
|
|
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
|
-
|
|
965
|
-
|
|
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
|
-
//
|
|
1270
|
-
|
|
1797
|
+
// 视觉代理「跟随供应商」下拉取数:与智能起名共用同一套供应商列表和过滤规则
|
|
1798
|
+
//(见 listTitleGenProviders),只回 id/名称/别名,不含密钥。
|
|
1271
1799
|
// 导出以便测试脚本直接驱动。
|
|
1272
1800
|
export function listVisionProviders() {
|
|
1273
|
-
|
|
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
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
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
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
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
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
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
|
|
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
|
-
|
|
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'
|
|
1452
|
-
|
|
1453
|
-
|
|
1454
|
-
|
|
1455
|
-
|
|
1456
|
-
|
|
1457
|
-
|
|
1458
|
-
|
|
1459
|
-
//
|
|
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
|
-
|
|
1468
|
-
const
|
|
1469
|
-
'https://github.com/rtk-ai/rtk/releases/latest'
|
|
1470
|
-
|
|
1471
|
-
|
|
1472
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
1528
|
-
|
|
1529
|
-
|
|
1530
|
-
|
|
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
|
-
|
|
1538
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
1581
|
-
//
|
|
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
|
|
1586
|
-
|
|
1587
|
-
|
|
1588
|
-
|
|
1589
|
-
|
|
1590
|
-
|
|
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
|
-
|
|
1704
|
-
|
|
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
|
|
1716
|
-
|
|
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)
|
|
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
|
|
1795
|
-
//
|
|
1796
|
-
|
|
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
|
|
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
|
-
|
|
1846
|
-
|
|
1847
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
1995
|
-
// Linux 用
|
|
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
|
-
|
|
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
|
-
|
|
2005
|
-
|
|
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
|
-
|
|
2011
|
-
|
|
2012
|
-
|
|
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
|
}
|