zcode-pro 0.19.0 → 0.20.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/zcode-pro.mjs +32 -9
- package/dist/inject.js +229 -127
- package/package.json +1 -1
- package/src/host/helper.mjs +325 -35
- package/src/host/main.mjs +8 -4
- package/src/host/setup.mjs +45 -42
- package/src/host/taskIndex.mjs +186 -6
- package/src/inject/core.js +23 -15
- package/src/inject/features/settings-dialog.js +85 -49
- package/src/inject/features/styles.js +1 -1
- package/src/inject/features/task-order.js +1 -1
- package/src/inject/features/title-gen.js +10 -1
- package/src/inject/features/title-lock.js +59 -1
- package/src/inject/features/ws-running.js +1 -1
- package/src/inject/provider-fields.js +2 -2
package/src/host/helper.mjs
CHANGED
|
@@ -4,19 +4,20 @@
|
|
|
4
4
|
|
|
5
5
|
import { createServer } from 'node:http';
|
|
6
6
|
import { execFile, spawn, spawnSync } from 'node:child_process';
|
|
7
|
-
import {
|
|
7
|
+
import { createDecipheriv, createHash } from 'node:crypto';
|
|
8
|
+
import { appendFileSync, chmodSync, copyFileSync, createWriteStream, existsSync, mkdirSync, readdirSync, readFileSync, readlinkSync, realpathSync, renameSync, rmSync, statSync, unlinkSync, writeFileSync } from 'node:fs';
|
|
8
9
|
import { mkdtemp } from 'node:fs/promises'; // 注意:fs 的 mkdtemp 是回调版,await 它会把 undefined 当回调
|
|
9
10
|
import { basename, delimiter, dirname, isAbsolute, join, resolve, sep } from 'node:path';
|
|
10
|
-
import { homedir, tmpdir } from 'node:os';
|
|
11
|
+
import { homedir, tmpdir, userInfo } from 'node:os';
|
|
11
12
|
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
12
13
|
import { readSettings, writeSettingsAtomic, remapSettingsPaths, isProjectOpenInTabs } from './settings.mjs';
|
|
13
|
-
import { taskIndexPath, probeTaskIndexWritable, remapTaskIndexPaths, taskIndexDriverAvailable, listOverriddenTitles, listTaskWorkspaceMap, setTaskTitleIfNotOverridden, getTaskProviderModel } from './taskIndex.mjs';
|
|
14
|
+
import { taskIndexPath, probeTaskIndexWritable, remapTaskIndexPaths, taskIndexDriverAvailable, listOverriddenTitles, listTaskWorkspaceMap, setTaskTitleIfNotOverridden, setCustomSessionTitle, isTaskTitleOverridden, reconcileCustomTitles, getTaskProviderModel } from './taskIndex.mjs';
|
|
14
15
|
import { pickFolderSystem } from './pickFolder.mjs';
|
|
15
16
|
import { reorderWorkspaceTasks, reorderGroupMembers } from './taskOrder.mjs';
|
|
16
17
|
import { webdavRequest, webdavPutWithMkcol, webdavHttpText, parseWebdavListing, findCurl } from './webdav.mjs';
|
|
17
18
|
import { resolveZcodeExecutable } from './paths.mjs';
|
|
18
19
|
|
|
19
|
-
const VERSION = '0.
|
|
20
|
+
const VERSION = '0.20.1';
|
|
20
21
|
|
|
21
22
|
// 全局提示词固定在用户主目录:官方加载器按 HOME/USERPROFILE 拼 .zcode/AGENTS.md,
|
|
22
23
|
// 不读 ZCODE_DATA_BASE_DIR(数据根迁走时全局指令仍在原位)。
|
|
@@ -104,6 +105,7 @@ export function defaultConfig() {
|
|
|
104
105
|
//(用会话当前模型);prompt 留空 = 内置默认提示词
|
|
105
106
|
titleGen: {
|
|
106
107
|
enabled: false,
|
|
108
|
+
customWritten: {},
|
|
107
109
|
provider: 'session',
|
|
108
110
|
model: 'glm-5.3-flash',
|
|
109
111
|
prompt: '',
|
|
@@ -118,14 +120,16 @@ export function defaultConfig() {
|
|
|
118
120
|
// 软件(headroom 与 rtk)的安装、检查更新与升级;sync = WebDAV 同步。同步默认
|
|
119
121
|
// 关闭——坚果云等国内网盘通常直连即可,代理停了同步会跟着失败
|
|
120
122
|
proxyScopes: { plugins: true, deps: true, sync: false },
|
|
121
|
-
//
|
|
123
|
+
// 开发模式(设置弹窗“调试”标签页):开启后出现“重载”按钮,可在不重启
|
|
122
124
|
// ZCode 的情况下加载新代码(构建 → 换新启动器进程 → 各窗口自动刷新重注入)
|
|
123
125
|
devMode: false,
|
|
124
126
|
// 设置同步(设置弹窗“同步”标签页,交互参考 Tampermonkey 的手动同步):
|
|
125
127
|
// 仅 WebDAV、仅在点击按钮时传输(type 字段预留多后端扩展,当前恒为 webdav)。
|
|
126
128
|
// url 为 WebDAV 服务器地址;dir 为其下的上传目录(默认 zcode-pro,避免直接
|
|
127
129
|
// 写进网盘根目录);include 选择同步内容;凭据只存本机、不同步出去;
|
|
128
|
-
// lastPushAt/lastPullAt
|
|
130
|
+
// lastPushAt/lastPullAt 只记录本机操作时间;historyLimit 为云端历史版本保留
|
|
131
|
+
// 个数(同一次上传的各组文件算一个,上传成功后自动删除更早的;0 = 不留历史,
|
|
132
|
+
// 只有固定名最新版)。
|
|
129
133
|
sync: {
|
|
130
134
|
type: 'webdav',
|
|
131
135
|
url: '',
|
|
@@ -133,6 +137,7 @@ export function defaultConfig() {
|
|
|
133
137
|
login: '',
|
|
134
138
|
password: '',
|
|
135
139
|
include: { zcodepro: true, model: true },
|
|
140
|
+
historyLimit: 10,
|
|
136
141
|
lastPushAt: null,
|
|
137
142
|
lastPullAt: null,
|
|
138
143
|
},
|
|
@@ -246,6 +251,20 @@ function proxyUrlFor(scope) {
|
|
|
246
251
|
return activeProxyScopes[scope] ? activeProxyUrl : '';
|
|
247
252
|
}
|
|
248
253
|
|
|
254
|
+
// 智能起名诊断日志:追加到 <dataRoot>/zcodepro.log(与启动诊断同文件),超 512KB
|
|
255
|
+
// 轮转 .old。起名过程注入侧静默(失败不打扰用户),出问题时靠这里回查
|
|
256
|
+
let titleGenLogRotated = false;
|
|
257
|
+
function titleGenLog(dataRoot, line) {
|
|
258
|
+
try {
|
|
259
|
+
const file = join(dataRoot, 'zcodepro.log');
|
|
260
|
+
if (!titleGenLogRotated && existsSync(file) && statSync(file).size > 512 * 1024) {
|
|
261
|
+
renameSync(file, file + '.old');
|
|
262
|
+
titleGenLogRotated = true;
|
|
263
|
+
}
|
|
264
|
+
appendFileSync(file, `[${new Date().toISOString()}] title-gen ${line}\n`);
|
|
265
|
+
} catch { /* 日志失败不影响功能 */ }
|
|
266
|
+
}
|
|
267
|
+
|
|
249
268
|
export function startHelper({ port, token, dataRoot, state, agentsFile = defaultAgentsFile() }) {
|
|
250
269
|
const configFile = join(dataRoot, 'zcodepro.json');
|
|
251
270
|
const settingsFile = join(dataRoot, 'v2', 'setting.json');
|
|
@@ -418,6 +437,13 @@ export function startHelper({ port, token, dataRoot, state, agentsFile = default
|
|
|
418
437
|
// 注入侧据此把被自动标题覆盖的侧栏行钉回重命名值;响应带开关,
|
|
419
438
|
// 注入侧按需拉取这一个端点即可(无轮询)
|
|
420
439
|
if (req.method === 'GET' && url.pathname === '/pinned-titles') {
|
|
440
|
+
// 先自动校正:桌面端打开会话时会把运行时缓存的旧标题写回任务索引(实测),
|
|
441
|
+
// 对我们写过且 CLI custom 仍是所写标题的会话,把索引改回来再下发
|
|
442
|
+
const config0 = loadConfig(configFile);
|
|
443
|
+
if (config0.titleGen.customWritten && Object.keys(config0.titleGen.customWritten).length) {
|
|
444
|
+
const repaired = await reconcileCustomTitles(dataRoot, config0.titleGen.customWritten);
|
|
445
|
+
if (repaired.length) titleGenLog(dataRoot, `reconciled sess=${repaired.map((s) => s.slice(5, 13)).join(',')}`);
|
|
446
|
+
}
|
|
421
447
|
const r = await listOverriddenTitles(dataRoot);
|
|
422
448
|
if (r.error) {
|
|
423
449
|
json(res, 500, { ok: false, code: r.code, error: r.error });
|
|
@@ -488,9 +514,43 @@ export function startHelper({ port, token, dataRoot, state, agentsFile = default
|
|
|
488
514
|
return;
|
|
489
515
|
}
|
|
490
516
|
const sessId = typeof body?.sessId === 'string' && /^sess_[A-Za-z0-9._-]+$/.test(body.sessId) ? body.sessId : '';
|
|
491
|
-
|
|
517
|
+
let title;
|
|
518
|
+
try {
|
|
519
|
+
title = await generateTitleWith(cfg, text, dataRoot, sessId);
|
|
520
|
+
} catch (err) {
|
|
521
|
+
titleGenLog(dataRoot, `generate-failed sess=${sessId.slice(0, 13)} err=${err?.message || err}`);
|
|
522
|
+
throw err; // 交给外层统一 500
|
|
523
|
+
}
|
|
492
524
|
let written = false;
|
|
525
|
+
let store = ''; // CLI 会话库写入结果:'' 成功 | skipped 原因 | 'error'
|
|
493
526
|
if (sessId && body.test !== true) {
|
|
527
|
+
// 已被手动重命名(或前次起名已写过)就整体跳过——手动重命名永远优先,
|
|
528
|
+
// 也避免起名晚到的 CLI 写入盖掉用户的 custom 标题
|
|
529
|
+
if (await isTaskTitleOverridden(dataRoot, sessId)) {
|
|
530
|
+
json(res, 200, { ok: true, title, written: false, store: 'skipped: overridden' });
|
|
531
|
+
return;
|
|
532
|
+
}
|
|
533
|
+
// 双写,顺序不能反:先写 CLI 会话库(title_source=custom,权威层——
|
|
534
|
+
// 官方自动起名看到 custom 会放弃,桌面同步器会把 custom 标题带着
|
|
535
|
+
// overridden=1 送回任务索引),再写任务索引(展示层,立即上屏)。
|
|
536
|
+
// 只写任务索引时,官方运行时排队的自动标题会在几秒后把它覆盖(实测)。
|
|
537
|
+
// CLI 库不可写(旧版无 title_source 列 / 无该会话行)不阻断:退回
|
|
538
|
+
// 单写任务索引的旧行为;任务索引随后失败也不回滚 CLI——同步器会补
|
|
539
|
+
for (let attempt = 0; ; attempt++) {
|
|
540
|
+
try {
|
|
541
|
+
const r = await setCustomSessionTitle(dataRoot, sessId, title);
|
|
542
|
+
store = r.written ? '' : (r.skipped || 'skipped');
|
|
543
|
+
break;
|
|
544
|
+
} catch (err) {
|
|
545
|
+
if (err.code === 'index-busy' && attempt < 2) {
|
|
546
|
+
await new Promise((r) => setTimeout(r, 1200));
|
|
547
|
+
continue;
|
|
548
|
+
}
|
|
549
|
+
store = 'error: ' + (err?.message || err);
|
|
550
|
+
titleGenLog(dataRoot, `cli-store-error sess=${sessId.slice(0, 13)} err=${err?.message || err}`);
|
|
551
|
+
break;
|
|
552
|
+
}
|
|
553
|
+
}
|
|
494
554
|
// 写库撞上宿主写入时稍候重试(起名不急这几秒)
|
|
495
555
|
for (let attempt = 0; ; attempt++) {
|
|
496
556
|
try {
|
|
@@ -502,12 +562,19 @@ export function startHelper({ port, token, dataRoot, state, agentsFile = default
|
|
|
502
562
|
await new Promise((r) => setTimeout(r, 1200));
|
|
503
563
|
continue;
|
|
504
564
|
}
|
|
565
|
+
titleGenLog(dataRoot, `index-error sess=${sessId.slice(0, 13)} err=${err?.message || err}`);
|
|
505
566
|
json(res, 500, { ok: false, code: err.code || 'index-error', error: err.message });
|
|
506
567
|
return;
|
|
507
568
|
}
|
|
508
569
|
}
|
|
509
570
|
}
|
|
510
|
-
|
|
571
|
+
// CLI 库写成功了才登记回查记录(reconcileCustomTitles 只认这些记录校正)
|
|
572
|
+
if (sessId && store === '') {
|
|
573
|
+
const c = loadConfig(configFile);
|
|
574
|
+
c.titleGen.customWritten = { ...(c.titleGen.customWritten || {}), [sessId]: { title, at: Date.now() } };
|
|
575
|
+
saveConfig(configFile, c);
|
|
576
|
+
}
|
|
577
|
+
json(res, 200, { ok: true, title, written, store });
|
|
511
578
|
return;
|
|
512
579
|
}
|
|
513
580
|
// —— zcode-vision 插件(图片视觉代理):设置弹窗“视觉代理”标签页读写 ————
|
|
@@ -910,7 +977,7 @@ export function startHelper({ port, token, dataRoot, state, agentsFile = default
|
|
|
910
977
|
json(res, r.status || 500, { ok: false, code: r.code || 'sync-run', error: r.error });
|
|
911
978
|
return;
|
|
912
979
|
}
|
|
913
|
-
json(res, 200, { ok: true, direction, applied: r.applied, sync: loadConfig(configFile).sync });
|
|
980
|
+
json(res, 200, { ok: true, direction, applied: r.applied, ...(typeof r.pruned === 'number' ? { pruned: r.pruned } : {}), sync: loadConfig(configFile).sync });
|
|
914
981
|
return;
|
|
915
982
|
}
|
|
916
983
|
// —— 同步连通性测试(“测试”按钮):用表单当前值逐步验证(可先测再存),不写入配置文件 ——
|
|
@@ -1076,7 +1143,7 @@ export function startHelper({ port, token, dataRoot, state, agentsFile = default
|
|
|
1076
1143
|
if (r.ok) updated += 1;
|
|
1077
1144
|
} else if (!p.installed && only) {
|
|
1078
1145
|
// 安装只走单插件入口(name 指定=各面板的安装卡):
|
|
1079
|
-
//
|
|
1146
|
+
// 全量“检查并更新”/启动自动更新只升级已装插件——卸载必须被尊重,
|
|
1080
1147
|
// 未装插件一律由用户在各 tab 显式点安装
|
|
1081
1148
|
const r = await runZcodeCli(['plugins', 'install', `${p.name}@${ZCODE_PLUGINS_MARKETPLACE}`], 120000);
|
|
1082
1149
|
steps.push({ step: `install ${p.name}`, ok: r.ok, output: r.output });
|
|
@@ -1143,6 +1210,18 @@ function mergeTitleGenConfig(saved) {
|
|
|
1143
1210
|
// 旧版配置的 'cli'(登录账号单独成模式)统一迁移为 'session':
|
|
1144
1211
|
// 登录账号的套餐已在供应商列表里,跟随会话即覆盖原语义
|
|
1145
1212
|
const provider = typeof s.provider === 'string' ? s.provider.trim().slice(0, 200) : d.provider;
|
|
1213
|
+
// customWritten:起名功能写过的 { sessId → { title, at } },自动校正只认这些
|
|
1214
|
+
// 记录(见 reconcileCustomTitles)。只收合法会话 id 与短字符串,超 7 天/500 条的丢弃
|
|
1215
|
+
const written = {};
|
|
1216
|
+
const cutoff = Date.now() - 7 * 24 * 3600 * 1000;
|
|
1217
|
+
let kept = 0;
|
|
1218
|
+
for (const [k, v] of Object.entries((s.customWritten && typeof s.customWritten === 'object' && !Array.isArray(s.customWritten)) ? s.customWritten : {})) {
|
|
1219
|
+
if (kept >= 500 || !/^sess_[A-Za-z0-9._-]+$/.test(k)) continue;
|
|
1220
|
+
if (!v || typeof v !== 'object' || typeof v.title !== 'string' || v.title.length > 200) continue;
|
|
1221
|
+
if (!Number.isFinite(v.at) || v.at < cutoff) continue;
|
|
1222
|
+
written[k] = { title: v.title, at: v.at };
|
|
1223
|
+
kept++;
|
|
1224
|
+
}
|
|
1146
1225
|
return {
|
|
1147
1226
|
enabled: s.enabled === true,
|
|
1148
1227
|
provider: provider === 'cli' ? 'session' : provider,
|
|
@@ -1151,6 +1230,7 @@ function mergeTitleGenConfig(saved) {
|
|
|
1151
1230
|
baseUrl: typeof s.baseUrl === 'string' ? s.baseUrl.trim().slice(0, 500) : d.baseUrl,
|
|
1152
1231
|
apiKey: typeof s.apiKey === 'string' ? s.apiKey.trim().slice(0, 500) : d.apiKey,
|
|
1153
1232
|
format: s.format === 'openai' ? 'openai' : s.format === 'anthropic' ? 'anthropic' : '',
|
|
1233
|
+
customWritten: written,
|
|
1154
1234
|
};
|
|
1155
1235
|
}
|
|
1156
1236
|
|
|
@@ -1179,13 +1259,16 @@ function mergeSyncConfig(saved) {
|
|
|
1179
1259
|
login: typeof saved.login === 'string' ? saved.login.slice(0, 300) : '',
|
|
1180
1260
|
password: typeof saved.password === 'string' ? saved.password.slice(0, 300) : '',
|
|
1181
1261
|
include: { zcodepro: inc.zcodepro !== false, model: inc.model !== false },
|
|
1262
|
+
historyLimit: Number.isInteger(saved.historyLimit) && saved.historyLimit >= 0 && saved.historyLimit <= 100
|
|
1263
|
+
? saved.historyLimit : d.historyLimit,
|
|
1182
1264
|
lastPushAt: Number.isFinite(saved.lastPushAt) ? saved.lastPushAt : null,
|
|
1183
1265
|
lastPullAt: Number.isFinite(saved.lastPullAt) ? saved.lastPullAt : null,
|
|
1184
1266
|
};
|
|
1185
1267
|
}
|
|
1186
1268
|
|
|
1187
1269
|
// POST /sync 表单校验:url/login/password/dir 全量替换(dir 缺省时不动已有值)。
|
|
1188
|
-
// URL 必填,指向 WebDAV 服务器(可含路径前缀);dir
|
|
1270
|
+
// URL 必填,指向 WebDAV 服务器(可含路径前缀);dir 为其下的上传目录,可多级;
|
|
1271
|
+
// historyLimit 可选(缺省时不动已有值),0–100 的整数
|
|
1189
1272
|
function validateSyncForm(raw) {
|
|
1190
1273
|
const url = typeof raw.url === 'string' ? raw.url.trim() : '';
|
|
1191
1274
|
const login = typeof raw.login === 'string' ? raw.login.trim() : '';
|
|
@@ -1206,6 +1289,12 @@ function validateSyncForm(raw) {
|
|
|
1206
1289
|
}
|
|
1207
1290
|
out.dir = dir;
|
|
1208
1291
|
}
|
|
1292
|
+
if (raw.historyLimit !== undefined && raw.historyLimit !== null) {
|
|
1293
|
+
if (!Number.isInteger(raw.historyLimit) || raw.historyLimit < 0 || raw.historyLimit > 100) {
|
|
1294
|
+
return { error: '历史版本保留个数需为 0–100 的整数' };
|
|
1295
|
+
}
|
|
1296
|
+
out.historyLimit = raw.historyLimit;
|
|
1297
|
+
}
|
|
1209
1298
|
return { sync: out };
|
|
1210
1299
|
}
|
|
1211
1300
|
|
|
@@ -1299,8 +1388,10 @@ async function runSettingsSync({ direction, dataRoot, configFile, config, proxyU
|
|
|
1299
1388
|
// 上传:每组独立一个云端文件(zcodepro.json / model.json,始终最新一版),勾选几组
|
|
1300
1389
|
// 写几个;写前预读云端内容,别名与本机的合并后再写(合并规则见 mergeAliases),
|
|
1301
1390
|
// 其余内容整份覆盖。同一份内容再写一个带时间戳的历史版本文件,供下载时任选版本
|
|
1302
|
-
// 恢复(旧版 zcode-pro
|
|
1391
|
+
// 恢复(旧版 zcode-pro 仍读固定名);历史版本最多保留 historyLimit 个(同一时间戳
|
|
1392
|
+
// 的各组文件算一个),上传成功后自动删除更早的
|
|
1303
1393
|
async function runSyncPush({ dataRoot, configFile, config, sync, proxyUrl }) {
|
|
1394
|
+
const keep = Number.isInteger(sync.historyLimit) ? sync.historyLimit : defaultConfig().sync.historyLimit;
|
|
1304
1395
|
const docs = {};
|
|
1305
1396
|
if (sync.include.zcodepro) {
|
|
1306
1397
|
// sync(本机的同步配置与凭据)与 version(本机 helper 版本)不外发
|
|
@@ -1345,10 +1436,13 @@ async function runSyncPush({ dataRoot, configFile, config, sync, proxyUrl }) {
|
|
|
1345
1436
|
if (put.httpCode < 200 || put.httpCode >= 300) {
|
|
1346
1437
|
return { code: 'sync-request', status: 502, error: webdavHttpText(put.httpCode) };
|
|
1347
1438
|
}
|
|
1348
|
-
|
|
1349
|
-
if (
|
|
1350
|
-
|
|
1351
|
-
return { code: 'sync-request', status: 502, error:
|
|
1439
|
+
// 历史版本只在保留个数 > 0 时写(0 = 只留固定名最新版,由上传后清理收尾)
|
|
1440
|
+
if (keep > 0) {
|
|
1441
|
+
const putVersion = await putUrl(syncVersionName(kind, stamp));
|
|
1442
|
+
if (!putVersion.ok) return { code: 'sync-request', status: 502, error: putVersion.error };
|
|
1443
|
+
if (putVersion.httpCode < 200 || putVersion.httpCode >= 300) {
|
|
1444
|
+
return { code: 'sync-request', status: 502, error: webdavHttpText(putVersion.httpCode) };
|
|
1445
|
+
}
|
|
1352
1446
|
}
|
|
1353
1447
|
pushed.push(kind);
|
|
1354
1448
|
}
|
|
@@ -1357,7 +1451,9 @@ async function runSyncPush({ dataRoot, configFile, config, sync, proxyUrl }) {
|
|
|
1357
1451
|
}
|
|
1358
1452
|
sync.lastPushAt = Date.now();
|
|
1359
1453
|
saveConfig(configFile, config);
|
|
1360
|
-
|
|
1454
|
+
// 上传成功后清理超出保留个数的旧历史版本(best-effort,失败不影响上传结果)
|
|
1455
|
+
const pruned = await pruneSyncHistory(sync, proxyUrl, keep);
|
|
1456
|
+
return { applied: pushed, pruned };
|
|
1361
1457
|
}
|
|
1362
1458
|
|
|
1363
1459
|
// 下载:把云端所选组应用到本机(各组独立文件,勾选几组读几组,可只覆盖模型设置)。
|
|
@@ -1500,6 +1596,39 @@ async function listSyncVersions(sync, proxyUrl) {
|
|
|
1500
1596
|
return { files };
|
|
1501
1597
|
}
|
|
1502
1598
|
|
|
1599
|
+
// 从云端文件名列表选出待删除的历史版本(导出以便测试脚本直接驱动):带时间戳的
|
|
1600
|
+
// 文件按时间戳段分组——同一次上传的各组文件共用一个时间戳,算“一个”;时间戳
|
|
1601
|
+
// 段定宽零填充,字典序即时间序。保留最近 keep 个,返回更早的各组文件名;
|
|
1602
|
+
// 固定名(zcodepro.json / model.json)和不相干的文件名不会出现在结果里
|
|
1603
|
+
export function syncHistoryStaleNames(names, keep) {
|
|
1604
|
+
const groups = new Map();
|
|
1605
|
+
for (const name of names) {
|
|
1606
|
+
const ts = (String(name).match(/^(?:zcodepro|model)(-\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}-\d{3}Z)\.json$/) || [])[1];
|
|
1607
|
+
if (!ts) continue;
|
|
1608
|
+
if (!groups.has(ts)) groups.set(ts, []);
|
|
1609
|
+
groups.get(ts).push(name);
|
|
1610
|
+
}
|
|
1611
|
+
// 时间戳段按新 → 旧排,slice(keep) 剩下的即超出保留个数的更早各组
|
|
1612
|
+
return [...groups.keys()].sort((a, b) => b.localeCompare(a)).slice(Math.max(0, keep)).flatMap((ts) => groups.get(ts));
|
|
1613
|
+
}
|
|
1614
|
+
|
|
1615
|
+
// 上传后清理云端目录里超出保留个数的历史版本:列出文件,删掉 syncHistoryStaleNames
|
|
1616
|
+
// 归出的旧文件。best-effort——列目录或单个删除失败只跳过(下次上传再清),不抛错;
|
|
1617
|
+
// 返回本次删掉的文件数
|
|
1618
|
+
async function pruneSyncHistory(sync, proxyUrl, keep) {
|
|
1619
|
+
const list = await listSyncVersions(sync, proxyUrl);
|
|
1620
|
+
if (list.error) return 0;
|
|
1621
|
+
const stale = syncHistoryStaleNames(list.files.map((f) => f.name), keep);
|
|
1622
|
+
let removed = 0;
|
|
1623
|
+
for (const name of stale) {
|
|
1624
|
+
const r = await webdavRequest({ method: 'DELETE', url: syncFileUrlByName(sync, name), login: sync.login, password: sync.password, proxyUrl, timeoutMs: 30000 });
|
|
1625
|
+
if (!r.ok) continue; // 网络失败:跳过该文件
|
|
1626
|
+
if (r.httpCode !== 404 && (r.httpCode < 200 || r.httpCode >= 300)) continue; // 404 = 已不在,视为已清
|
|
1627
|
+
removed++;
|
|
1628
|
+
}
|
|
1629
|
+
return removed;
|
|
1630
|
+
}
|
|
1631
|
+
|
|
1503
1632
|
// 别名表清洗(同步下载用):只收 路径字符串 → 短字符串 的条目,数量限幅
|
|
1504
1633
|
function sanitizeAliases(raw) {
|
|
1505
1634
|
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null;
|
|
@@ -1607,7 +1736,7 @@ export async function scanIdleBusy(paths, dataRoot) {
|
|
|
1607
1736
|
return SCAN_OFF;
|
|
1608
1737
|
}
|
|
1609
1738
|
|
|
1610
|
-
// Linux
|
|
1739
|
+
// Linux:直接读取 /proc(comm 定位 CLI、cwd 定位项目、task/<pid>/children 拿子进程)
|
|
1611
1740
|
function scanIdleBusyLinux(paths) {
|
|
1612
1741
|
let pids;
|
|
1613
1742
|
try { pids = readdirSync('/proc').filter((d) => /^\d+$/.test(d)); }
|
|
@@ -1794,7 +1923,7 @@ function sanitizeFontStack(v) {
|
|
|
1794
1923
|
return s;
|
|
1795
1924
|
}
|
|
1796
1925
|
|
|
1797
|
-
//
|
|
1926
|
+
// 视觉代理“跟随供应商”下拉取数:与智能起名共用同一套供应商列表和过滤规则
|
|
1798
1927
|
//(见 listTitleGenProviders),只回 id/名称/别名,不含密钥。
|
|
1799
1928
|
// 导出以便测试脚本直接驱动。
|
|
1800
1929
|
export function listVisionProviders() {
|
|
@@ -1853,7 +1982,7 @@ const TITLE_GEN_TIMEOUT_MS = 60000; // 单次模型调用超时(与官方起
|
|
|
1853
1982
|
//(coding-plan 与 start-plan 都叫 "Coding Plan"),界面里按套餐类型显示。
|
|
1854
1983
|
// coding-plan 的个人/团队是动态的:从桌面设置(v2/setting.json 的
|
|
1855
1984
|
// providerFamilyConnectionSelections)读当前登录的连接方式(kind 含
|
|
1856
|
-
// individual → 个人、team →
|
|
1985
|
+
// individual → 个人、team → 团队),读不到时退回“个人/团队”
|
|
1857
1986
|
function codingPlanKindByFamily() {
|
|
1858
1987
|
const s = readJsonFile(join(homedir(), '.zcode', 'v2', 'setting.json'));
|
|
1859
1988
|
const sel = (s && s.providerFamilyConnectionSelections && typeof s.providerFamilyConnectionSelections === 'object')
|
|
@@ -1962,7 +2091,7 @@ async function callAnthropicText(baseUrl, apiKey, model, content) {
|
|
|
1962
2091
|
body: JSON.stringify({ model, max_tokens: 2048, messages: [{ role: 'user', content }] }),
|
|
1963
2092
|
});
|
|
1964
2093
|
const body = await resp.text();
|
|
1965
|
-
if (!resp.ok) throw new Error(`HTTP ${resp.status}: ${body.slice(0, 300)}`);
|
|
2094
|
+
if (!resp.ok) throw new Error(`HTTP ${resp.status} [model=${model}]: ${body.slice(0, 300)}`);
|
|
1966
2095
|
const doc = JSON.parse(body);
|
|
1967
2096
|
const text = Array.isArray(doc.content)
|
|
1968
2097
|
? doc.content.filter((b) => b && b.type === 'text').map((b) => b.text || '').join('') : '';
|
|
@@ -1988,7 +2117,7 @@ async function callOpenAiText(baseUrl, apiKey, model, content) {
|
|
|
1988
2117
|
body: JSON.stringify({ model, max_tokens: 2048, messages: [{ role: 'user', content }] }),
|
|
1989
2118
|
});
|
|
1990
2119
|
const body = await resp.text();
|
|
1991
|
-
if (!resp.ok) throw new Error(`HTTP ${resp.status}: ${body.slice(0, 300)}`);
|
|
2120
|
+
if (!resp.ok) throw new Error(`HTTP ${resp.status} [model=${model}]: ${body.slice(0, 300)}`);
|
|
1992
2121
|
const doc = JSON.parse(body);
|
|
1993
2122
|
const text = doc.choices?.[0]?.message?.content || '';
|
|
1994
2123
|
if (typeof text !== 'string' || !text.trim()) throw new Error('响应中没有文本内容');
|
|
@@ -2008,7 +2137,12 @@ async function callOpenAiText(baseUrl, apiKey, model, content) {
|
|
|
2008
2137
|
export async function generateTitleWith(cfg, rawText, dataRoot = '', sessId = '') {
|
|
2009
2138
|
const text = String(rawText || '').trim().replace(/\s+/g, ' ').slice(0, TITLE_GEN_MAX_TEXT);
|
|
2010
2139
|
const prompt = (cfg.prompt && cfg.prompt.trim()) || titleGenDefaultPrompt();
|
|
2011
|
-
|
|
2140
|
+
// 注入当前日期(上海时区):起名模型是无状态的,自定义提示词里要求
|
|
2141
|
+
// “MMDD 取对话创建日期”时它无从得知,只能留字面量 MMDD
|
|
2142
|
+
const now = new Date(Date.now() + 8 * 3600 * 1000);
|
|
2143
|
+
const pad = (n) => String(n).padStart(2, '0');
|
|
2144
|
+
const dateLine = `当前日期(Asia/Shanghai):${now.getUTCFullYear()}-${pad(now.getUTCMonth() + 1)}-${pad(now.getUTCDate())}(MMDD 即 ${pad(now.getUTCMonth() + 1)}${pad(now.getUTCDate())})`;
|
|
2145
|
+
const userContent = `${prompt}\n\n${dateLine}\n\n会话的用户消息:\n${text}`;
|
|
2012
2146
|
const providerId = (cfg.provider || '').trim();
|
|
2013
2147
|
const model = (cfg.model || '').trim();
|
|
2014
2148
|
const conn = await resolveTitleGenConnection(cfg, providerId, model, dataRoot, sessId);
|
|
@@ -2020,16 +2154,165 @@ export async function generateTitleWith(cfg, rawText, dataRoot = '', sessId = ''
|
|
|
2020
2154
|
return title;
|
|
2021
2155
|
}
|
|
2022
2156
|
|
|
2157
|
+
// 供应商 id 的匹配归一化(导出以便测试):会话索引里登录账号供应商带 account:
|
|
2158
|
+
// 前缀(如 account:bigmodel-individual-coding-plan),供应商列表里同名条目带
|
|
2159
|
+
// builtin: 前缀(builtin:bigmodel-coding-plan),个人/团队是同一端点——剥前缀、
|
|
2160
|
+
// 抹掉 individual/team 差异后按同一家匹配
|
|
2161
|
+
export function normalizeProviderIdForMatch(id) {
|
|
2162
|
+
return String(id || '')
|
|
2163
|
+
.replace(/^(account|builtin):/, '')
|
|
2164
|
+
.replace(/-(individual|team)-/, '-');
|
|
2165
|
+
}
|
|
2166
|
+
|
|
2167
|
+
// —— 登录账号供应商(Coding Plan)直连凭据的换取 ——
|
|
2168
|
+
// Coding Plan 的直连密钥不是静态保存的,官方桌面/CLI 用 OAuth access_token
|
|
2169
|
+
// 实时换“项目 token”。这里复刻同款流程(协议读自官方 project-access-token-
|
|
2170
|
+
// client.ts),让只登录、从未配置过供应商的机器也能直连起名:
|
|
2171
|
+
// ① 解密 v2/credentials.json 里的 oauth:<family>:access_token——加密格式
|
|
2172
|
+
// enc:v1:<iv>.<authTag>.<cipher>(AES-256-GCM),密钥推导与官方
|
|
2173
|
+
// credential-cipher.ts 一致(ZCODE_CREDENTIAL_SECRET 环境变量优先,否则
|
|
2174
|
+
// sha256("zcode-credential-fallback:平台:主目录:用户名")),同机同用户可解;
|
|
2175
|
+
// ② GET getCustomerInfo 定位个人(非团队)项目——优先名称含“默认机构/
|
|
2176
|
+
// 默认项目”的条目,否则取第一个;
|
|
2177
|
+
// ③ 在项目的 api_keys 里找官方建的 zcode-api-key(没有则创建);
|
|
2178
|
+
// ④ POST …/access_tokens 换项目 token(临期前进程内缓存复用)。
|
|
2179
|
+
// 管理接口的鉴权:bigmodel 用原始 token、zai 带 Bearer 前缀;签发接口两家
|
|
2180
|
+
// 都用 Bearer——与官方实现一致
|
|
2181
|
+
const ACCOUNT_FAMILY_BASE = {
|
|
2182
|
+
bigmodel: { manageOrigin: 'https://bigmodel.cn', apiBase: 'https://open.bigmodel.cn/api/anthropic' },
|
|
2183
|
+
zai: { manageOrigin: 'https://api.z.ai', apiBase: 'https://api.z.ai/api/anthropic' },
|
|
2184
|
+
};
|
|
2185
|
+
const accountTokenCache = new Map(); // family → { token, expiresAt }
|
|
2186
|
+
|
|
2187
|
+
// 解密 credentials.json 的 enc:v1: 值(非加密值原样返回;失败返回空串)
|
|
2188
|
+
export function decryptCredentialValue(value) {
|
|
2189
|
+
const s = String(value || '');
|
|
2190
|
+
if (!s.startsWith('enc:v1:')) return s;
|
|
2191
|
+
const [ivRaw, tagRaw, ctRaw] = s.slice('enc:v1:'.length).split('.');
|
|
2192
|
+
if (!ivRaw || !tagRaw || !ctRaw) return '';
|
|
2193
|
+
try {
|
|
2194
|
+
const secret = (process.env.ZCODE_CREDENTIAL_SECRET || '').trim()
|
|
2195
|
+
|| `zcode-credential-fallback:${process.platform}:${homedir()}:${userInfo().username}`;
|
|
2196
|
+
const key = createHash('sha256').update(secret).digest();
|
|
2197
|
+
const d = createDecipheriv('aes-256-gcm', key, Buffer.from(ivRaw, 'base64url'));
|
|
2198
|
+
d.setAuthTag(Buffer.from(tagRaw, 'base64url'));
|
|
2199
|
+
return Buffer.concat([d.update(Buffer.from(ctRaw, 'base64url')), d.final()]).toString('utf8');
|
|
2200
|
+
} catch { return ''; }
|
|
2201
|
+
}
|
|
2202
|
+
|
|
2203
|
+
// 换取登录账号的项目 token;成功返回 { token, apiBase, expiresAt }
|
|
2204
|
+
export async function exchangeAccountProjectToken(family, dataRoot) {
|
|
2205
|
+
const conf = ACCOUNT_FAMILY_BASE[family];
|
|
2206
|
+
if (!conf) throw new Error(`不支持的供应商族 ${family}`);
|
|
2207
|
+
const cached = accountTokenCache.get(family);
|
|
2208
|
+
if (cached && Date.now() < cached.expiresAt - 5 * 60 * 1000) {
|
|
2209
|
+
return { token: cached.token, apiBase: conf.apiBase, expiresAt: cached.expiresAt };
|
|
2210
|
+
}
|
|
2211
|
+
const creds = readJsonFile(join(dataRoot, 'v2', 'credentials.json'));
|
|
2212
|
+
const loginToken = (creds && typeof creds === 'object')
|
|
2213
|
+
? decryptCredentialValue(creds[`oauth:${family}:access_token`]) : '';
|
|
2214
|
+
if (!loginToken) throw new Error(`credentials.json 里没有可解的 ${family} 登录令牌(未登录或已过期)`);
|
|
2215
|
+
|
|
2216
|
+
// BIGMODEL_API_BASE_URL:官方同款测试环境覆盖,顺带让协议可注入测试
|
|
2217
|
+
const origin = (family === 'bigmodel' && (process.env.BIGMODEL_API_BASE_URL || '').trim())
|
|
2218
|
+
|| conf.manageOrigin;
|
|
2219
|
+
// 管理接口沿用旧鉴权格式(bigmodel 原始 token / zai Bearer);签发接口用 Bearer
|
|
2220
|
+
const manageHeaders = {
|
|
2221
|
+
'content-type': 'application/json',
|
|
2222
|
+
authorization: family === 'zai' ? `Bearer ${loginToken}` : loginToken,
|
|
2223
|
+
};
|
|
2224
|
+
const api = async (path, init = {}) => {
|
|
2225
|
+
const ctrl = new AbortController();
|
|
2226
|
+
const timer = setTimeout(() => ctrl.abort(), 20000);
|
|
2227
|
+
try {
|
|
2228
|
+
const resp = await fetch(origin + path, { ...init, signal: ctrl.signal, headers: manageHeaders });
|
|
2229
|
+
const doc = await resp.json().catch(() => null);
|
|
2230
|
+
const data = (doc && [200, 0, '200', '0'].includes(doc.code)) ? doc.data : null;
|
|
2231
|
+
if (data === null) throw new Error(`接口 ${path} 返回异常(${resp.status})`);
|
|
2232
|
+
return data;
|
|
2233
|
+
} finally { clearTimeout(timer); }
|
|
2234
|
+
};
|
|
2235
|
+
|
|
2236
|
+
// ① 客户信息 → ② 定位个人项目(projectType "2" 是团队项目,个人密钥不能挂在团队下)
|
|
2237
|
+
const cust = await api('/api/biz/customer/getCustomerInfo');
|
|
2238
|
+
const orgs = (Array.isArray(cust?.organizations) ? cust.organizations : [])
|
|
2239
|
+
.map((o) => ({ id: String(o?.organizationId || '').trim(), name: String(o?.organizationName || ''),
|
|
2240
|
+
projects: (Array.isArray(o?.projects) ? o.projects : [])
|
|
2241
|
+
.filter((p) => String(p?.projectType ?? '').trim() !== '2' && String(p?.projectId || '').trim()) }));
|
|
2242
|
+
const org = orgs.find((o) => o.id && o.projects.length && o.name.includes('默认机构')) || orgs.find((o) => o.id && o.projects.length);
|
|
2243
|
+
const project = org && (org.projects.find((p) => String(p?.projectName || '').includes('默认项目')) || org.projects[0]);
|
|
2244
|
+
if (!org || !project) throw new Error('账号下没有可用的个人项目(未订阅 Coding Plan?)');
|
|
2245
|
+
|
|
2246
|
+
// ③ 找官方建的 zcode-api-key,没有就建一个
|
|
2247
|
+
const keysUrl = `/api/biz/v1/organization/${encodeURIComponent(org.id)}/projects/${encodeURIComponent(project.projectId)}/api_keys`;
|
|
2248
|
+
const keys = await api(keysUrl);
|
|
2249
|
+
const keyEntry = (Array.isArray(keys) ? keys : []).find((k) => k && typeof k === 'object' && k.name === 'zcode-api-key')
|
|
2250
|
+
|| await api(keysUrl, { method: 'POST', body: JSON.stringify({ name: 'zcode-api-key', usageScene: 1 }) });
|
|
2251
|
+
const apiKeyId = keyEntry && String(keyEntry.apiKey || '').trim();
|
|
2252
|
+
if (!apiKeyId) throw new Error('未能定位 Coding Plan 的 API Key');
|
|
2253
|
+
|
|
2254
|
+
// ④ 签发项目 token(此接口用 Bearer 鉴权;超时与 api() 同款)
|
|
2255
|
+
const issueCtrl = new AbortController();
|
|
2256
|
+
const issueTimer = setTimeout(() => issueCtrl.abort(), 20000);
|
|
2257
|
+
let issueResp;
|
|
2258
|
+
try {
|
|
2259
|
+
issueResp = await fetch(`${origin}${keysUrl}/${encodeURIComponent(apiKeyId)}/access_tokens`, {
|
|
2260
|
+
method: 'POST',
|
|
2261
|
+
signal: issueCtrl.signal,
|
|
2262
|
+
headers: { 'content-type': 'application/json', authorization: `Bearer ${loginToken}` },
|
|
2263
|
+
body: JSON.stringify({ clientType: 'zcode', clientVersion: VERSION }),
|
|
2264
|
+
});
|
|
2265
|
+
} finally { clearTimeout(issueTimer); }
|
|
2266
|
+
const issue = await issueResp.json().catch(() => null);
|
|
2267
|
+
const issueData = (issue && [200, 0, '200', '0'].includes(issue.code)) ? issue.data : null;
|
|
2268
|
+
if (!issueData) throw new Error(`签发项目 token 失败(${issueResp.status})`);
|
|
2269
|
+
// enable 语义与官方一致:旧版接口不回该字段(实测回的是同义的 enabled),
|
|
2270
|
+
// 缺失视为开启,仅显式 false 才算未开启
|
|
2271
|
+
if (issueData.enable === false || !issueData.accessToken) throw new Error('项目 token 未开启(enable=false)');
|
|
2272
|
+
accountTokenCache.set(family, { token: issueData.accessToken, expiresAt: Number(issueData.expiresAt) || 0 });
|
|
2273
|
+
return { token: issueData.accessToken, apiBase: conf.apiBase, expiresAt: Number(issueData.expiresAt) || 0 };
|
|
2274
|
+
}
|
|
2275
|
+
|
|
2023
2276
|
// 直连前的连接解析(见 generateTitleWith 的取值说明);解析不出抛明确错误
|
|
2024
2277
|
async function resolveTitleGenConnection(cfg, providerId, model, dataRoot, sessId) {
|
|
2025
2278
|
if (providerId === 'session') {
|
|
2026
|
-
if (!
|
|
2027
|
-
|
|
2279
|
+
if (!dataRoot) throw new Error('跟随当前会话需要会话信息');
|
|
2280
|
+
// sessId 为空(设置面板测试按钮,没有会话上下文)时取任务索引里最近更新
|
|
2281
|
+
// 的会话——与视觉代理测试按钮同款语义
|
|
2282
|
+
const task = await getTaskProviderModel(dataRoot, sessId || '');
|
|
2283
|
+
if (!task || !task.providerId) {
|
|
2284
|
+
throw new Error(sessId ? '索引中查不到该会话的供应商信息' : '任务索引里没有会话记录(“跟随当前会话”取上次会话的供应商/模型):先在任意会话里发过消息,或改选具体供应商再测试');
|
|
2285
|
+
}
|
|
2286
|
+
const list = listTitleGenProviders();
|
|
2287
|
+
const entry = list.find((p) => p.id === task.providerId)
|
|
2288
|
+
|| list.find((p) => normalizeProviderIdForMatch(p.id) === normalizeProviderIdForMatch(task.providerId));
|
|
2289
|
+
if (!entry) {
|
|
2290
|
+
// 登录账号供应商:本机没有物化密钥表(从未保存过供应商配置的机器不生成
|
|
2291
|
+
// v2/config.json)时按官方同款流程换取项目 token 直连(见下方实现)
|
|
2292
|
+
if (task.providerId.startsWith('account:')) {
|
|
2293
|
+
if (/start-plan/.test(task.providerId)) {
|
|
2294
|
+
throw new Error('会话用的是 Start Plan 登录供应商,暂不支持自动直连:请在此改选具体供应商或不跟随(自填地址)');
|
|
2295
|
+
}
|
|
2296
|
+
const family = task.providerId.includes(':zai') ? 'zai' : 'bigmodel';
|
|
2297
|
+
let accountConn;
|
|
2298
|
+
try {
|
|
2299
|
+
accountConn = await exchangeAccountProjectToken(family, dataRoot);
|
|
2300
|
+
} catch (err) {
|
|
2301
|
+
throw new Error(`登录账号供应商取直连密钥失败(${err?.message || err}):请在 ZCode 里重新登录,或在此改选具体供应商 / 不跟随`);
|
|
2302
|
+
}
|
|
2303
|
+
const useAccountModel = model || task.modelId;
|
|
2304
|
+
if (!useAccountModel) throw new Error('未能确定会话当前模型,请手动填写模型');
|
|
2305
|
+
return {
|
|
2306
|
+
format: 'anthropic',
|
|
2307
|
+
baseUrl: accountConn.apiBase,
|
|
2308
|
+
apiKey: accountConn.token,
|
|
2309
|
+
// 官方目录的模型名是大写 GLM- 前缀;会话索引里可能是小写(不同供应商
|
|
2310
|
+
// 记法不一),登录端点按目录写法规整
|
|
2311
|
+
model: /^glm-\d/.test(useAccountModel) ? useAccountModel.replace(/^glm/, 'GLM') : useAccountModel,
|
|
2312
|
+
};
|
|
2313
|
+
}
|
|
2314
|
+
throw new Error(`会话供应商 ${task.providerId} 无法在会话外直连(无密钥或协议未知)`);
|
|
2028
2315
|
}
|
|
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
2316
|
const useModel = model || task.modelId;
|
|
2034
2317
|
if (!useModel) throw new Error('未能确定会话当前模型,请手动填写模型');
|
|
2035
2318
|
return { format: entry.format, baseUrl: entry.baseUrl, apiKey: entry.apiKey, model: useModel };
|
|
@@ -2056,12 +2339,19 @@ async function resolveTitleGenConnection(cfg, providerId, model, dataRoot, sessI
|
|
|
2056
2339
|
}
|
|
2057
2340
|
|
|
2058
2341
|
// 清洗模型输出:去思考块/代码围栏/首尾引号/序号/末尾标点,取首个非空行并限长。
|
|
2059
|
-
// 模型可能沿用官方起名的习惯回 JSON 对象,此时取其 title
|
|
2342
|
+
// 模型可能沿用官方起名的习惯回 JSON 对象,此时取其 title 字段。自定义提示词常让
|
|
2343
|
+
// 模型“先说明再给标题”(如“无法直接修改会话标题……建议手动设置:”后另起一行
|
|
2344
|
+
// 【MMDD|类型|主题】),此时首个非空行是说明而非标题——优先取独立成行的
|
|
2345
|
+
// 【】/[] 包裹标题行(允许行首 markdown 标题符)。导出以便测试
|
|
2060
2346
|
export function cleanGeneratedTitle(text) {
|
|
2061
2347
|
let t = String(text || '').replace(/<think>[\s\S]*?<\/think>/gi, '').trim();
|
|
2062
|
-
//
|
|
2348
|
+
// 整段包在代码围栏里时先去掉围栏标记(先取行会把“```json”当成标题内容)
|
|
2063
2349
|
t = t.replace(/^```[a-zA-Z]*\s*\n?/, '').replace(/\n?```\s*$/, '').trim();
|
|
2064
|
-
|
|
2350
|
+
const lines = t.split(/\r?\n/).map((s) => s.trim()).filter(Boolean);
|
|
2351
|
+
// 标题行判定前剥掉 markdown 外壳(## 标题符、**加粗**、*斜体*、_下划线_、~~删除线~~)
|
|
2352
|
+
const unwrap = (s) => s.replace(/^#{1,4}\s*/, '').replace(/^[*_~]+/, '').replace(/[*_~]+$/, '').trim();
|
|
2353
|
+
const bracketed = lines.find((s) => /^(?:【[^】]{1,60}】|\[[^\]\n]{1,60}\])$/.test(unwrap(s)));
|
|
2354
|
+
let line = bracketed ? unwrap(bracketed) : (lines[0] || '');
|
|
2065
2355
|
line = line.replace(/^```[a-zA-Z]*\s*/, '').replace(/```\s*$/, '');
|
|
2066
2356
|
try {
|
|
2067
2357
|
const doc = JSON.parse(line);
|
|
@@ -2096,7 +2386,7 @@ export function validateVisionConfig(raw) {
|
|
|
2096
2386
|
apiTimeoutMs: clampInt(raw.apiTimeoutMs, 1000, 600000, 120000),
|
|
2097
2387
|
compressThresholdKB: clampInt(raw.compressThresholdKB, 0, 102400, 1024),
|
|
2098
2388
|
// 连续失败跳过(0 = 不跳过);跳过时长(分钟)
|
|
2099
|
-
skipAfterFailures: clampInt(raw.skipAfterFailures, 0, 100,
|
|
2389
|
+
skipAfterFailures: clampInt(raw.skipAfterFailures, 0, 100, 2),
|
|
2100
2390
|
skipMinutes: clampInt(raw.skipMinutes, 1, 10080, 30),
|
|
2101
2391
|
// 主模型能看图时是否也拦截识别(false = 跳过;flash 主模型始终不触发,见插件)
|
|
2102
2392
|
forceIntercept: raw.forceIntercept !== false,
|
|
@@ -2411,7 +2701,7 @@ async function ensureUserPathEntry(dir) {
|
|
|
2411
2701
|
}
|
|
2412
2702
|
|
|
2413
2703
|
// 在插件缓存里找市场插件的钩子脚本:只认 installed_plugins.json 登记的 installPath。
|
|
2414
|
-
//
|
|
2704
|
+
// 不能按目录扫描:卸载只删“当前指向”的版本目录,缓存里的历史版本目录会残留
|
|
2415
2705
|
// (升级留旧版、GitHub/本地源先后装过等),扫目录会把已卸载的插件误判为已装,
|
|
2416
2706
|
// 面板就不显示安装卡了。本地开发流(install-plugin-local.sh)同样会更新安装记录,兼容。
|
|
2417
2707
|
// 注意只认正式市场 duanluan-zcode-plugins:本地临时市场(如 duanluan-local)装的副本不在查找范围,
|
|
@@ -2430,7 +2720,7 @@ function findVisionHook() {
|
|
|
2430
2720
|
return findMarketplaceHook('zcode-vision', join('hooks', 'vision-hook.mjs'));
|
|
2431
2721
|
}
|
|
2432
2722
|
|
|
2433
|
-
// 读市场插件在 installed_plugins.json
|
|
2723
|
+
// 读市场插件在 installed_plugins.json 里登记的版本(面板信息卡显示“插件 x.y.z”用)
|
|
2434
2724
|
function installedPluginVersion(pluginName) {
|
|
2435
2725
|
const installedRaw = readJsonFile(join(cliPluginsDir(), 'installed_plugins.json'));
|
|
2436
2726
|
const rec = (installedRaw?.plugins || []).find((p) => p?.name === pluginName && p?.marketplace === ZCODE_PLUGINS_MARKETPLACE);
|
package/src/host/main.mjs
CHANGED
|
@@ -17,11 +17,15 @@ import { runSetup } from './setup.mjs';
|
|
|
17
17
|
const here = dirname(fileURLToPath(import.meta.url));
|
|
18
18
|
const INJECT_BUNDLE = resolve(here, '..', '..', 'dist', 'inject.js');
|
|
19
19
|
|
|
20
|
-
// Windows 控制台中文乱码适配:输出为 UTF-8 字节,旧版 conhost/
|
|
21
|
-
|
|
22
|
-
//
|
|
20
|
+
// Windows 控制台中文乱码适配:输出为 UTF-8 字节,旧版 conhost/cmd 的默认代码页
|
|
21
|
+
//(936/GBK)按 GBK 解码会显示乱码。检测到非 UTF-8 代码页时切到 65001——只影响
|
|
22
|
+
// 当前控制台会话(重开终端即恢复)。不能用 stdout.isTTY 单独作前提:npm 全局
|
|
23
|
+
// 入口(bin/zcode-pro.mjs)spawn ZCode 运行时跑 cli.mjs,子进程 stdout 经
|
|
24
|
+
// stdio:'inherit' 是管道(isTTY=false),但控制台乱码照样发生(0.18/0.19 用户
|
|
25
|
+
// 实拍);此时 stdin 仍连着终端,故用 stdout 或 stdin 任一 isTTY 判定。真正的
|
|
26
|
+
// 重定向场景(stdout 与 stdin 都不是 TTY)不干预。
|
|
23
27
|
// 放在模块顶层:cli.mjs 经 import main.mjs 进入,setup 等子命令的输出同样受益。
|
|
24
|
-
if (process.platform === 'win32' && process.stdout.isTTY) {
|
|
28
|
+
if (process.platform === 'win32' && (process.stdout.isTTY || process.stdin.isTTY)) {
|
|
25
29
|
try {
|
|
26
30
|
const cp = execSync('chcp.com', { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], windowsHide: true }).match(/(\d+)/)?.[1];
|
|
27
31
|
if (cp && cp !== '65001') execSync('chcp.com 65001', { stdio: 'ignore', windowsHide: true });
|