zcode-pro 1.2.0 → 1.3.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 +3 -0
- package/README.md +3 -0
- package/dist/inject.js +996 -84
- package/package.json +1 -1
- package/src/host/helper.mjs +179 -5
- package/src/host/taskIndex.mjs +18 -0
- package/src/inject/core.js +86 -0
- package/src/inject/features/plugin-card-retry.js +48 -0
- package/src/inject/features/sandbox-reclaim.js +49 -20
- package/src/inject/features/settings-dialog.js +156 -3
- package/src/inject/features/status-bar.js +274 -0
- package/src/inject/features/turn-usage.js +195 -0
- package/src/inject/index.js +8 -0
- package/src/inject/ui.js +50 -0
package/package.json
CHANGED
package/src/host/helper.mjs
CHANGED
|
@@ -11,13 +11,13 @@ import { basename, delimiter, dirname, isAbsolute, join, resolve, sep } from 'no
|
|
|
11
11
|
import { homedir, tmpdir, userInfo } from 'node:os';
|
|
12
12
|
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
13
13
|
import { readSettings, writeSettingsAtomic, remapSettingsPaths, isProjectOpenInTabs } from './settings.mjs';
|
|
14
|
-
import { taskIndexPath, probeTaskIndexWritable, remapTaskIndexPaths, taskIndexDriverAvailable, listOverriddenTitles, listTaskWorkspaceMap, setTaskTitleIfNotOverridden, setCustomSessionTitle, isTaskTitleOverridden, reconcileCustomTitles, getTaskProviderModel, listVirtualProjects } from './taskIndex.mjs';
|
|
14
|
+
import { taskIndexPath, probeTaskIndexWritable, remapTaskIndexPaths, taskIndexDriverAvailable, listOverriddenTitles, listTaskWorkspaceMap, setTaskTitleIfNotOverridden, setCustomSessionTitle, isTaskTitleOverridden, reconcileCustomTitles, getTaskProviderModel, listVirtualProjects, countTasks } from './taskIndex.mjs';
|
|
15
15
|
import { pickFolderSystem } from './pickFolder.mjs';
|
|
16
16
|
import { reorderWorkspaceTasks, reorderGroupMembers } from './taskOrder.mjs';
|
|
17
17
|
import { webdavRequest, webdavPutWithMkcol, webdavHttpText, parseWebdavListing, findCurl } from './webdav.mjs';
|
|
18
18
|
import { resolveZcodeExecutable } from './paths.mjs';
|
|
19
19
|
|
|
20
|
-
const VERSION = '1.
|
|
20
|
+
const VERSION = '1.3.0';
|
|
21
21
|
|
|
22
22
|
// 全局提示词固定在用户主目录:官方加载器按 HOME/USERPROFILE 拼 .zcode/AGENTS.md,
|
|
23
23
|
// 不读 ZCODE_DATA_BASE_DIR(数据根迁走时全局指令仍在原位)。
|
|
@@ -348,6 +348,35 @@ export function startHelper({ port, token, dataRoot, state, agentsFile = default
|
|
|
348
348
|
}
|
|
349
349
|
current.shortcuts = { ...(current.shortcuts || {}), ...patch };
|
|
350
350
|
}
|
|
351
|
+
// 状态栏统计(设置 → 样式 → 状态栏):items(任务统计项)与
|
|
352
|
+
// turnItems(每轮统计项)两个清单各自整表覆盖,谁在请求里就更新谁;
|
|
353
|
+
// id 白名单 + 去重保序,未知项丢弃
|
|
354
|
+
if (body && typeof body === 'object' && body.statusBar && typeof body.statusBar === 'object') {
|
|
355
|
+
const next = { ...(current.statusBar || {}) };
|
|
356
|
+
if (Array.isArray(body.statusBar.items)) {
|
|
357
|
+
const valid = new Set(['ctx', 'questions', 'todayTokens', 'totalTokens', 'tasks']);
|
|
358
|
+
const seen = new Set();
|
|
359
|
+
const items = [];
|
|
360
|
+
for (const it of body.statusBar.items) {
|
|
361
|
+
if (!it || !valid.has(it.id) || seen.has(it.id)) continue;
|
|
362
|
+
seen.add(it.id);
|
|
363
|
+
items.push({ id: it.id, on: it.on !== false });
|
|
364
|
+
}
|
|
365
|
+
next.items = items;
|
|
366
|
+
}
|
|
367
|
+
if (Array.isArray(body.statusBar.turnItems)) {
|
|
368
|
+
const turnValid = new Set(['turnIn', 'turnOut', 'turnCache']);
|
|
369
|
+
const tseen = new Set();
|
|
370
|
+
const turnItems = [];
|
|
371
|
+
for (const it of body.statusBar.turnItems) {
|
|
372
|
+
if (!it || !turnValid.has(it.id) || tseen.has(it.id)) continue;
|
|
373
|
+
tseen.add(it.id);
|
|
374
|
+
turnItems.push({ id: it.id, on: it.on !== false });
|
|
375
|
+
}
|
|
376
|
+
next.turnItems = turnItems;
|
|
377
|
+
}
|
|
378
|
+
current.statusBar = next;
|
|
379
|
+
}
|
|
351
380
|
if (body && typeof body === 'object' && body.titleGen && typeof body.titleGen === 'object') {
|
|
352
381
|
current.titleGen = mergeTitleGenConfig({ ...current.titleGen, ...body.titleGen });
|
|
353
382
|
}
|
|
@@ -611,7 +640,17 @@ export function startHelper({ port, token, dataRoot, state, agentsFile = default
|
|
|
611
640
|
// —— zcode-vision 插件(图片视觉代理):设置弹窗“视觉代理”标签页读写 ————
|
|
612
641
|
if (req.method === 'GET' && url.pathname === '/vision') {
|
|
613
642
|
const file = defaultVisionFile();
|
|
614
|
-
|
|
643
|
+
let parsed = readJsonFile(file);
|
|
644
|
+
// 装了 zcode-pro 默认启用识图卡片(插件纯安装默认关,纯文本展示):
|
|
645
|
+
// 只补从未写过的配置,用户显式设过 cardUi(含 false)不覆盖
|
|
646
|
+
if (parsed && typeof parsed === 'object' && !('cardUi' in parsed)) {
|
|
647
|
+
parsed = { ...parsed, cardUi: true };
|
|
648
|
+
try {
|
|
649
|
+
const tmp = file + '.tmp';
|
|
650
|
+
writeFileSync(tmp, JSON.stringify(parsed, null, 2) + '\n', 'utf-8');
|
|
651
|
+
renameSync(tmp, file);
|
|
652
|
+
} catch { /* 补写失败下次再试,不阻塞读取 */ }
|
|
653
|
+
}
|
|
615
654
|
if (parsed === null && existsSync(file)) {
|
|
616
655
|
json(res, 500, { ok: false, code: 'vision-read', error: 'zcode-vision.json 不是合法 JSON' });
|
|
617
656
|
return;
|
|
@@ -628,8 +667,12 @@ export function startHelper({ port, token, dataRoot, state, agentsFile = default
|
|
|
628
667
|
}
|
|
629
668
|
try {
|
|
630
669
|
const file = defaultVisionFile();
|
|
670
|
+
// 与磁盘旧配置合并(本块曾两次被并发改动覆盖回全量覆盖):前端按自己
|
|
671
|
+
// 的字段重建整份配置,直接覆盖会丢插件新增、面板还不认识的键
|
|
672
|
+
//(cardSandboxBudget/cardUi/interceptMode 等)
|
|
673
|
+
const merged = { ...(readJsonFile(file) || {}), ...cfg };
|
|
631
674
|
const tmp = file + '.tmp';
|
|
632
|
-
writeFileSync(tmp, JSON.stringify(
|
|
675
|
+
writeFileSync(tmp, JSON.stringify(merged, null, 2) + '\n', 'utf-8');
|
|
633
676
|
renameSync(tmp, file);
|
|
634
677
|
} catch (err2) {
|
|
635
678
|
json(res, 500, { ok: false, code: 'vision-write', error: '写入 zcode-vision.json 失败: ' + (err2?.message || err2) });
|
|
@@ -1135,6 +1178,21 @@ export function startHelper({ port, token, dataRoot, state, agentsFile = default
|
|
|
1135
1178
|
return;
|
|
1136
1179
|
}
|
|
1137
1180
|
// —— zcode-plugins 市场更新:读状态(带 30 分钟节流的市场同步)与执行更新 ————
|
|
1181
|
+
// —— 状态栏统计(status-bar.js):聚合 ~/.zcode/cli/rollout 的模型调用记录 ————
|
|
1182
|
+
// model-io-*.jsonl 每行一次模型调用,response.usage 有分项 token;成本字段
|
|
1183
|
+
// 官方未落本地,成本类统计项由注入侧按“无数据自动隐藏”处理
|
|
1184
|
+
if (req.method === 'GET' && url.pathname === '/stats/usage') {
|
|
1185
|
+
const sessionId = url.searchParams.get('session') || '';
|
|
1186
|
+
const { global, recentTurns } = collectUsageStats(sessionId);
|
|
1187
|
+
// 任务总数 60 秒缓存:状态栏每 2 秒请求一次,不必每次开关 SQLite
|
|
1188
|
+
if (Date.now() - tasksCountAt > 60_000) {
|
|
1189
|
+
tasksCountAt = Date.now();
|
|
1190
|
+
tasksCountCache = await countTasks(dataRoot);
|
|
1191
|
+
}
|
|
1192
|
+
global.tasks = tasksCountCache;
|
|
1193
|
+
json(res, 200, { ok: true, global, recentTurns });
|
|
1194
|
+
return;
|
|
1195
|
+
}
|
|
1138
1196
|
if (req.method === 'GET' && url.pathname === '/plugins/status') {
|
|
1139
1197
|
await ensureZcodePluginsMarketplace();
|
|
1140
1198
|
// 有更新任务在执行或排队时跳过这里的节流同步(任务本身会同步市场并刷新
|
|
@@ -1178,6 +1236,18 @@ export function startHelper({ port, token, dataRoot, state, agentsFile = default
|
|
|
1178
1236
|
}
|
|
1179
1237
|
rejectStarted(err);
|
|
1180
1238
|
});
|
|
1239
|
+
// 启动即补视觉配置缺省键:装了 zcode-pro ⇒ cardUi=true(识图卡片开,缺省
|
|
1240
|
+
// 才补、显式设过不动,规则同 GET /vision),不必等用户打开视觉设置页
|
|
1241
|
+
try {
|
|
1242
|
+
const vFile = defaultVisionFile();
|
|
1243
|
+
const vParsed = readJsonFile(vFile);
|
|
1244
|
+
if (vParsed && typeof vParsed === 'object' && !('cardUi' in vParsed)) {
|
|
1245
|
+
vParsed.cardUi = true;
|
|
1246
|
+
const vTmp = vFile + '.tmp';
|
|
1247
|
+
writeFileSync(vTmp, JSON.stringify(vParsed, null, 2) + '\n', 'utf-8');
|
|
1248
|
+
renameSync(vTmp, vFile);
|
|
1249
|
+
}
|
|
1250
|
+
} catch { /* 无配置或不可写不影响启动 */ }
|
|
1181
1251
|
server.listen(port, '127.0.0.1', () => resolveStarted(server));
|
|
1182
1252
|
};
|
|
1183
1253
|
tryListen();
|
|
@@ -1186,6 +1256,101 @@ export function startHelper({ port, token, dataRoot, state, agentsFile = default
|
|
|
1186
1256
|
|
|
1187
1257
|
// 市场快照同步节流:helper 进程内 30 分钟最多主动 git pull 一次
|
|
1188
1258
|
let lastMarketplaceSync = 0;
|
|
1259
|
+
let tasksCountAt = 0;
|
|
1260
|
+
let tasksCountCache = 0; // 任务总数缓存(/stats/usage 高频轮询,60 秒一刷)
|
|
1261
|
+
|
|
1262
|
+
// —— 状态栏统计:rollout 模型调用记录聚合 ————
|
|
1263
|
+
// ~/.zcode/cli/rollout/model-io-*.jsonl 每行一次模型调用;usage 字段有两种键风格
|
|
1264
|
+
// (协议 camelCase 与原始 snake_case),两种都解析。文件小(个位数、每会话一个),
|
|
1265
|
+
// 按 mtime+size 缓存只重读变过的文件;聚合结果含按日分桶(本地时区)供“今日”用
|
|
1266
|
+
const usageFileCache = new Map(); // 文件名 → { mtime, size, recs: [{at, in, out, session, model}] }
|
|
1267
|
+
function rolloutRecords() {
|
|
1268
|
+
const dir = join(homedir(), '.zcode', 'cli', 'rollout');
|
|
1269
|
+
let names = [];
|
|
1270
|
+
try { names = readdirSync(dir).filter((n) => n.startsWith('model-io-') && n.endsWith('.jsonl')); } catch { return []; }
|
|
1271
|
+
const recs = [];
|
|
1272
|
+
for (const name of names) {
|
|
1273
|
+
const full = join(dir, name);
|
|
1274
|
+
let st;
|
|
1275
|
+
try { st = statSync(full); } catch { continue; }
|
|
1276
|
+
const cached = usageFileCache.get(name);
|
|
1277
|
+
if (cached && cached.mtime === st.mtimeMs && cached.size === st.size) {
|
|
1278
|
+
recs.push(...cached.recs);
|
|
1279
|
+
continue;
|
|
1280
|
+
}
|
|
1281
|
+
const parsed = [];
|
|
1282
|
+
try {
|
|
1283
|
+
for (const line of readFileSync(full, 'utf8').split('\n')) {
|
|
1284
|
+
if (!line.trim()) continue;
|
|
1285
|
+
let d;
|
|
1286
|
+
try { d = JSON.parse(line); } catch { continue; }
|
|
1287
|
+
const u = (d.response && d.response.usage) || d.usage;
|
|
1288
|
+
if (!u) continue;
|
|
1289
|
+
const num = (v) => Number(v) || 0;
|
|
1290
|
+
// 两种键风格语义相反,必须分开处理:
|
|
1291
|
+
// 协议格式(inputTokens)的总输入已含缓存读(官方 summary 证实);
|
|
1292
|
+
// 原始 API 格式(input_tokens,Anthropic 语义)三个字段分开、需相加
|
|
1293
|
+
let input, cacheRead, cacheWrite;
|
|
1294
|
+
if (u.inputTokens != null) {
|
|
1295
|
+
input = num(u.inputTokens);
|
|
1296
|
+
cacheRead = num(u.cacheReadTokens);
|
|
1297
|
+
cacheWrite = num(u.cacheWriteTokens);
|
|
1298
|
+
} else {
|
|
1299
|
+
cacheRead = num(u.cache_read_input_tokens);
|
|
1300
|
+
cacheWrite = num(u.cache_write_input_tokens);
|
|
1301
|
+
input = num(u.input_tokens) + cacheRead + cacheWrite;
|
|
1302
|
+
}
|
|
1303
|
+
const output = num(u.outputTokens ?? u.output_tokens);
|
|
1304
|
+
parsed.push({
|
|
1305
|
+
at: d.completedAt || d.startedAt || '',
|
|
1306
|
+
in: input, out: output,
|
|
1307
|
+
cacheRead,
|
|
1308
|
+
session: String(d.sessionId || ''),
|
|
1309
|
+
});
|
|
1310
|
+
}
|
|
1311
|
+
} catch { /* 文件读失败跳过 */ }
|
|
1312
|
+
usageFileCache.set(name, { mtime: st.mtimeMs, size: st.size, recs: parsed });
|
|
1313
|
+
recs.push(...parsed);
|
|
1314
|
+
}
|
|
1315
|
+
return recs;
|
|
1316
|
+
}
|
|
1317
|
+
|
|
1318
|
+
function collectUsageStats(sessionId) {
|
|
1319
|
+
const recs = rolloutRecords();
|
|
1320
|
+
const today = new Date();
|
|
1321
|
+
const todayKey = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}-${String(today.getDate()).padStart(2, '0')}`;
|
|
1322
|
+
const global = { todayTokens: 0, todayOut: 0, totalTokens: 0, totalOut: 0, tasks: 0, records: recs.length };
|
|
1323
|
+
// 每轮用量按本地 HH:mm 分桶(回答末尾的显示时间与之匹配);
|
|
1324
|
+
// 本地记录只留最近几轮,注入侧只给能对上时间的回答显示
|
|
1325
|
+
const byMinute = new Map();
|
|
1326
|
+
for (const r of recs) {
|
|
1327
|
+
const total = r.in + r.out;
|
|
1328
|
+
global.totalTokens += total;
|
|
1329
|
+
global.totalOut += r.out;
|
|
1330
|
+
const day = String(r.at || '').slice(0, 10);
|
|
1331
|
+
if (day === todayKey) { global.todayTokens += total; global.todayOut += r.out; }
|
|
1332
|
+
// 每轮回答只统计主对话流的调用:起名等辅助请求是独立小上下文、
|
|
1333
|
+
// 缓存从零开始,混进分钟桶会把命中率拉到个位数(全局统计仍全量计入)
|
|
1334
|
+
if (total < 5000) continue;
|
|
1335
|
+
// 指定会话时每轮桶只算该会话的调用——否则切到别的会话聊天后,
|
|
1336
|
+
// 那些调用会被区间归属算进当前会话最后一行的头上
|
|
1337
|
+
if (sessionId && r.session !== sessionId) continue;
|
|
1338
|
+
const t = Date.parse(r.at);
|
|
1339
|
+
if (!Number.isFinite(t)) continue;
|
|
1340
|
+
const d = new Date(t);
|
|
1341
|
+
// 键带日期(月-日 时:分):注入侧按回答行的时间文本(今天/昨天/10-21 三种
|
|
1342
|
+
// 形态)解析出同款键精确绑定,数值不随记录滚动移位
|
|
1343
|
+
const hm = (d.getMonth() + 1) + '-' + d.getDate() + ' ' + String(d.getHours()).padStart(2, '0') + ':' + String(d.getMinutes()).padStart(2, '0');
|
|
1344
|
+
const bucket = byMinute.get(hm) || { hm, in: 0, out: 0, cacheRead: 0, ts: t };
|
|
1345
|
+
bucket.in += r.in;
|
|
1346
|
+
bucket.out += r.out;
|
|
1347
|
+
bucket.cacheRead += r.cacheRead;
|
|
1348
|
+
bucket.ts = Math.max(bucket.ts, t);
|
|
1349
|
+
byMinute.set(hm, bucket);
|
|
1350
|
+
}
|
|
1351
|
+
const recentTurns = [...byMinute.values()].sort((a, b) => b.ts - a.ts).slice(0, 60);
|
|
1352
|
+
return { global, recentTurns };
|
|
1353
|
+
}
|
|
1189
1354
|
|
|
1190
1355
|
function readJsonFile(p) {
|
|
1191
1356
|
try {
|
|
@@ -1664,8 +1829,12 @@ export function mergeAliases(localRaw, remoteRaw) {
|
|
|
1664
1829
|
function applyFeaturesPartial(base, partial) {
|
|
1665
1830
|
const out = (base && typeof base === 'object') ? base : {};
|
|
1666
1831
|
if (!partial || typeof partial !== 'object') return out;
|
|
1667
|
-
|
|
1832
|
+
const known = new Set(Object.keys(defaultConfig().features));
|
|
1833
|
+
for (const key of Object.keys(partial)) {
|
|
1834
|
+
// 布尔特性一律可存(新特性未列入 defaultConfig 时不能被丢弃),
|
|
1835
|
+
// 非布尔值只在已知键内接受
|
|
1668
1836
|
if (typeof partial[key] === 'boolean') out[key] = partial[key];
|
|
1837
|
+
else if (known.has(key)) out[key] = partial[key];
|
|
1669
1838
|
}
|
|
1670
1839
|
return out;
|
|
1671
1840
|
}
|
|
@@ -2402,6 +2571,11 @@ export function validateVisionConfig(raw) {
|
|
|
2402
2571
|
skipMinutes: clampInt(raw.skipMinutes, 1, 10080, 30),
|
|
2403
2572
|
// 主模型能看图时是否也拦截识别(false = 跳过;flash 主模型始终不触发,见插件)
|
|
2404
2573
|
forceIntercept: raw.forceIntercept !== false,
|
|
2574
|
+
// 识图卡片展示:只在调用方显式给值时透传(面板开关用);缺省不写键,
|
|
2575
|
+
// 让 POST 的磁盘合并保留原值(zcode-pro 装机缺省 true 的补键逻辑见 GET /vision)
|
|
2576
|
+
...(raw.cardUi !== undefined ? { cardUi: raw.cardUi === true } : {}),
|
|
2577
|
+
// interceptMode(tool/inject)同 cardUi:显式给值才透传,缺省保持磁盘原值
|
|
2578
|
+
...(raw.interceptMode !== undefined && ['tool', 'inject'].includes(raw.interceptMode) ? { interceptMode: raw.interceptMode } : {}),
|
|
2405
2579
|
};
|
|
2406
2580
|
const names = new Set();
|
|
2407
2581
|
for (const p of raw.proxies) {
|
package/src/host/taskIndex.mjs
CHANGED
|
@@ -270,6 +270,24 @@ export async function listTaskStatuses(dataRoot, ids) {
|
|
|
270
270
|
}
|
|
271
271
|
|
|
272
272
|
// 已重命名会话的标题表:task_id → title(title_overridden = 1 且未删除)。
|
|
273
|
+
// 状态栏统计用:任务总数(未删除的会话行)
|
|
274
|
+
export async function countTasks(dataRoot) {
|
|
275
|
+
const dbFile = taskIndexPath(dataRoot);
|
|
276
|
+
if (!existsSync(dbFile)) return 0;
|
|
277
|
+
const driver = await loadDriver();
|
|
278
|
+
if (!driver) return 0;
|
|
279
|
+
const sql = 'SELECT COUNT(*) AS n FROM tasks WHERE deleted = 0';
|
|
280
|
+
try {
|
|
281
|
+
if (driver.kind === 'node') {
|
|
282
|
+
const db = new driver.DatabaseSync(dbFile, { readOnly: true });
|
|
283
|
+
try { return Number(db.prepare(sql).get()?.n) || 0; } finally { db.close(); }
|
|
284
|
+
}
|
|
285
|
+
return Number(runCli(dbFile, sql + ';').stdout.trim()) || 0;
|
|
286
|
+
} catch {
|
|
287
|
+
return 0;
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
273
291
|
// 只读单条 SELECT,不开事务;库不存在或没有 SQLite 支持时返回空表,
|
|
274
292
|
// 调用方(会话名锁定)自然降级为不干预。
|
|
275
293
|
export async function listOverriddenTitles(dataRoot) {
|
package/src/inject/core.js
CHANGED
|
@@ -48,6 +48,28 @@ export const DEFAULT_SHORTCUTS = {
|
|
|
48
48
|
switchAttention: 'r', // 打开运行中 / 未查看
|
|
49
49
|
};
|
|
50
50
|
|
|
51
|
+
// 状态栏任务统计项的两个组:会话级跟随当前会话,全局级始终显示;
|
|
52
|
+
// 设置里两节各自排序,保存时归一化为会话组在前(状态栏两组间一道竖线)
|
|
53
|
+
export const STATUS_SESSION_IDS = new Set(['ctx', 'sessionCost', 'questions']);
|
|
54
|
+
export const STATUS_GLOBAL_IDS = new Set(['todayTokens', 'todayCost', 'totalTokens', 'totalCost', 'tasks']);
|
|
55
|
+
|
|
56
|
+
// 状态栏统计项的默认清单(顺序即默认显示顺序);设置 → 样式 → 状态栏里
|
|
57
|
+
// 可调顺序与显隐,配置存在 config.statusBar.items(整表覆盖)
|
|
58
|
+
// 每轮回答统计项(turn-usage.js):回答末尾标注的三段,各自可显隐(顺序固定)
|
|
59
|
+
export const DEFAULT_TURN_ITEMS = [
|
|
60
|
+
{ id: 'turnIn', on: true }, // 输入
|
|
61
|
+
{ id: 'turnOut', on: true }, // 输出
|
|
62
|
+
{ id: 'turnCache', on: true }, // 缓存命中率
|
|
63
|
+
];
|
|
64
|
+
|
|
65
|
+
export const DEFAULT_STATUS_ITEMS = [
|
|
66
|
+
{ id: 'ctx', on: true }, // 上下文占用
|
|
67
|
+
{ id: 'questions', on: true }, // 提问次数
|
|
68
|
+
{ id: 'todayTokens', on: true }, // 今日 token(今天的会话记录完整,数据准确)
|
|
69
|
+
{ id: 'totalTokens', on: true }, // 累计 token(官方使用统计,全历史)
|
|
70
|
+
{ id: 'tasks', on: true }, // 任务总数
|
|
71
|
+
];
|
|
72
|
+
|
|
51
73
|
// —— ZCode 版本判断(按官方版本启用/停用功能)——
|
|
52
74
|
// 版本来源:启动器从 app.asar 读出后写进 bootstrap(window.__ZCODEPRO__.zcodeVersion),
|
|
53
75
|
// helper 的 /config 响应也附加同名字段。读不到时为空串,各判断保持旧行为。
|
|
@@ -245,6 +267,11 @@ const zh = {
|
|
|
245
267
|
tbPluginStore: '插件市场',
|
|
246
268
|
featurePinnedCollapse: '已置顶分区可折叠',
|
|
247
269
|
featurePinnedCollapseDesc: '侧边栏“已置顶”标题可点击折叠/展开其任务列表(与“项目”“分组”分区一致),状态记住。',
|
|
270
|
+
featureSandboxReclaim: '插件界面沙箱回收',
|
|
271
|
+
featureSandboxReclaimDesc: '插件卡片沙箱数量达到上限时自动释放最早创建的(正在查看的排最后),避免 64 个名额耗尽后新卡片打不开;被释放的旧卡片重新打开所在会话即恢复(预算可在配置文件 sandboxReclaimBudget 调整)。',
|
|
272
|
+
featurePluginCardRetry: '插件卡片自动重试',
|
|
273
|
+
featurePluginCardRetryDesc: '因名额上限加载失败的插件卡片自动点击“重新加载”,名额空出后自动恢复,无需手动蹲守。',
|
|
274
|
+
visionCardUi: '思考过程显示识图卡片',
|
|
248
275
|
pinnedToggleAria: '折叠/展开已置顶',
|
|
249
276
|
sidebarProjectSpacingName: '项目间距',
|
|
250
277
|
sidebarProjectSpacingDesc: '侧边栏中相邻项目行之间的视觉间距(含行内留白);设置后各分区(已置顶/项目/任务)之间的间距也统一为该值。',
|
|
@@ -269,6 +296,33 @@ const zh = {
|
|
|
269
296
|
featureNewtabPanelsDesc: '官方“新标签页”只列官方面板与推荐的官方插件,此功能把第三方市场插件(headroom、rtk 等)的面板卡片加进去,点击在侧边面板打开。',
|
|
270
297
|
ntpDescSuffix: '插件面板',
|
|
271
298
|
ntpNeedSession: '先打开一个任务会话,再从新标签页打开插件面板',
|
|
299
|
+
featureStatusBarDesc: '主界面底部右下角显示统计项:上下文占用、会话/今日/累计 token、任务数等;各项的顺序与显隐在“样式 → 状态栏”里调整。',
|
|
300
|
+
tabStylesPane: '样式调整',
|
|
301
|
+
tabStatusPane: '状态栏',
|
|
302
|
+
sbItemCtx: '上下文占用',
|
|
303
|
+
sbItemQuestions: '提问次数',
|
|
304
|
+
sbMaster: '启用统计',
|
|
305
|
+
sbMasterDesc: '总开关:关闭后底部状态栏与每轮回答的用量标注一起停用。',
|
|
306
|
+
sbGroupSession: '会话统计(跟随当前会话)',
|
|
307
|
+
sbGroupGlobal: '总统计',
|
|
308
|
+
sbGroupTurn: '每轮统计(回答末尾)',
|
|
309
|
+
sbLabelIn: '输入',
|
|
310
|
+
sbLabelOut: '输出',
|
|
311
|
+
sbLabelCache: '缓存',
|
|
312
|
+
turnUsageTitle: '本轮 token',
|
|
313
|
+
sbLabelQuestions: '提问',
|
|
314
|
+
sbItemTodayTokens: '今日 token',
|
|
315
|
+
sbItemTasks: '任务总数',
|
|
316
|
+
sbLabelCtx: '上下文',
|
|
317
|
+
sbLabelTurns: '调用',
|
|
318
|
+
sbLabelToday: '今日',
|
|
319
|
+
sbLabelTasks: '任务',
|
|
320
|
+
sbItemTotalTokens: '累计 token',
|
|
321
|
+
sbLabelTotal: '累计',
|
|
322
|
+
sbMoveUp: '上移',
|
|
323
|
+
sbMoveDown: '下移',
|
|
324
|
+
sbVisible: '显示',
|
|
325
|
+
sbHidden: '隐藏',
|
|
272
326
|
tabShortcuts: '快捷键',
|
|
273
327
|
hkTitle: '会话快捷切换',
|
|
274
328
|
hkAltZ: '在当前与上次使用的会话间来回切换',
|
|
@@ -670,6 +724,11 @@ const en = {
|
|
|
670
724
|
tbPluginStore: 'Plugin store',
|
|
671
725
|
featurePinnedCollapse: 'Collapsible Pinned section',
|
|
672
726
|
featurePinnedCollapseDesc: 'The sidebar "Pinned" header collapses/expands its task list (like Projects and Groups); the state is remembered.',
|
|
727
|
+
featureSandboxReclaim: 'Plugin card sandbox reclaim',
|
|
728
|
+
featureSandboxReclaimDesc: 'Releases the oldest plugin-card sandboxes once the cap is hit (the one being viewed goes last), so new cards keep opening after the 64-slot pool fills up; released cards come back when their session is reopened (budget adjustable via sandboxReclaimBudget in the config file).',
|
|
729
|
+
featurePluginCardRetry: 'Plugin card auto-retry',
|
|
730
|
+
featurePluginCardRetryDesc: 'Auto-clicks "Reload" on cards that failed the slot cap; they recover as soon as a slot frees up.',
|
|
731
|
+
visionCardUi: 'Vision cards',
|
|
673
732
|
pinnedToggleAria: 'Collapse/expand Pinned',
|
|
674
733
|
sidebarProjectSpacingName: 'Project spacing',
|
|
675
734
|
sidebarProjectSpacingDesc: 'Visual spacing between adjacent project rows in the sidebar (row padding included); when set, section gaps (Pinned/Projects/Tasks) follow the same value.',
|
|
@@ -694,6 +753,33 @@ const en = {
|
|
|
694
753
|
featureNewtabPanelsDesc: "The built-in \"new tab page\" only lists built-in panels and recommended official plugins; this adds cards for third-party marketplace plugin panels (headroom, rtk, …) that open in the side pane.",
|
|
695
754
|
ntpDescSuffix: 'plugin panel',
|
|
696
755
|
ntpNeedSession: 'Open a task session first, then open plugin panels from the new tab page',
|
|
756
|
+
featureStatusBarDesc: 'Shows statistics at the bottom-right of the main view: context usage, session/today/total tokens, task count, etc.; reorder or hide each item under "Styles → Status bar".',
|
|
757
|
+
tabStylesPane: 'Styles',
|
|
758
|
+
tabStatusPane: 'Status bar',
|
|
759
|
+
sbItemCtx: 'Context usage',
|
|
760
|
+
sbItemQuestions: 'Questions asked',
|
|
761
|
+
sbMaster: 'Enable statistics',
|
|
762
|
+
sbMasterDesc: 'Master switch: turning this off disables both the status bar and per-turn labels.',
|
|
763
|
+
sbGroupSession: 'Session statistics (follows current session)',
|
|
764
|
+
sbGroupGlobal: 'Global statistics',
|
|
765
|
+
sbGroupTurn: 'Per-turn statistics (answer rows)',
|
|
766
|
+
sbLabelIn: 'in',
|
|
767
|
+
sbLabelOut: 'out',
|
|
768
|
+
sbLabelCache: 'cache',
|
|
769
|
+
turnUsageTitle: 'Turn tokens',
|
|
770
|
+
sbLabelQuestions: 'asks',
|
|
771
|
+
sbItemTodayTokens: 'Today tokens',
|
|
772
|
+
sbItemTasks: 'Tasks',
|
|
773
|
+
sbLabelCtx: 'ctx',
|
|
774
|
+
sbLabelTurns: 'calls',
|
|
775
|
+
sbLabelToday: 'today',
|
|
776
|
+
sbLabelTasks: 'tasks',
|
|
777
|
+
sbItemTotalTokens: 'Total tokens',
|
|
778
|
+
sbLabelTotal: 'total',
|
|
779
|
+
sbMoveUp: 'Up',
|
|
780
|
+
sbMoveDown: 'Down',
|
|
781
|
+
sbVisible: 'shown',
|
|
782
|
+
sbHidden: 'hidden',
|
|
697
783
|
tabShortcuts: 'Shortcuts',
|
|
698
784
|
hkTitle: 'Session quick switch',
|
|
699
785
|
hkAltZ: 'Toggle between the current and the last-used session',
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
// 插件卡片自动重试:宿主对插件交互卡片(沙箱)有全局 64 个名额,重度会话打开时
|
|
2
|
+
// 注册洪峰会撞上限,一部分卡片显示"插件界面数量已达上限/加载失败"并带"重新加载"
|
|
3
|
+
// 按钮。名额由宿主缓慢回收(或沙箱回收功能主动释放),空出后重试即成——但手动
|
|
4
|
+
// 蹲守空窗不现实(真机实测:名额约一小时只腾出两个,每次腾出恰好能救一张卡)。
|
|
5
|
+
// 本功能定期找出失败卡片的"重新加载"按钮自动点按:名额一空卡片自动复活。
|
|
6
|
+
// 匹配规则:按钮文字恰为"重新加载",且四层祖先内含"插件界面/已达上限/加载失败"
|
|
7
|
+
// 字样,避免误点其他"重新加载"。
|
|
8
|
+
// 开关:features.pluginCardRetry(默认开)。
|
|
9
|
+
import { getConfig } from '../core.js';
|
|
10
|
+
|
|
11
|
+
const SCAN_MS = 15_000; // 巡检周期
|
|
12
|
+
const CLICK_INTERVAL_MS = 30_000; // 两次点按的最小间隔(全局节流)
|
|
13
|
+
// 宿主两种失败文案("插件界面加载失败""插件界面数量已达上限")都含"插件界面",
|
|
14
|
+
// 只认这一个词足够精确,也避免误点其他"重新加载"按钮
|
|
15
|
+
const MARKERS = ['插件界面'];
|
|
16
|
+
|
|
17
|
+
let lastClickAt = 0; // 全局节流:按按钮元素记冷却会被宿主重建的新元素绕过
|
|
18
|
+
|
|
19
|
+
function failedRetryButtons() {
|
|
20
|
+
const out = [];
|
|
21
|
+
for (const btn of document.querySelectorAll('button')) {
|
|
22
|
+
if ((btn.textContent || '').trim() !== '重新加载') continue;
|
|
23
|
+
let anc = btn;
|
|
24
|
+
for (let i = 0; i < 4 && anc.parentElement; i++) {
|
|
25
|
+
anc = anc.parentElement;
|
|
26
|
+
const ctx = anc.textContent || '';
|
|
27
|
+
if (MARKERS.some((m) => ctx.includes(m))) { out.push(btn); break; }
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
return out;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
async function tick() {
|
|
34
|
+
const cfg = await getConfig();
|
|
35
|
+
if ((cfg.features || {}).pluginCardRetry === false) return;
|
|
36
|
+
const now = Date.now();
|
|
37
|
+
if (now - lastClickAt < CLICK_INTERVAL_MS) return;
|
|
38
|
+
for (const btn of failedRetryButtons()) {
|
|
39
|
+
lastClickAt = now;
|
|
40
|
+
btn.click();
|
|
41
|
+
// 一轮只点一个:名额通常一次只空一个,排队恢复,避免齐发再撞上限
|
|
42
|
+
break;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function startPluginCardRetry() {
|
|
47
|
+
setInterval(() => { tick().catch(() => { /* 单次巡检失败不影响后续 */ }); }, SCAN_MS);
|
|
48
|
+
}
|
|
@@ -27,7 +27,29 @@ const MIN_BUDGET = 8;
|
|
|
27
27
|
const MAX_BUDGET = 64;
|
|
28
28
|
const RETRY_COOLDOWN_MS = 10 * 60_000; // 同一沙箱释放失败后的重试冷却
|
|
29
29
|
|
|
30
|
+
const SEEN_KEY = 'zcodepro:sbx-seen'; // 登记簿持久化键(localStorage,跨 zcode-pro 重载保留)
|
|
30
31
|
const seen = new Map(); // sandboxId → { initId, firstSeen }
|
|
32
|
+
function loadSeen() {
|
|
33
|
+
try {
|
|
34
|
+
const raw = JSON.parse(localStorage.getItem(SEEN_KEY) || '{}');
|
|
35
|
+
for (const [id, v] of Object.entries(raw)) {
|
|
36
|
+
if (id && v && Number.isFinite(v.firstSeen)) seen.set(id, { initId: v.initId, firstSeen: v.firstSeen });
|
|
37
|
+
}
|
|
38
|
+
} catch { /* 损坏则从零开始 */ }
|
|
39
|
+
}
|
|
40
|
+
let saveSeenTimer = null;
|
|
41
|
+
function saveSeenSoon() {
|
|
42
|
+
// 防抖落盘:广播可能成串到达,逐条写 localStorage 没必要
|
|
43
|
+
if (saveSeenTimer) return;
|
|
44
|
+
saveSeenTimer = setTimeout(() => {
|
|
45
|
+
saveSeenTimer = null;
|
|
46
|
+
try {
|
|
47
|
+
const out = {};
|
|
48
|
+
for (const [id, v] of seen) out[id] = v;
|
|
49
|
+
localStorage.setItem(SEEN_KEY, JSON.stringify(out));
|
|
50
|
+
} catch { /* 写失败不影响运行 */ }
|
|
51
|
+
}, 2000);
|
|
52
|
+
}
|
|
31
53
|
const cooldown = new Map(); // sandboxId → 释放失败冷却截止时刻
|
|
32
54
|
let disabledLogged = false;
|
|
33
55
|
|
|
@@ -37,6 +59,7 @@ function onPortsMessage(e) {
|
|
|
37
59
|
// 端口广播≈沙箱创建时刻;页面刷新后宿主按幂等键复用同一沙箱并再次广播,
|
|
38
60
|
// 创建时刻随之刷新(视作新名额重新排队)
|
|
39
61
|
seen.set(d.sandboxId, { initId: Number(d.initId) || 0, firstSeen: Date.now() });
|
|
62
|
+
saveSeenSoon();
|
|
40
63
|
}
|
|
41
64
|
|
|
42
65
|
function extractSandboxId(wv) {
|
|
@@ -74,36 +97,41 @@ async function tick() {
|
|
|
74
97
|
budget = Math.min(MAX_BUDGET, Math.max(MIN_BUDGET, budget));
|
|
75
98
|
|
|
76
99
|
const now = Date.now();
|
|
77
|
-
|
|
78
|
-
|
|
100
|
+
// 存活计数以端口广播登记数(seen)为准:卡片滚出视口后宿主会把它收进停靠区并从
|
|
101
|
+
// 常驻 DOM 摘除(实测 DOM webview 数远小于宿主注册数),拿 DOM 数当触发条件
|
|
102
|
+
// 永远到不了预算、从不驱逐——名额就一直是满的。DOM 只用来标记"正在被看":
|
|
103
|
+
const visible = new Set(); // 展示中的 sandboxId(驱逐时避开)
|
|
104
|
+
for (const wv of document.querySelectorAll('webview')) {
|
|
79
105
|
const id = extractSandboxId(wv);
|
|
80
|
-
if (id)
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
}
|
|
86
|
-
// 重注入后端口广播没赶上(Map 为空)的兜底:首次观察到时按现在起算创建时刻
|
|
87
|
-
for (const id of live.keys()) {
|
|
88
|
-
if (!seen.has(id)) seen.set(id, { initId: undefined, firstSeen: now });
|
|
106
|
+
if (id) {
|
|
107
|
+
if (isVisible(wv)) visible.add(id);
|
|
108
|
+
// 漏听广播的名额(持久化损坏/清空过):见到即补录,创建时刻从现在起算
|
|
109
|
+
if (!seen.has(id)) { seen.set(id, { initId: undefined, firstSeen: now }); saveSeenSoon(); }
|
|
110
|
+
}
|
|
89
111
|
}
|
|
90
112
|
|
|
91
|
-
while (
|
|
92
|
-
// 驱逐顺序:不可见的按创建时间最早的 →
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
if (!
|
|
97
|
-
const
|
|
113
|
+
while (seen.size > budget) {
|
|
114
|
+
// 驱逐顺序:不可见的按创建时间最早的 → 全都可见时最早的(保持原语义)
|
|
115
|
+
let pool = [...seen.keys()].filter((id) => (cooldown.get(id) || 0) <= now && !visible.has(id));
|
|
116
|
+
if (!pool.length) pool = [...seen.keys()].filter((id) => (cooldown.get(id) || 0) <= now);
|
|
117
|
+
pool.sort((a, b) => ((seen.get(a) || {}).firstSeen || 0) - ((seen.get(b) || {}).firstSeen || 0));
|
|
118
|
+
if (!pool.length) break;
|
|
119
|
+
const id = pool[0];
|
|
98
120
|
const entry = seen.get(id);
|
|
99
121
|
try {
|
|
100
122
|
await bridge.disposeSandbox(id, entry && entry.initId);
|
|
101
|
-
console.info('[zcodepro] sandbox-reclaim:
|
|
123
|
+
console.info('[zcodepro] sandbox-reclaim: 登记数 ' + seen.size + ' 超预算 ' + budget + ',已驱逐最早的 ' + id + (visible.has(id) ? '(可见,属兜底)' : ''));
|
|
102
124
|
} catch (err) {
|
|
103
125
|
console.warn('[zcodepro] sandbox-reclaim: 释放失败 ' + id, err);
|
|
126
|
+
// 失败也出账:死 id(如应用重启后的存量)不清会永远排最旧、堵住驱逐队列;
|
|
127
|
+
// 真活着的名额漏掉这一轮,下张卡创建时广播会重新入账
|
|
128
|
+
seen.delete(id);
|
|
129
|
+
continue;
|
|
104
130
|
}
|
|
105
131
|
seen.delete(id);
|
|
106
|
-
|
|
132
|
+
saveSeenSoon();
|
|
133
|
+
// 驱逐成功也设冷却:宿主侧真正释放是异步的,同轮连发多个 dispose 可能
|
|
134
|
+
// 在宿主来不及记账时重复命中;冷却让下一轮重新评估剩余数量
|
|
107
135
|
cooldown.set(id, now + RETRY_COOLDOWN_MS);
|
|
108
136
|
}
|
|
109
137
|
if (cooldown.size > 256) {
|
|
@@ -114,6 +142,7 @@ async function tick() {
|
|
|
114
142
|
}
|
|
115
143
|
|
|
116
144
|
export function startSandboxReclaim() {
|
|
145
|
+
loadSeen();
|
|
117
146
|
window.addEventListener('message', onPortsMessage);
|
|
118
147
|
const loop = () => { tick().catch(() => { /* 单次巡检失败不影响后续 */ }); };
|
|
119
148
|
loop();
|