zcode-pro 0.18.0 → 0.20.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/bin/zcode-pro.mjs +32 -9
- package/dist/inject.js +684 -130
- package/package.json +1 -1
- package/src/host/helper.mjs +613 -50
- package/src/host/main.mjs +8 -4
- package/src/host/setup.mjs +45 -42
- package/src/host/taskIndex.mjs +258 -0
- package/src/inject/core.js +67 -19
- package/src/inject/features/file-menu.js +9 -2
- package/src/inject/features/settings-dialog.js +266 -63
- package/src/inject/features/styles.js +1 -1
- package/src/inject/features/task-order.js +1 -1
- package/src/inject/features/title-gen.js +178 -0
- package/src/inject/features/title-lock.js +73 -1
- package/src/inject/features/toolbar-icons.js +38 -4
- package/src/inject/features/ws-running.js +1 -1
- package/src/inject/index.js +2 -0
- package/src/inject/provider-fields.js +71 -0
- package/src/inject/ui.js +20 -5
package/src/host/helper.mjs
CHANGED
|
@@ -4,19 +4,19 @@
|
|
|
4
4
|
|
|
5
5
|
import { createServer } from 'node:http';
|
|
6
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';
|
|
7
|
+
import { appendFileSync, 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
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, setCustomSessionTitle, isTaskTitleOverridden, reconcileCustomTitles, getTaskProviderModel } from './taskIndex.mjs';
|
|
14
14
|
import { pickFolderSystem } from './pickFolder.mjs';
|
|
15
15
|
import { reorderWorkspaceTasks, reorderGroupMembers } from './taskOrder.mjs';
|
|
16
16
|
import { webdavRequest, webdavPutWithMkcol, webdavHttpText, parseWebdavListing, findCurl } from './webdav.mjs';
|
|
17
17
|
import { resolveZcodeExecutable } from './paths.mjs';
|
|
18
18
|
|
|
19
|
-
const VERSION = '0.
|
|
19
|
+
const VERSION = '0.20.0';
|
|
20
20
|
|
|
21
21
|
// 全局提示词固定在用户主目录:官方加载器按 HOME/USERPROFILE 拼 .zcode/AGENTS.md,
|
|
22
22
|
// 不读 ZCODE_DATA_BASE_DIR(数据根迁走时全局指令仍在原位)。
|
|
@@ -95,6 +95,23 @@ export function defaultConfig() {
|
|
|
95
95
|
// headroom 启用开关的记忆(供应商 id → { proxy, direct }):关闭时把走 headroom 的
|
|
96
96
|
// 供应商地址切回直连并记录下来,再次开启时按记录把地址切回代理
|
|
97
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
|
+
customWritten: {},
|
|
108
|
+
provider: 'session',
|
|
109
|
+
model: 'glm-5.3-flash',
|
|
110
|
+
prompt: '',
|
|
111
|
+
baseUrl: '',
|
|
112
|
+
apiKey: '',
|
|
113
|
+
format: '',
|
|
114
|
+
},
|
|
98
115
|
// HTTP 代理(http(s)://host:port):helper 发起的网络访问走它——插件市场更新/安装
|
|
99
116
|
// (zcode CLI → git)、headroom/rtk 的安装与升级(pip/GitHub)。空 = 不用代理。
|
|
100
117
|
proxy: '',
|
|
@@ -102,14 +119,16 @@ export function defaultConfig() {
|
|
|
102
119
|
// 软件(headroom 与 rtk)的安装、检查更新与升级;sync = WebDAV 同步。同步默认
|
|
103
120
|
// 关闭——坚果云等国内网盘通常直连即可,代理停了同步会跟着失败
|
|
104
121
|
proxyScopes: { plugins: true, deps: true, sync: false },
|
|
105
|
-
//
|
|
122
|
+
// 开发模式(设置弹窗“调试”标签页):开启后出现“重载”按钮,可在不重启
|
|
106
123
|
// ZCode 的情况下加载新代码(构建 → 换新启动器进程 → 各窗口自动刷新重注入)
|
|
107
124
|
devMode: false,
|
|
108
125
|
// 设置同步(设置弹窗“同步”标签页,交互参考 Tampermonkey 的手动同步):
|
|
109
126
|
// 仅 WebDAV、仅在点击按钮时传输(type 字段预留多后端扩展,当前恒为 webdav)。
|
|
110
127
|
// url 为 WebDAV 服务器地址;dir 为其下的上传目录(默认 zcode-pro,避免直接
|
|
111
128
|
// 写进网盘根目录);include 选择同步内容;凭据只存本机、不同步出去;
|
|
112
|
-
// lastPushAt/lastPullAt
|
|
129
|
+
// lastPushAt/lastPullAt 只记录本机操作时间;historyLimit 为云端历史版本保留
|
|
130
|
+
// 个数(同一次上传的各组文件算一个,上传成功后自动删除更早的;0 = 不留历史,
|
|
131
|
+
// 只有固定名最新版)。
|
|
113
132
|
sync: {
|
|
114
133
|
type: 'webdav',
|
|
115
134
|
url: '',
|
|
@@ -117,6 +136,7 @@ export function defaultConfig() {
|
|
|
117
136
|
login: '',
|
|
118
137
|
password: '',
|
|
119
138
|
include: { zcodepro: true, model: true },
|
|
139
|
+
historyLimit: 10,
|
|
120
140
|
lastPushAt: null,
|
|
121
141
|
lastPullAt: null,
|
|
122
142
|
},
|
|
@@ -141,6 +161,7 @@ export function loadConfig(configFile) {
|
|
|
141
161
|
styles: { ...defaultConfig().styles, ...(saved.styles && typeof saved.styles === 'object' ? saved.styles : {}) },
|
|
142
162
|
aliases: { ...((saved.aliases && typeof saved.aliases === 'object') ? saved.aliases : {}) },
|
|
143
163
|
headroomRoutes: { ...((saved.headroomRoutes && typeof saved.headroomRoutes === 'object' && !Array.isArray(saved.headroomRoutes)) ? saved.headroomRoutes : {}) },
|
|
164
|
+
titleGen: mergeTitleGenConfig(saved.titleGen),
|
|
144
165
|
proxyScopes: mergeProxyScopes(saved.proxyScopes),
|
|
145
166
|
devMode: saved.devMode === true,
|
|
146
167
|
// devActive 每次读取时重算(保存时会随配置一起写入文件,但读取方不用保存的值):
|
|
@@ -229,6 +250,20 @@ function proxyUrlFor(scope) {
|
|
|
229
250
|
return activeProxyScopes[scope] ? activeProxyUrl : '';
|
|
230
251
|
}
|
|
231
252
|
|
|
253
|
+
// 智能起名诊断日志:追加到 <dataRoot>/zcodepro.log(与启动诊断同文件),超 512KB
|
|
254
|
+
// 轮转 .old。起名过程注入侧静默(失败不打扰用户),出问题时靠这里回查
|
|
255
|
+
let titleGenLogRotated = false;
|
|
256
|
+
function titleGenLog(dataRoot, line) {
|
|
257
|
+
try {
|
|
258
|
+
const file = join(dataRoot, 'zcodepro.log');
|
|
259
|
+
if (!titleGenLogRotated && existsSync(file) && statSync(file).size > 512 * 1024) {
|
|
260
|
+
renameSync(file, file + '.old');
|
|
261
|
+
titleGenLogRotated = true;
|
|
262
|
+
}
|
|
263
|
+
appendFileSync(file, `[${new Date().toISOString()}] title-gen ${line}\n`);
|
|
264
|
+
} catch { /* 日志失败不影响功能 */ }
|
|
265
|
+
}
|
|
266
|
+
|
|
232
267
|
export function startHelper({ port, token, dataRoot, state, agentsFile = defaultAgentsFile() }) {
|
|
233
268
|
const configFile = join(dataRoot, 'zcodepro.json');
|
|
234
269
|
const settingsFile = join(dataRoot, 'v2', 'setting.json');
|
|
@@ -299,6 +334,9 @@ export function startHelper({ port, token, dataRoot, state, agentsFile = default
|
|
|
299
334
|
if (body && typeof body === 'object' && body.styles && typeof body.styles === 'object') {
|
|
300
335
|
current.styles = applyStylesPartial(current.styles, body.styles);
|
|
301
336
|
}
|
|
337
|
+
if (body && typeof body === 'object' && body.titleGen && typeof body.titleGen === 'object') {
|
|
338
|
+
current.titleGen = mergeTitleGenConfig({ ...current.titleGen, ...body.titleGen });
|
|
339
|
+
}
|
|
302
340
|
saveConfig(configFile, current);
|
|
303
341
|
json(res, 200, { ok: true, config: current });
|
|
304
342
|
return;
|
|
@@ -398,6 +436,13 @@ export function startHelper({ port, token, dataRoot, state, agentsFile = default
|
|
|
398
436
|
// 注入侧据此把被自动标题覆盖的侧栏行钉回重命名值;响应带开关,
|
|
399
437
|
// 注入侧按需拉取这一个端点即可(无轮询)
|
|
400
438
|
if (req.method === 'GET' && url.pathname === '/pinned-titles') {
|
|
439
|
+
// 先自动校正:桌面端打开会话时会把运行时缓存的旧标题写回任务索引(实测),
|
|
440
|
+
// 对我们写过且 CLI custom 仍是所写标题的会话,把索引改回来再下发
|
|
441
|
+
const config0 = loadConfig(configFile);
|
|
442
|
+
if (config0.titleGen.customWritten && Object.keys(config0.titleGen.customWritten).length) {
|
|
443
|
+
const repaired = await reconcileCustomTitles(dataRoot, config0.titleGen.customWritten);
|
|
444
|
+
if (repaired.length) titleGenLog(dataRoot, `reconciled sess=${repaired.map((s) => s.slice(5, 13)).join(',')}`);
|
|
445
|
+
}
|
|
401
446
|
const r = await listOverriddenTitles(dataRoot);
|
|
402
447
|
if (r.error) {
|
|
403
448
|
json(res, 500, { ok: false, code: r.code, error: r.error });
|
|
@@ -407,6 +452,130 @@ export function startHelper({ port, token, dataRoot, state, agentsFile = default
|
|
|
407
452
|
json(res, 200, { ok: true, enabled: config.features.titleLock !== false, titles: r.titles });
|
|
408
453
|
return;
|
|
409
454
|
}
|
|
455
|
+
// —— 智能起名:配置读写 + 供应商列表 + 生成并写库 ——
|
|
456
|
+
if (req.method === 'GET' && url.pathname === '/title-gen') {
|
|
457
|
+
json(res, 200, { ok: true, titleGen: loadConfig(configFile).titleGen, defaultPrompt: titleGenDefaultPrompt() });
|
|
458
|
+
return;
|
|
459
|
+
}
|
|
460
|
+
if (req.method === 'POST' && url.pathname === '/title-gen') {
|
|
461
|
+
const body = await readBody(req);
|
|
462
|
+
if (body && typeof body === 'object' && body.titleGen && typeof body.titleGen === 'object') {
|
|
463
|
+
const current = loadConfig(configFile);
|
|
464
|
+
const next = { ...current.titleGen, ...body.titleGen };
|
|
465
|
+
// 选了列表内供应商:同步解析并保存其地址/密钥/协议副本——供应商日后
|
|
466
|
+
// 被删,这份副本仍可直连(界面标注“已删除”并展开字段);
|
|
467
|
+
// 跟随会话不存副本(每次调用时按该会话实时解析)
|
|
468
|
+
const providerId = (next.provider || '').trim();
|
|
469
|
+
if (providerId && providerId !== 'session') {
|
|
470
|
+
const found = listTitleGenProviders().find((p) => p.id === providerId);
|
|
471
|
+
if (found) {
|
|
472
|
+
next.baseUrl = found.baseUrl;
|
|
473
|
+
next.apiKey = found.apiKey;
|
|
474
|
+
next.format = found.format;
|
|
475
|
+
}
|
|
476
|
+
// 找不到(手输 id 或已删):保留原有副本字段不动
|
|
477
|
+
} else if (providerId === 'session') {
|
|
478
|
+
// 跟随会话不存副本(每次调用时按该会话实时解析)
|
|
479
|
+
next.baseUrl = '';
|
|
480
|
+
next.apiKey = '';
|
|
481
|
+
next.format = '';
|
|
482
|
+
}
|
|
483
|
+
current.titleGen = mergeTitleGenConfig(next);
|
|
484
|
+
saveConfig(configFile, current);
|
|
485
|
+
json(res, 200, { ok: true, titleGen: current.titleGen });
|
|
486
|
+
return;
|
|
487
|
+
}
|
|
488
|
+
json(res, 400, { ok: false, code: 'invalid-request', error: 'body 需包含 titleGen 对象' });
|
|
489
|
+
return;
|
|
490
|
+
}
|
|
491
|
+
// 供应商下拉数据:与视觉代理同源同规则,但只列 helper 能在会话外直接
|
|
492
|
+
// 调用的条目(有地址、密钥且协议可判定),并带可选模型列表;不回密钥
|
|
493
|
+
if (req.method === 'GET' && url.pathname === '/title-gen/providers') {
|
|
494
|
+
json(res, 200, {
|
|
495
|
+
ok: true,
|
|
496
|
+
providers: listTitleGenProviders().map(({ apiKey, ...rest }) => rest),
|
|
497
|
+
});
|
|
498
|
+
return;
|
|
499
|
+
}
|
|
500
|
+
// 生成(并可选写库):text = 会话首条消息;sessId 指定且非 test 时按
|
|
501
|
+
// “手动重命名”写入任务索引(已重命名的会话不会被动);test = 设置面板
|
|
502
|
+
// 的测试按钮,只生成不写库
|
|
503
|
+
if (req.method === 'POST' && url.pathname === '/title-gen/run') {
|
|
504
|
+
const body = await readBody(req);
|
|
505
|
+
const text = typeof body?.text === 'string' ? body.text : '';
|
|
506
|
+
if (!text.trim()) {
|
|
507
|
+
json(res, 400, { ok: false, code: 'invalid-request', error: 'text 不能为空' });
|
|
508
|
+
return;
|
|
509
|
+
}
|
|
510
|
+
const cfg = loadConfig(configFile).titleGen;
|
|
511
|
+
if (cfg.enabled !== true && body.test !== true) {
|
|
512
|
+
json(res, 400, { ok: false, code: 'title-gen-disabled', error: '智能起名未开启' });
|
|
513
|
+
return;
|
|
514
|
+
}
|
|
515
|
+
const sessId = typeof body?.sessId === 'string' && /^sess_[A-Za-z0-9._-]+$/.test(body.sessId) ? body.sessId : '';
|
|
516
|
+
let title;
|
|
517
|
+
try {
|
|
518
|
+
title = await generateTitleWith(cfg, text, dataRoot, sessId);
|
|
519
|
+
} catch (err) {
|
|
520
|
+
titleGenLog(dataRoot, `generate-failed sess=${sessId.slice(0, 13)} err=${err?.message || err}`);
|
|
521
|
+
throw err; // 交给外层统一 500
|
|
522
|
+
}
|
|
523
|
+
let written = false;
|
|
524
|
+
let store = ''; // CLI 会话库写入结果:'' 成功 | skipped 原因 | 'error'
|
|
525
|
+
if (sessId && body.test !== true) {
|
|
526
|
+
// 已被手动重命名(或前次起名已写过)就整体跳过——手动重命名永远优先,
|
|
527
|
+
// 也避免起名晚到的 CLI 写入盖掉用户的 custom 标题
|
|
528
|
+
if (await isTaskTitleOverridden(dataRoot, sessId)) {
|
|
529
|
+
json(res, 200, { ok: true, title, written: false, store: 'skipped: overridden' });
|
|
530
|
+
return;
|
|
531
|
+
}
|
|
532
|
+
// 双写,顺序不能反:先写 CLI 会话库(title_source=custom,权威层——
|
|
533
|
+
// 官方自动起名看到 custom 会放弃,桌面同步器会把 custom 标题带着
|
|
534
|
+
// overridden=1 送回任务索引),再写任务索引(展示层,立即上屏)。
|
|
535
|
+
// 只写任务索引时,官方运行时排队的自动标题会在几秒后把它覆盖(实测)。
|
|
536
|
+
// CLI 库不可写(旧版无 title_source 列 / 无该会话行)不阻断:退回
|
|
537
|
+
// 单写任务索引的旧行为;任务索引随后失败也不回滚 CLI——同步器会补
|
|
538
|
+
for (let attempt = 0; ; attempt++) {
|
|
539
|
+
try {
|
|
540
|
+
const r = await setCustomSessionTitle(dataRoot, sessId, title);
|
|
541
|
+
store = r.written ? '' : (r.skipped || 'skipped');
|
|
542
|
+
break;
|
|
543
|
+
} catch (err) {
|
|
544
|
+
if (err.code === 'index-busy' && attempt < 2) {
|
|
545
|
+
await new Promise((r) => setTimeout(r, 1200));
|
|
546
|
+
continue;
|
|
547
|
+
}
|
|
548
|
+
store = 'error: ' + (err?.message || err);
|
|
549
|
+
titleGenLog(dataRoot, `cli-store-error sess=${sessId.slice(0, 13)} err=${err?.message || err}`);
|
|
550
|
+
break;
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
// 写库撞上宿主写入时稍候重试(起名不急这几秒)
|
|
554
|
+
for (let attempt = 0; ; attempt++) {
|
|
555
|
+
try {
|
|
556
|
+
const r = await setTaskTitleIfNotOverridden(dataRoot, sessId, title);
|
|
557
|
+
written = r.written;
|
|
558
|
+
break;
|
|
559
|
+
} catch (err) {
|
|
560
|
+
if (err.code === 'index-busy' && attempt < 2) {
|
|
561
|
+
await new Promise((r) => setTimeout(r, 1200));
|
|
562
|
+
continue;
|
|
563
|
+
}
|
|
564
|
+
titleGenLog(dataRoot, `index-error sess=${sessId.slice(0, 13)} err=${err?.message || err}`);
|
|
565
|
+
json(res, 500, { ok: false, code: err.code || 'index-error', error: err.message });
|
|
566
|
+
return;
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
// CLI 库写成功了才登记回查记录(reconcileCustomTitles 只认这些记录校正)
|
|
571
|
+
if (sessId && store === '') {
|
|
572
|
+
const c = loadConfig(configFile);
|
|
573
|
+
c.titleGen.customWritten = { ...(c.titleGen.customWritten || {}), [sessId]: { title, at: Date.now() } };
|
|
574
|
+
saveConfig(configFile, c);
|
|
575
|
+
}
|
|
576
|
+
json(res, 200, { ok: true, title, written, store });
|
|
577
|
+
return;
|
|
578
|
+
}
|
|
410
579
|
// —— zcode-vision 插件(图片视觉代理):设置弹窗“视觉代理”标签页读写 ————
|
|
411
580
|
if (req.method === 'GET' && url.pathname === '/vision') {
|
|
412
581
|
const file = defaultVisionFile();
|
|
@@ -807,7 +976,7 @@ export function startHelper({ port, token, dataRoot, state, agentsFile = default
|
|
|
807
976
|
json(res, r.status || 500, { ok: false, code: r.code || 'sync-run', error: r.error });
|
|
808
977
|
return;
|
|
809
978
|
}
|
|
810
|
-
json(res, 200, { ok: true, direction, applied: r.applied, sync: loadConfig(configFile).sync });
|
|
979
|
+
json(res, 200, { ok: true, direction, applied: r.applied, ...(typeof r.pruned === 'number' ? { pruned: r.pruned } : {}), sync: loadConfig(configFile).sync });
|
|
811
980
|
return;
|
|
812
981
|
}
|
|
813
982
|
// —— 同步连通性测试(“测试”按钮):用表单当前值逐步验证(可先测再存),不写入配置文件 ——
|
|
@@ -973,7 +1142,7 @@ export function startHelper({ port, token, dataRoot, state, agentsFile = default
|
|
|
973
1142
|
if (r.ok) updated += 1;
|
|
974
1143
|
} else if (!p.installed && only) {
|
|
975
1144
|
// 安装只走单插件入口(name 指定=各面板的安装卡):
|
|
976
|
-
//
|
|
1145
|
+
// 全量“检查并更新”/启动自动更新只升级已装插件——卸载必须被尊重,
|
|
977
1146
|
// 未装插件一律由用户在各 tab 显式点安装
|
|
978
1147
|
const r = await runZcodeCli(['plugins', 'install', `${p.name}@${ZCODE_PLUGINS_MARKETPLACE}`], 120000);
|
|
979
1148
|
steps.push({ step: `install ${p.name}`, ok: r.ok, output: r.output });
|
|
@@ -1032,6 +1201,38 @@ function clampInt(v, min, max, dflt) {
|
|
|
1032
1201
|
|
|
1033
1202
|
// —— 设置同步(“同步”标签页)的实现 ——
|
|
1034
1203
|
|
|
1204
|
+
// loadConfig 时把磁盘上的 titleGen 段按默认值归一(智能起名):只收已知字段,
|
|
1205
|
+
// 字符串限长;enabled 默认关闭(改动官方起名行为,需用户显式开启)
|
|
1206
|
+
function mergeTitleGenConfig(saved) {
|
|
1207
|
+
const d = defaultConfig().titleGen;
|
|
1208
|
+
const s = (saved && typeof saved === 'object' && !Array.isArray(saved)) ? saved : {};
|
|
1209
|
+
// 旧版配置的 'cli'(登录账号单独成模式)统一迁移为 'session':
|
|
1210
|
+
// 登录账号的套餐已在供应商列表里,跟随会话即覆盖原语义
|
|
1211
|
+
const provider = typeof s.provider === 'string' ? s.provider.trim().slice(0, 200) : d.provider;
|
|
1212
|
+
// customWritten:起名功能写过的 { sessId → { title, at } },自动校正只认这些
|
|
1213
|
+
// 记录(见 reconcileCustomTitles)。只收合法会话 id 与短字符串,超 7 天/500 条的丢弃
|
|
1214
|
+
const written = {};
|
|
1215
|
+
const cutoff = Date.now() - 7 * 24 * 3600 * 1000;
|
|
1216
|
+
let kept = 0;
|
|
1217
|
+
for (const [k, v] of Object.entries((s.customWritten && typeof s.customWritten === 'object' && !Array.isArray(s.customWritten)) ? s.customWritten : {})) {
|
|
1218
|
+
if (kept >= 500 || !/^sess_[A-Za-z0-9._-]+$/.test(k)) continue;
|
|
1219
|
+
if (!v || typeof v !== 'object' || typeof v.title !== 'string' || v.title.length > 200) continue;
|
|
1220
|
+
if (!Number.isFinite(v.at) || v.at < cutoff) continue;
|
|
1221
|
+
written[k] = { title: v.title, at: v.at };
|
|
1222
|
+
kept++;
|
|
1223
|
+
}
|
|
1224
|
+
return {
|
|
1225
|
+
enabled: s.enabled === true,
|
|
1226
|
+
provider: provider === 'cli' ? 'session' : provider,
|
|
1227
|
+
model: typeof s.model === 'string' ? s.model.trim().slice(0, 200) : d.model,
|
|
1228
|
+
prompt: typeof s.prompt === 'string' ? s.prompt.slice(0, 20000) : d.prompt,
|
|
1229
|
+
baseUrl: typeof s.baseUrl === 'string' ? s.baseUrl.trim().slice(0, 500) : d.baseUrl,
|
|
1230
|
+
apiKey: typeof s.apiKey === 'string' ? s.apiKey.trim().slice(0, 500) : d.apiKey,
|
|
1231
|
+
format: s.format === 'openai' ? 'openai' : s.format === 'anthropic' ? 'anthropic' : '',
|
|
1232
|
+
customWritten: written,
|
|
1233
|
+
};
|
|
1234
|
+
}
|
|
1235
|
+
|
|
1035
1236
|
// loadConfig 时把磁盘上的 proxyScopes 按默认值归一(只收布尔,缺的补默认:
|
|
1036
1237
|
// 插件市场/依赖软件开、同步关)
|
|
1037
1238
|
function mergeProxyScopes(saved) {
|
|
@@ -1057,13 +1258,16 @@ function mergeSyncConfig(saved) {
|
|
|
1057
1258
|
login: typeof saved.login === 'string' ? saved.login.slice(0, 300) : '',
|
|
1058
1259
|
password: typeof saved.password === 'string' ? saved.password.slice(0, 300) : '',
|
|
1059
1260
|
include: { zcodepro: inc.zcodepro !== false, model: inc.model !== false },
|
|
1261
|
+
historyLimit: Number.isInteger(saved.historyLimit) && saved.historyLimit >= 0 && saved.historyLimit <= 100
|
|
1262
|
+
? saved.historyLimit : d.historyLimit,
|
|
1060
1263
|
lastPushAt: Number.isFinite(saved.lastPushAt) ? saved.lastPushAt : null,
|
|
1061
1264
|
lastPullAt: Number.isFinite(saved.lastPullAt) ? saved.lastPullAt : null,
|
|
1062
1265
|
};
|
|
1063
1266
|
}
|
|
1064
1267
|
|
|
1065
1268
|
// POST /sync 表单校验:url/login/password/dir 全量替换(dir 缺省时不动已有值)。
|
|
1066
|
-
// URL 必填,指向 WebDAV 服务器(可含路径前缀);dir
|
|
1269
|
+
// URL 必填,指向 WebDAV 服务器(可含路径前缀);dir 为其下的上传目录,可多级;
|
|
1270
|
+
// historyLimit 可选(缺省时不动已有值),0–100 的整数
|
|
1067
1271
|
function validateSyncForm(raw) {
|
|
1068
1272
|
const url = typeof raw.url === 'string' ? raw.url.trim() : '';
|
|
1069
1273
|
const login = typeof raw.login === 'string' ? raw.login.trim() : '';
|
|
@@ -1084,6 +1288,12 @@ function validateSyncForm(raw) {
|
|
|
1084
1288
|
}
|
|
1085
1289
|
out.dir = dir;
|
|
1086
1290
|
}
|
|
1291
|
+
if (raw.historyLimit !== undefined && raw.historyLimit !== null) {
|
|
1292
|
+
if (!Number.isInteger(raw.historyLimit) || raw.historyLimit < 0 || raw.historyLimit > 100) {
|
|
1293
|
+
return { error: '历史版本保留个数需为 0–100 的整数' };
|
|
1294
|
+
}
|
|
1295
|
+
out.historyLimit = raw.historyLimit;
|
|
1296
|
+
}
|
|
1087
1297
|
return { sync: out };
|
|
1088
1298
|
}
|
|
1089
1299
|
|
|
@@ -1177,8 +1387,10 @@ async function runSettingsSync({ direction, dataRoot, configFile, config, proxyU
|
|
|
1177
1387
|
// 上传:每组独立一个云端文件(zcodepro.json / model.json,始终最新一版),勾选几组
|
|
1178
1388
|
// 写几个;写前预读云端内容,别名与本机的合并后再写(合并规则见 mergeAliases),
|
|
1179
1389
|
// 其余内容整份覆盖。同一份内容再写一个带时间戳的历史版本文件,供下载时任选版本
|
|
1180
|
-
// 恢复(旧版 zcode-pro
|
|
1390
|
+
// 恢复(旧版 zcode-pro 仍读固定名);历史版本最多保留 historyLimit 个(同一时间戳
|
|
1391
|
+
// 的各组文件算一个),上传成功后自动删除更早的
|
|
1181
1392
|
async function runSyncPush({ dataRoot, configFile, config, sync, proxyUrl }) {
|
|
1393
|
+
const keep = Number.isInteger(sync.historyLimit) ? sync.historyLimit : defaultConfig().sync.historyLimit;
|
|
1182
1394
|
const docs = {};
|
|
1183
1395
|
if (sync.include.zcodepro) {
|
|
1184
1396
|
// sync(本机的同步配置与凭据)与 version(本机 helper 版本)不外发
|
|
@@ -1223,10 +1435,13 @@ async function runSyncPush({ dataRoot, configFile, config, sync, proxyUrl }) {
|
|
|
1223
1435
|
if (put.httpCode < 200 || put.httpCode >= 300) {
|
|
1224
1436
|
return { code: 'sync-request', status: 502, error: webdavHttpText(put.httpCode) };
|
|
1225
1437
|
}
|
|
1226
|
-
|
|
1227
|
-
if (
|
|
1228
|
-
|
|
1229
|
-
return { code: 'sync-request', status: 502, error:
|
|
1438
|
+
// 历史版本只在保留个数 > 0 时写(0 = 只留固定名最新版,由上传后清理收尾)
|
|
1439
|
+
if (keep > 0) {
|
|
1440
|
+
const putVersion = await putUrl(syncVersionName(kind, stamp));
|
|
1441
|
+
if (!putVersion.ok) return { code: 'sync-request', status: 502, error: putVersion.error };
|
|
1442
|
+
if (putVersion.httpCode < 200 || putVersion.httpCode >= 300) {
|
|
1443
|
+
return { code: 'sync-request', status: 502, error: webdavHttpText(putVersion.httpCode) };
|
|
1444
|
+
}
|
|
1230
1445
|
}
|
|
1231
1446
|
pushed.push(kind);
|
|
1232
1447
|
}
|
|
@@ -1235,7 +1450,9 @@ async function runSyncPush({ dataRoot, configFile, config, sync, proxyUrl }) {
|
|
|
1235
1450
|
}
|
|
1236
1451
|
sync.lastPushAt = Date.now();
|
|
1237
1452
|
saveConfig(configFile, config);
|
|
1238
|
-
|
|
1453
|
+
// 上传成功后清理超出保留个数的旧历史版本(best-effort,失败不影响上传结果)
|
|
1454
|
+
const pruned = await pruneSyncHistory(sync, proxyUrl, keep);
|
|
1455
|
+
return { applied: pushed, pruned };
|
|
1239
1456
|
}
|
|
1240
1457
|
|
|
1241
1458
|
// 下载:把云端所选组应用到本机(各组独立文件,勾选几组读几组,可只覆盖模型设置)。
|
|
@@ -1378,6 +1595,39 @@ async function listSyncVersions(sync, proxyUrl) {
|
|
|
1378
1595
|
return { files };
|
|
1379
1596
|
}
|
|
1380
1597
|
|
|
1598
|
+
// 从云端文件名列表选出待删除的历史版本(导出以便测试脚本直接驱动):带时间戳的
|
|
1599
|
+
// 文件按时间戳段分组——同一次上传的各组文件共用一个时间戳,算“一个”;时间戳
|
|
1600
|
+
// 段定宽零填充,字典序即时间序。保留最近 keep 个,返回更早的各组文件名;
|
|
1601
|
+
// 固定名(zcodepro.json / model.json)和不相干的文件名不会出现在结果里
|
|
1602
|
+
export function syncHistoryStaleNames(names, keep) {
|
|
1603
|
+
const groups = new Map();
|
|
1604
|
+
for (const name of names) {
|
|
1605
|
+
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];
|
|
1606
|
+
if (!ts) continue;
|
|
1607
|
+
if (!groups.has(ts)) groups.set(ts, []);
|
|
1608
|
+
groups.get(ts).push(name);
|
|
1609
|
+
}
|
|
1610
|
+
// 时间戳段按新 → 旧排,slice(keep) 剩下的即超出保留个数的更早各组
|
|
1611
|
+
return [...groups.keys()].sort((a, b) => b.localeCompare(a)).slice(Math.max(0, keep)).flatMap((ts) => groups.get(ts));
|
|
1612
|
+
}
|
|
1613
|
+
|
|
1614
|
+
// 上传后清理云端目录里超出保留个数的历史版本:列出文件,删掉 syncHistoryStaleNames
|
|
1615
|
+
// 归出的旧文件。best-effort——列目录或单个删除失败只跳过(下次上传再清),不抛错;
|
|
1616
|
+
// 返回本次删掉的文件数
|
|
1617
|
+
async function pruneSyncHistory(sync, proxyUrl, keep) {
|
|
1618
|
+
const list = await listSyncVersions(sync, proxyUrl);
|
|
1619
|
+
if (list.error) return 0;
|
|
1620
|
+
const stale = syncHistoryStaleNames(list.files.map((f) => f.name), keep);
|
|
1621
|
+
let removed = 0;
|
|
1622
|
+
for (const name of stale) {
|
|
1623
|
+
const r = await webdavRequest({ method: 'DELETE', url: syncFileUrlByName(sync, name), login: sync.login, password: sync.password, proxyUrl, timeoutMs: 30000 });
|
|
1624
|
+
if (!r.ok) continue; // 网络失败:跳过该文件
|
|
1625
|
+
if (r.httpCode !== 404 && (r.httpCode < 200 || r.httpCode >= 300)) continue; // 404 = 已不在,视为已清
|
|
1626
|
+
removed++;
|
|
1627
|
+
}
|
|
1628
|
+
return removed;
|
|
1629
|
+
}
|
|
1630
|
+
|
|
1381
1631
|
// 别名表清洗(同步下载用):只收 路径字符串 → 短字符串 的条目,数量限幅
|
|
1382
1632
|
function sanitizeAliases(raw) {
|
|
1383
1633
|
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null;
|
|
@@ -1485,7 +1735,7 @@ export async function scanIdleBusy(paths, dataRoot) {
|
|
|
1485
1735
|
return SCAN_OFF;
|
|
1486
1736
|
}
|
|
1487
1737
|
|
|
1488
|
-
// Linux
|
|
1738
|
+
// Linux:直接读取 /proc(comm 定位 CLI、cwd 定位项目、task/<pid>/children 拿子进程)
|
|
1489
1739
|
function scanIdleBusyLinux(paths) {
|
|
1490
1740
|
let pids;
|
|
1491
1741
|
try { pids = readdirSync('/proc').filter((d) => /^\d+$/.test(d)); }
|
|
@@ -1672,33 +1922,302 @@ function sanitizeFontStack(v) {
|
|
|
1672
1922
|
return s;
|
|
1673
1923
|
}
|
|
1674
1924
|
|
|
1675
|
-
//
|
|
1676
|
-
|
|
1925
|
+
// 视觉代理“跟随供应商”下拉取数:与智能起名共用同一套供应商列表和过滤规则
|
|
1926
|
+
//(见 listTitleGenProviders),只回 id/名称/别名,不含密钥。
|
|
1677
1927
|
// 导出以便测试脚本直接驱动。
|
|
1678
1928
|
export function listVisionProviders() {
|
|
1679
|
-
|
|
1929
|
+
return listTitleGenProviders().map((p) => ({ id: p.id, name: p.name, aliases: p.aliases || [] }));
|
|
1930
|
+
}
|
|
1931
|
+
|
|
1932
|
+
// —— 智能起名:供应商取数、模型调用与标题清洗 ——
|
|
1933
|
+
|
|
1934
|
+
// 内置默认提示词(用户可在设置弹窗覆盖),按界面语言取中文或英文版。
|
|
1935
|
+
// 英文版的词数上限比中文版紧:英文单词更长,3~15 个词在会话列表里放不下
|
|
1936
|
+
const TITLE_GEN_DEFAULT_PROMPTS = {
|
|
1937
|
+
zh: [
|
|
1938
|
+
'为下面的会话生成一个简短的标题。',
|
|
1939
|
+
'要求:',
|
|
1940
|
+
'- 使用用户消息的主要语言(中文消息用中文标题)',
|
|
1941
|
+
'- 概括用户要做的主要事情或主题,不要给出答案或结果',
|
|
1942
|
+
'- 一般3~15个词,在会话列表里能一眼认出',
|
|
1943
|
+
'- 保留重要的专有名词(文件名、产品名、技术名词等,如有)',
|
|
1944
|
+
'- 不要用`用户请求`、`新任务`、`提问`这类泛泛的标题',
|
|
1945
|
+
'- 不要直接复述或截取用户消息原文当标题',
|
|
1946
|
+
'- 只输出标题本身:不要解释、不要引号、不要末尾标点、不要markdown',
|
|
1947
|
+
].join('\n'),
|
|
1948
|
+
en: [
|
|
1949
|
+
'Generate a short title for the session below.',
|
|
1950
|
+
'Requirements:',
|
|
1951
|
+
"- Use the primary language of the user's message (an English message gets an English title)",
|
|
1952
|
+
'- Summarize the user\'s main task or topic; do not give the answer or result',
|
|
1953
|
+
'- Usually 3-8 words, easy to recognize in a session list',
|
|
1954
|
+
'- Keep important proper nouns (file names, product names, technical terms, if any)',
|
|
1955
|
+
'- Avoid generic titles such as `User Request`, `New Task`, or `Question`',
|
|
1956
|
+
'- Do not copy or truncate the user\'s message as the title',
|
|
1957
|
+
'- Output only the title itself: no explanation, no quotes, no trailing punctuation, no markdown',
|
|
1958
|
+
].join('\n'),
|
|
1959
|
+
};
|
|
1960
|
+
|
|
1961
|
+
// 界面语言:桌面设置(v2/setting.json 的 localePreference)为 en-US / zh-CN 时
|
|
1962
|
+
// 直接采用;system 或未设置时按系统环境语言判断(中文环境 → 中文,其余 → 英文)
|
|
1963
|
+
function titleGenLocale() {
|
|
1964
|
+
const s = readJsonFile(join(homedir(), '.zcode', 'v2', 'setting.json'));
|
|
1965
|
+
const pref = String((s && s.localePreference) || '').toLowerCase();
|
|
1966
|
+
if (pref.startsWith('en')) return 'en';
|
|
1967
|
+
if (pref.startsWith('zh')) return 'zh';
|
|
1968
|
+
const lang = String(process.env.LC_ALL || process.env.LC_MESSAGES || process.env.LANG || '').toLowerCase();
|
|
1969
|
+
return lang.startsWith('zh') ? 'zh' : 'en';
|
|
1970
|
+
}
|
|
1971
|
+
|
|
1972
|
+
function titleGenDefaultPrompt() {
|
|
1973
|
+
return TITLE_GEN_DEFAULT_PROMPTS[titleGenLocale()] || TITLE_GEN_DEFAULT_PROMPTS.en;
|
|
1974
|
+
}
|
|
1975
|
+
|
|
1976
|
+
const TITLE_GEN_MAX_TEXT = 1200; // 用户消息送入模型的长度上限(与官方起名一致)
|
|
1977
|
+
const TITLE_GEN_MAX_TITLE = 64; // 生成标题的长度上限(按字符计)
|
|
1978
|
+
const TITLE_GEN_TIMEOUT_MS = 60000; // 单次模型调用超时(与官方起名一致;推理模型思考占时较长)
|
|
1979
|
+
|
|
1980
|
+
// 内置套餐显示名:与 ZCode 界面的叫法一致——条目原始名区分度差
|
|
1981
|
+
//(coding-plan 与 start-plan 都叫 "Coding Plan"),界面里按套餐类型显示。
|
|
1982
|
+
// coding-plan 的个人/团队是动态的:从桌面设置(v2/setting.json 的
|
|
1983
|
+
// providerFamilyConnectionSelections)读当前登录的连接方式(kind 含
|
|
1984
|
+
// individual → 个人、team → 团队),读不到时退回“个人/团队”
|
|
1985
|
+
function codingPlanKindByFamily() {
|
|
1986
|
+
const s = readJsonFile(join(homedir(), '.zcode', 'v2', 'setting.json'));
|
|
1987
|
+
const sel = (s && s.providerFamilyConnectionSelections && typeof s.providerFamilyConnectionSelections === 'object')
|
|
1988
|
+
? s.providerFamilyConnectionSelections : {};
|
|
1989
|
+
const out = {};
|
|
1990
|
+
for (const [family, v] of Object.entries(sel)) {
|
|
1991
|
+
const kind = String(v?.kind || '');
|
|
1992
|
+
if (/individual/i.test(kind)) out[family] = '个人';
|
|
1993
|
+
else if (/team/i.test(kind)) out[family] = '团队';
|
|
1994
|
+
}
|
|
1995
|
+
return out;
|
|
1996
|
+
}
|
|
1997
|
+
|
|
1998
|
+
function titleGenProviderDisplayName(id, name, planKinds = {}) {
|
|
1999
|
+
const m = /^builtin:(zai|bigmodel)-coding-plan$/.exec(id);
|
|
2000
|
+
if (m) {
|
|
2001
|
+
const fam = m[1] === 'zai' ? 'Z.ai' : 'BigModel';
|
|
2002
|
+
return `${fam}(${planKinds[m[1]] || '个人/团队'})`;
|
|
2003
|
+
}
|
|
2004
|
+
if (/^builtin:(zai|bigmodel)-start-plan$/.test(id)) return 'Start Plan';
|
|
2005
|
+
return (name && String(name).trim()) || id;
|
|
2006
|
+
}
|
|
2007
|
+
|
|
2008
|
+
// 供应商条目(智能起名与视觉代理共用),列表范围与 ZCode 界面的供应商列表
|
|
2009
|
+
// 保持一致:
|
|
2010
|
+
// - 内置套餐(builtin:*):有地址、密钥才列出(无密钥的 helper 无法在会话外
|
|
2011
|
+
// 调用,如未配 key 的 API Key 型条目);
|
|
2012
|
+
// - 自定义供应商:以 provider_config.json 的 providerOrder 为准(ZCode 界面的
|
|
2013
|
+
// 自定义列表就来自它),provider 表里不在其中的残留条目(已弃用的中转等)
|
|
2014
|
+
// 不列出;
|
|
2015
|
+
// - 协议:api.type / kind 缺失时按地址推断(含 anthropic → anthropic,含
|
|
2016
|
+
// openai 或以 /v\d 结尾 → openai),两个都推不出才放弃该条目。
|
|
2017
|
+
// 两张来源表同 id 合并(模型并集、原始名收进别名)。models 为该供应商的
|
|
2018
|
+
// 可选模型(下拉用,可为空)。导出以便测试
|
|
2019
|
+
export function listTitleGenProviders() {
|
|
2020
|
+
const out = [];
|
|
1680
2021
|
const byId = new Map();
|
|
1681
|
-
const
|
|
1682
|
-
|
|
1683
|
-
|
|
1684
|
-
|
|
2022
|
+
const planKinds = codingPlanKindByFamily();
|
|
2023
|
+
const add = (id, { name, aliases = [], kind, baseUrl, apiKey, models = [] }) => {
|
|
2024
|
+
baseUrl = typeof baseUrl === 'string' ? baseUrl.trim() : '';
|
|
2025
|
+
apiKey = typeof apiKey === 'string' ? apiKey.trim() : '';
|
|
2026
|
+
if (!id || !baseUrl || !apiKey) return;
|
|
2027
|
+
let format = /anthropic/i.test(String(kind)) ? 'anthropic' : /openai/i.test(String(kind)) ? 'openai' : '';
|
|
2028
|
+
if (!format) format = /anthropic/i.test(baseUrl) ? 'anthropic'
|
|
2029
|
+
: (/openai/i.test(baseUrl) || /\/v\d+$/.test(baseUrl)) ? 'openai' : '';
|
|
2030
|
+
if (!format) return;
|
|
2031
|
+
models = models.filter(Boolean);
|
|
2032
|
+
const known = byId.get(id);
|
|
2033
|
+
if (known) {
|
|
2034
|
+
known.models = [...new Set([...known.models, ...models])];
|
|
2035
|
+
for (const a of aliases) if (a && !known.aliases.includes(a)) known.aliases.push(a);
|
|
2036
|
+
return;
|
|
2037
|
+
}
|
|
2038
|
+
const shown = titleGenProviderDisplayName(id, name, planKinds);
|
|
2039
|
+
const entry = {
|
|
2040
|
+
id, name: shown, aliases: [...new Set(aliases.filter((a) => a && a !== shown))],
|
|
2041
|
+
format, baseUrl, apiKey, models: [...new Set(models)],
|
|
2042
|
+
};
|
|
1685
2043
|
byId.set(id, entry);
|
|
1686
|
-
|
|
2044
|
+
out.push(entry);
|
|
2045
|
+
};
|
|
2046
|
+
|
|
2047
|
+
const v2 = readJsonFile(join(homedir(), '.zcode', 'v2', 'config.json'));
|
|
2048
|
+
const providers = (v2 && typeof v2.provider === 'object' && v2.provider) || {};
|
|
1687
2049
|
const rulesDoc = readJsonFile(join(homedir(), '.zcode', 'v2', 'provider_config.json'));
|
|
1688
2050
|
const rules = (rulesDoc && rulesDoc.config?.providerConfigRules?.providerRules) || [];
|
|
1689
|
-
|
|
1690
|
-
|
|
1691
|
-
|
|
1692
|
-
|
|
1693
|
-
|
|
1694
|
-
|
|
1695
|
-
|
|
1696
|
-
|
|
1697
|
-
|
|
1698
|
-
|
|
1699
|
-
|
|
2051
|
+
const ruleById = new Map(rules.filter((r) => r && typeof r.providerId === 'string').map((r) => [r.providerId, r]));
|
|
2052
|
+
// 同一 id 在两张表的字段并起来(provider 表优先:地址/密钥以桌面实际在用的为准)
|
|
2053
|
+
const addMerged = (id) => {
|
|
2054
|
+
const p = providers[id];
|
|
2055
|
+
if (p !== undefined && (p === null || typeof p !== 'object')) return;
|
|
2056
|
+
const r = ruleById.get(id) || {};
|
|
2057
|
+
const c = r.config || {};
|
|
2058
|
+
add(id, {
|
|
2059
|
+
name: (p && p.name) || r.providerName,
|
|
2060
|
+
aliases: [r.providerName, p && p.name],
|
|
2061
|
+
kind: (p && p.kind) || c.api?.type,
|
|
2062
|
+
baseUrl: (p && p.options?.baseURL) || c.api?.baseUrl,
|
|
2063
|
+
apiKey: (p && p.options?.apiKey) || c.access?.apiKey,
|
|
2064
|
+
models: [...Object.keys((p && p.models) || {}), ...(c.modelOrder || []), ...(c.personalModelIds || [])],
|
|
2065
|
+
});
|
|
2066
|
+
};
|
|
2067
|
+
|
|
2068
|
+
// 内置套餐在前(BigModel(个人/团队)→ Start Plan 的顺序)
|
|
2069
|
+
for (const id of Object.keys(providers)) {
|
|
2070
|
+
if (id.startsWith('builtin:')) addMerged(id);
|
|
1700
2071
|
}
|
|
1701
|
-
|
|
2072
|
+
// 自定义供应商按界面顺序(providerOrder)
|
|
2073
|
+
const order = (rulesDoc && Array.isArray(rulesDoc.config?.providerOrder) ? rulesDoc.config.providerOrder : [])
|
|
2074
|
+
.filter((id) => typeof id === 'string');
|
|
2075
|
+
for (const id of order) addMerged(id);
|
|
2076
|
+
return out;
|
|
2077
|
+
}
|
|
2078
|
+
|
|
2079
|
+
// Anthropic Messages 文本调用(无系统提示词,单条用户消息)。max_tokens 给足
|
|
2080
|
+
// 2048:glm 系列等推理模型先输出 thinking 块再出正文,给小了会全部耗在
|
|
2081
|
+
// 思考上(stop_reason=max_tokens 且没有任何 text 块),起名调用不在意这点额度
|
|
2082
|
+
async function callAnthropicText(baseUrl, apiKey, model, content) {
|
|
2083
|
+
const url = baseUrl.replace(/\/+$/, '') + '/v1/messages';
|
|
2084
|
+
const ctrl = new AbortController();
|
|
2085
|
+
const timer = setTimeout(() => ctrl.abort(), TITLE_GEN_TIMEOUT_MS);
|
|
2086
|
+
try {
|
|
2087
|
+
const resp = await fetch(url, {
|
|
2088
|
+
method: 'POST', signal: ctrl.signal,
|
|
2089
|
+
headers: { 'content-type': 'application/json', 'x-api-key': apiKey, 'anthropic-version': '2023-06-01' },
|
|
2090
|
+
body: JSON.stringify({ model, max_tokens: 2048, messages: [{ role: 'user', content }] }),
|
|
2091
|
+
});
|
|
2092
|
+
const body = await resp.text();
|
|
2093
|
+
if (!resp.ok) throw new Error(`HTTP ${resp.status} [model=${model}]: ${body.slice(0, 300)}`);
|
|
2094
|
+
const doc = JSON.parse(body);
|
|
2095
|
+
const text = Array.isArray(doc.content)
|
|
2096
|
+
? doc.content.filter((b) => b && b.type === 'text').map((b) => b.text || '').join('') : '';
|
|
2097
|
+
if (!text) throw new Error(doc.stop_reason === 'max_tokens'
|
|
2098
|
+
? '模型的思考用完了输出上限,仍未给出标题' : '响应中没有文本内容');
|
|
2099
|
+
return text;
|
|
2100
|
+
} catch (err) {
|
|
2101
|
+
if (err && err.name === 'AbortError') throw new Error(`请求超时(${Math.round(TITLE_GEN_TIMEOUT_MS / 1000)} 秒)`);
|
|
2102
|
+
throw err;
|
|
2103
|
+
} finally { clearTimeout(timer); }
|
|
2104
|
+
}
|
|
2105
|
+
|
|
2106
|
+
// OpenAI Chat Completions 文本调用;baseURL 已含 /v1 时不重复拼
|
|
2107
|
+
async function callOpenAiText(baseUrl, apiKey, model, content) {
|
|
2108
|
+
const base = baseUrl.replace(/\/+$/, '');
|
|
2109
|
+
const url = /\/v\d+$/.test(base) ? base + '/chat/completions' : base + '/v1/chat/completions';
|
|
2110
|
+
const ctrl = new AbortController();
|
|
2111
|
+
const timer = setTimeout(() => ctrl.abort(), TITLE_GEN_TIMEOUT_MS);
|
|
2112
|
+
try {
|
|
2113
|
+
const resp = await fetch(url, {
|
|
2114
|
+
method: 'POST', signal: ctrl.signal,
|
|
2115
|
+
headers: { 'content-type': 'application/json', authorization: `Bearer ${apiKey}` },
|
|
2116
|
+
body: JSON.stringify({ model, max_tokens: 2048, messages: [{ role: 'user', content }] }),
|
|
2117
|
+
});
|
|
2118
|
+
const body = await resp.text();
|
|
2119
|
+
if (!resp.ok) throw new Error(`HTTP ${resp.status} [model=${model}]: ${body.slice(0, 300)}`);
|
|
2120
|
+
const doc = JSON.parse(body);
|
|
2121
|
+
const text = doc.choices?.[0]?.message?.content || '';
|
|
2122
|
+
if (typeof text !== 'string' || !text.trim()) throw new Error('响应中没有文本内容');
|
|
2123
|
+
return text;
|
|
2124
|
+
} catch (err) {
|
|
2125
|
+
if (err && err.name === 'AbortError') throw new Error(`请求超时(${Math.round(TITLE_GEN_TIMEOUT_MS / 1000)} 秒)`);
|
|
2126
|
+
throw err;
|
|
2127
|
+
} finally { clearTimeout(timer); }
|
|
2128
|
+
}
|
|
2129
|
+
|
|
2130
|
+
// 生成标题。provider 三种取值:
|
|
2131
|
+
// - 'session':跟随当前会话——从任务索引解析该会话正在用的供应商与模型
|
|
2132
|
+
// (model 已填时用填写的),供应商需在可直连列表内;
|
|
2133
|
+
// - '':不跟随,用配置里的 baseUrl/apiKey/format 直连;
|
|
2134
|
+
// - 供应商 id:按列表解析直连;id 已被删时退回保存时留下的地址/密钥副本。
|
|
2135
|
+
// 输出统一清洗为单行短标题。导出以便测试
|
|
2136
|
+
export async function generateTitleWith(cfg, rawText, dataRoot = '', sessId = '') {
|
|
2137
|
+
const text = String(rawText || '').trim().replace(/\s+/g, ' ').slice(0, TITLE_GEN_MAX_TEXT);
|
|
2138
|
+
const prompt = (cfg.prompt && cfg.prompt.trim()) || titleGenDefaultPrompt();
|
|
2139
|
+
// 注入当前日期(上海时区):起名模型是无状态的,自定义提示词里要求
|
|
2140
|
+
// “MMDD 取对话创建日期”时它无从得知,只能留字面量 MMDD
|
|
2141
|
+
const now = new Date(Date.now() + 8 * 3600 * 1000);
|
|
2142
|
+
const pad = (n) => String(n).padStart(2, '0');
|
|
2143
|
+
const dateLine = `当前日期(Asia/Shanghai):${now.getUTCFullYear()}-${pad(now.getUTCMonth() + 1)}-${pad(now.getUTCDate())}(MMDD 即 ${pad(now.getUTCMonth() + 1)}${pad(now.getUTCDate())})`;
|
|
2144
|
+
const userContent = `${prompt}\n\n${dateLine}\n\n会话的用户消息:\n${text}`;
|
|
2145
|
+
const providerId = (cfg.provider || '').trim();
|
|
2146
|
+
const model = (cfg.model || '').trim();
|
|
2147
|
+
const conn = await resolveTitleGenConnection(cfg, providerId, model, dataRoot, sessId);
|
|
2148
|
+
const raw = conn.format === 'anthropic'
|
|
2149
|
+
? await callAnthropicText(conn.baseUrl, conn.apiKey, conn.model, userContent)
|
|
2150
|
+
: await callOpenAiText(conn.baseUrl, conn.apiKey, conn.model, userContent);
|
|
2151
|
+
const title = cleanGeneratedTitle(raw);
|
|
2152
|
+
if (!title) throw new Error('模型未返回可用标题');
|
|
2153
|
+
return title;
|
|
2154
|
+
}
|
|
2155
|
+
|
|
2156
|
+
// 直连前的连接解析(见 generateTitleWith 的取值说明);解析不出抛明确错误
|
|
2157
|
+
async function resolveTitleGenConnection(cfg, providerId, model, dataRoot, sessId) {
|
|
2158
|
+
if (providerId === 'session') {
|
|
2159
|
+
if (!sessId || !dataRoot) {
|
|
2160
|
+
throw new Error('跟随当前会话需要会话信息(测试按钮请选择具体供应商或“不跟随”)');
|
|
2161
|
+
}
|
|
2162
|
+
const task = await getTaskProviderModel(dataRoot, sessId);
|
|
2163
|
+
if (!task || !task.providerId) throw new Error('索引中查不到该会话的供应商信息');
|
|
2164
|
+
const entry = listTitleGenProviders().find((p) => p.id === task.providerId);
|
|
2165
|
+
if (!entry) throw new Error(`会话供应商 ${task.providerId} 无法在会话外直连(无密钥或协议未知)`);
|
|
2166
|
+
const useModel = model || task.modelId;
|
|
2167
|
+
if (!useModel) throw new Error('未能确定会话当前模型,请手动填写模型');
|
|
2168
|
+
return { format: entry.format, baseUrl: entry.baseUrl, apiKey: entry.apiKey, model: useModel };
|
|
2169
|
+
}
|
|
2170
|
+
if (providerId) {
|
|
2171
|
+
const entry = listTitleGenProviders().find((p) => p.id === providerId);
|
|
2172
|
+
if (entry) {
|
|
2173
|
+
if (!model) throw new Error('已选择供应商,但未填写模型');
|
|
2174
|
+
return { format: entry.format, baseUrl: entry.baseUrl, apiKey: entry.apiKey, model };
|
|
2175
|
+
}
|
|
2176
|
+
// 供应商已被删:退回保存时的地址/密钥副本
|
|
2177
|
+
if (cfg.baseUrl && cfg.apiKey && cfg.format) {
|
|
2178
|
+
if (!model) throw new Error('已删除的供应商需手动填写模型');
|
|
2179
|
+
return { format: cfg.format, baseUrl: cfg.baseUrl, apiKey: cfg.apiKey, model };
|
|
2180
|
+
}
|
|
2181
|
+
throw new Error(`供应商 ${providerId} 已不可用(未保存地址/密钥副本),请重新选择`);
|
|
2182
|
+
}
|
|
2183
|
+
// 不跟随(自填地址)
|
|
2184
|
+
if (!cfg.baseUrl || !cfg.apiKey || !cfg.format) {
|
|
2185
|
+
throw new Error('不跟随(自填地址)需要填写接口地址、API Key 与协议');
|
|
2186
|
+
}
|
|
2187
|
+
if (!model) throw new Error('不跟随(自填地址)需要填写模型');
|
|
2188
|
+
return { format: cfg.format, baseUrl: cfg.baseUrl, apiKey: cfg.apiKey, model };
|
|
2189
|
+
}
|
|
2190
|
+
|
|
2191
|
+
// 清洗模型输出:去思考块/代码围栏/首尾引号/序号/末尾标点,取首个非空行并限长。
|
|
2192
|
+
// 模型可能沿用官方起名的习惯回 JSON 对象,此时取其 title 字段。自定义提示词常让
|
|
2193
|
+
// 模型“先说明再给标题”(如“无法直接修改会话标题……建议手动设置:”后另起一行
|
|
2194
|
+
// 【MMDD|类型|主题】),此时首个非空行是说明而非标题——优先取独立成行的
|
|
2195
|
+
// 【】/[] 包裹标题行(允许行首 markdown 标题符)。导出以便测试
|
|
2196
|
+
export function cleanGeneratedTitle(text) {
|
|
2197
|
+
let t = String(text || '').replace(/<think>[\s\S]*?<\/think>/gi, '').trim();
|
|
2198
|
+
// 整段包在代码围栏里时先去掉围栏标记(先取行会把“```json”当成标题内容)
|
|
2199
|
+
t = t.replace(/^```[a-zA-Z]*\s*\n?/, '').replace(/\n?```\s*$/, '').trim();
|
|
2200
|
+
const lines = t.split(/\r?\n/).map((s) => s.trim()).filter(Boolean);
|
|
2201
|
+
// 标题行判定前剥掉 markdown 外壳(## 标题符、**加粗**、*斜体*、_下划线_、~~删除线~~)
|
|
2202
|
+
const unwrap = (s) => s.replace(/^#{1,4}\s*/, '').replace(/^[*_~]+/, '').replace(/[*_~]+$/, '').trim();
|
|
2203
|
+
const bracketed = lines.find((s) => /^(?:【[^】]{1,60}】|\[[^\]\n]{1,60}\])$/.test(unwrap(s)));
|
|
2204
|
+
let line = bracketed ? unwrap(bracketed) : (lines[0] || '');
|
|
2205
|
+
line = line.replace(/^```[a-zA-Z]*\s*/, '').replace(/```\s*$/, '');
|
|
2206
|
+
try {
|
|
2207
|
+
const doc = JSON.parse(line);
|
|
2208
|
+
if (doc && typeof doc.title === 'string') line = doc.title;
|
|
2209
|
+
} catch { /* 不是 JSON,按纯文本处理 */ }
|
|
2210
|
+
line = line.replace(/^["'「『“]+/, '').replace(/["'」』”]+$/, '');
|
|
2211
|
+
line = line.replace(/^\d+[.、)\]]\s*/, '');
|
|
2212
|
+
line = line.replace(/[.。!!??,,;;::]\s*$/, '');
|
|
2213
|
+
line = line.replace(/\s+/g, ' ').trim();
|
|
2214
|
+
const chars = [...line];
|
|
2215
|
+
if (chars.length > TITLE_GEN_MAX_TITLE) {
|
|
2216
|
+
line = chars.slice(0, TITLE_GEN_MAX_TITLE).join('');
|
|
2217
|
+
// 截断落在英文单词中间时退到词边界,避免出现半个单词
|
|
2218
|
+
if (line.includes(' ')) line = line.slice(0, line.lastIndexOf(' '));
|
|
2219
|
+
}
|
|
2220
|
+
return line.trim();
|
|
1702
2221
|
}
|
|
1703
2222
|
|
|
1704
2223
|
// zcode-vision 配置校验:只保留已知字段并规范化;出错时返回中文原因。
|
|
@@ -1743,7 +2262,11 @@ export function validateVisionConfig(raw) {
|
|
|
1743
2262
|
names.add(name);
|
|
1744
2263
|
proxy.name = name;
|
|
1745
2264
|
if (!(proxy.baseUrl || '').trim() && !(proxy.useProvider || '').trim()) return [null, `代理 ${name} 缺少 baseUrl(或设置 useProvider 跟随供应商)`];
|
|
1746
|
-
|
|
2265
|
+
// 跟随会话(useProvider=session)时允许 model 留空:留空 = 用会话当前模型
|
|
2266
|
+
//(zcode-vision ≥1.6.1 支持);其余情况必须填写
|
|
2267
|
+
if (!(proxy.model || '').trim() && proxy.useProvider !== 'session') {
|
|
2268
|
+
return [null, `代理 ${name} 缺少 model`];
|
|
2269
|
+
}
|
|
1747
2270
|
if (proxy.baseUrl) proxy.baseUrl = proxy.baseUrl.trim();
|
|
1748
2271
|
if (proxy.model) proxy.model = proxy.model.trim();
|
|
1749
2272
|
if (proxy.useProvider) proxy.useProvider = proxy.useProvider.trim();
|
|
@@ -2028,7 +2551,7 @@ async function ensureUserPathEntry(dir) {
|
|
|
2028
2551
|
}
|
|
2029
2552
|
|
|
2030
2553
|
// 在插件缓存里找市场插件的钩子脚本:只认 installed_plugins.json 登记的 installPath。
|
|
2031
|
-
//
|
|
2554
|
+
// 不能按目录扫描:卸载只删“当前指向”的版本目录,缓存里的历史版本目录会残留
|
|
2032
2555
|
// (升级留旧版、GitHub/本地源先后装过等),扫目录会把已卸载的插件误判为已装,
|
|
2033
2556
|
// 面板就不显示安装卡了。本地开发流(install-plugin-local.sh)同样会更新安装记录,兼容。
|
|
2034
2557
|
// 注意只认正式市场 duanluan-zcode-plugins:本地临时市场(如 duanluan-local)装的副本不在查找范围,
|
|
@@ -2047,7 +2570,7 @@ function findVisionHook() {
|
|
|
2047
2570
|
return findMarketplaceHook('zcode-vision', join('hooks', 'vision-hook.mjs'));
|
|
2048
2571
|
}
|
|
2049
2572
|
|
|
2050
|
-
// 读市场插件在 installed_plugins.json
|
|
2573
|
+
// 读市场插件在 installed_plugins.json 里登记的版本(面板信息卡显示“插件 x.y.z”用)
|
|
2051
2574
|
function installedPluginVersion(pluginName) {
|
|
2052
2575
|
const installedRaw = readJsonFile(join(cliPluginsDir(), 'installed_plugins.json'));
|
|
2053
2576
|
const rec = (installedRaw?.plugins || []).find((p) => p?.name === pluginName && p?.marketplace === ZCODE_PLUGINS_MARKETPLACE);
|
|
@@ -2523,13 +3046,14 @@ function zcodeCliBundleCandidates() {
|
|
|
2523
3046
|
].filter(Boolean);
|
|
2524
3047
|
}
|
|
2525
3048
|
|
|
2526
|
-
// 运行 zcode CLI 子命令(plugins
|
|
2527
|
-
|
|
3049
|
+
// 运行 zcode CLI 子命令(plugins … 默认走 plugins 代理范围;智能起名的
|
|
3050
|
+
// 无界面提问传 none——模型 API 直连,不走下载代理)
|
|
3051
|
+
function runZcodeCli(subArgs, timeoutMs = 120000, scope = 'plugins') {
|
|
2528
3052
|
const bundle = zcodeCliBundleCandidates().find((c) => existsSync(c));
|
|
2529
3053
|
if (!bundle) {
|
|
2530
3054
|
return Promise.resolve({ ok: false, code: 'cli-not-found', error: '未找到 ZCode CLI(resources/glm/zcode.cjs)' });
|
|
2531
3055
|
}
|
|
2532
|
-
return runNodeScript([bundle, ...subArgs], timeoutMs,
|
|
3056
|
+
return runNodeScript([bundle, ...subArgs], timeoutMs, scope);
|
|
2533
3057
|
}
|
|
2534
3058
|
|
|
2535
3059
|
// 比对市场清单与已装清单:每个插件的已装版本与最新版本。导出以便测试。
|
|
@@ -2600,12 +3124,30 @@ export function writeAgents(body, agentsFile) {
|
|
|
2600
3124
|
return [200, { ok: true, content: body.content }];
|
|
2601
3125
|
}
|
|
2602
3126
|
|
|
3127
|
+
// 路径不存在时的还原尝试。宿主把消息里的反斜杠路径渲染成文件 chip 时,markdown
|
|
3128
|
+
// 会把 “\.” 当转义序列吃掉反斜杠(Administrator\.zcode 变成 Administrator.zcode),
|
|
3129
|
+
// 其余分隔符再统一显示为 /,chip 上拿到的就是这种坏路径。把每个“段名.点开头后缀”
|
|
3130
|
+
// 形式的段试着拆回两级(Administrator.zcode → Administrator/.zcode),哪个变体
|
|
3131
|
+
// 真实存在就用哪个;都不存在返回 null,按原路径走 not-found。导出以便测试脚本直接驱动。
|
|
3132
|
+
export function recoverSwallowedSeparator(rawPath) {
|
|
3133
|
+
const segs = String(rawPath).split(/[\\/]/);
|
|
3134
|
+
for (let i = 0; i < segs.length; i++) {
|
|
3135
|
+
const dot = segs[i].indexOf('.');
|
|
3136
|
+
if (dot <= 0) continue; // 段里没有点,或本身就是 .zcode 这类点开头目录
|
|
3137
|
+
const candidate = [...segs.slice(0, i), segs[i].slice(0, dot), segs[i].slice(dot), ...segs.slice(i + 1)].join(sep);
|
|
3138
|
+
try {
|
|
3139
|
+
if (existsSync(candidate)) return candidate;
|
|
3140
|
+
} catch { /* ignore */ }
|
|
3141
|
+
}
|
|
3142
|
+
return null;
|
|
3143
|
+
}
|
|
3144
|
+
|
|
2603
3145
|
// 在系统文件管理器中定位文件:
|
|
2604
3146
|
// Linux 走 freedesktop FileManager1.ShowItems(Dolphin/Nautilus 均支持),无会话服务时回退打开所在目录;
|
|
2605
3147
|
// macOS 用 open -R;Windows 用 explorer /select。导出以便测试脚本直接驱动。
|
|
2606
3148
|
export function revealInFileManager(rawPath) {
|
|
2607
3149
|
return new Promise((resolveReveal) => {
|
|
2608
|
-
|
|
3150
|
+
let p = typeof rawPath === 'string' ? rawPath.trim() : '';
|
|
2609
3151
|
if (!p || !isAbsolute(p)) {
|
|
2610
3152
|
resolveReveal([400, { ok: false, code: 'invalid-path', error: '无效的路径' }]);
|
|
2611
3153
|
return;
|
|
@@ -2660,25 +3202,46 @@ export function openFolder(rawPath) {
|
|
|
2660
3202
|
});
|
|
2661
3203
|
}
|
|
2662
3204
|
|
|
2663
|
-
// 用系统默认应用打开文件(或文件夹):macOS 用 open;Windows
|
|
2664
|
-
// Linux 用
|
|
3205
|
+
// 用系统默认应用打开文件(或文件夹):macOS 用 open;Windows 走 rundll32 的
|
|
3206
|
+
// FileProtocolHandler(等价 ShellExecute,启动失败时退回 cmd start);Linux 用
|
|
3207
|
+
// xdg-open。导出以便测试脚本直接驱动。Windows 上不能用 explorer.exe <文件路径>:
|
|
3208
|
+
// 那只会在资源管理器里定位该文件(打开所在目录并选中),不会启动默认关联应用。
|
|
2665
3209
|
export function openWithDefaultApp(rawPath) {
|
|
2666
3210
|
return new Promise((resolveOpen) => {
|
|
2667
|
-
|
|
3211
|
+
let p = typeof rawPath === 'string' ? rawPath.trim() : '';
|
|
2668
3212
|
if (!p || !isAbsolute(p)) {
|
|
2669
3213
|
resolveOpen([400, { ok: false, code: 'invalid-path', error: '无效的路径' }]);
|
|
2670
3214
|
return;
|
|
2671
3215
|
}
|
|
2672
3216
|
if (!existsSync(p)) {
|
|
2673
|
-
|
|
2674
|
-
|
|
3217
|
+
// chip 带来的路径可能被宿主渲染弄坏(见 recoverSwallowedSeparator),先试还原
|
|
3218
|
+
const recovered = recoverSwallowedSeparator(p);
|
|
3219
|
+
if (!recovered) {
|
|
3220
|
+
resolveOpen([400, { ok: false, code: 'not-found', error: '文件不存在' }]);
|
|
3221
|
+
return;
|
|
3222
|
+
}
|
|
3223
|
+
p = recovered;
|
|
2675
3224
|
}
|
|
2676
3225
|
const done = (err) => resolveOpen(err ? [500, { ok: false, error: err.message }] : [200, { ok: true }]);
|
|
2677
3226
|
if (process.platform === 'darwin') execFile('open', [p], { timeout: 5000 }, done);
|
|
2678
3227
|
else if (process.platform === 'win32') {
|
|
2679
|
-
|
|
2680
|
-
|
|
2681
|
-
|
|
3228
|
+
// rundll32 的参数不经 shell 解释,路径里的空格、%、& 等字符都安全;
|
|
3229
|
+
// start 的第一个引号参数是窗口标题,兜底时必须先给空标题再给路径。
|
|
3230
|
+
// 进程拉起即算成功:系统里没有该类型的关联应用时 rundll32 会弹
|
|
3231
|
+
// “你要如何打开这个文件”对话框并一直挂着,不能等它退出才回话
|
|
3232
|
+
let settled = false;
|
|
3233
|
+
const finish = (err) => {
|
|
3234
|
+
if (settled) return;
|
|
3235
|
+
settled = true;
|
|
3236
|
+
done(err);
|
|
3237
|
+
};
|
|
3238
|
+
const child = spawn('rundll32.exe', ['url.dll,FileProtocolHandler', p], { detached: true, stdio: 'ignore', windowsHide: true });
|
|
3239
|
+
child.on('spawn', () => finish(null));
|
|
3240
|
+
child.on('error', () => {
|
|
3241
|
+
const viaCmd = spawn('cmd.exe', [`/d /s /c start "" "${p}"`], { windowsVerbatimArguments: true, detached: true, stdio: 'ignore', windowsHide: true });
|
|
3242
|
+
viaCmd.on('spawn', () => finish(null));
|
|
3243
|
+
viaCmd.on('error', finish);
|
|
3244
|
+
});
|
|
2682
3245
|
} else execFile('xdg-open', [p], { timeout: 5000 }, done);
|
|
2683
3246
|
});
|
|
2684
3247
|
}
|