zcode-pro 1.1.0 → 1.2.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/README.en.md +4 -0
- package/README.md +4 -0
- package/dist/inject.js +1182 -78
- package/package.json +1 -1
- package/src/host/helper.mjs +174 -4
- package/src/host/taskIndex.mjs +18 -0
- package/src/inject/core.js +96 -0
- package/src/inject/features/newtab-panels.js +224 -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 +151 -3
- package/src/inject/features/status-bar.js +274 -0
- package/src/inject/features/turn-usage.js +195 -0
- package/src/inject/index.js +10 -0
- package/src/inject/ui.js +50 -0
|
@@ -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();
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// “ZCode Pro 增强设置”弹窗:功能开关 + 样式调整 + 运行状态。
|
|
2
2
|
// 顶部标签页切换(视觉参考侧栏“分组/项目”切换);配置写入 helper(~/.zcode/zcodepro.json)。
|
|
3
|
-
import { h, t, rpc, getConfig, clearConfigCache, errText, HELPER_URL, isZhLocale, isNewProjectSidebar, DEFAULT_SHORTCUTS } from '../core.js';
|
|
3
|
+
import { h, t, rpc, getConfig, clearConfigCache, errText, HELPER_URL, isZhLocale, isNewProjectSidebar, DEFAULT_SHORTCUTS, DEFAULT_STATUS_ITEMS, DEFAULT_TURN_ITEMS, STATUS_SESSION_IDS, STATUS_GLOBAL_IDS } from '../core.js';
|
|
4
4
|
import { openDialog, dialogFooter, btnPrimary, btnSecondary, btnSmall, settingRow, ensureStyle, showToast, numberField, unitField } from '../ui.js';
|
|
5
5
|
import { comboField, providerOptionsOf, modelOptionsOf, modelPlaceholderOf, isManualConn } from '../provider-fields.js';
|
|
6
6
|
import { refreshAliases } from './alias.js';
|
|
@@ -355,11 +355,36 @@ export function openSettingsDialog() {
|
|
|
355
355
|
if (await setFeature('imageCopy', next)) f.imageCopy = next;
|
|
356
356
|
refreshRows();
|
|
357
357
|
}),
|
|
358
|
+
settingRow(L.featureSandboxReclaim, L.featureSandboxReclaimDesc, f.sandboxReclaim !== false, async () => {
|
|
359
|
+
const next = !(f.sandboxReclaim !== false);
|
|
360
|
+
if (await setFeature('sandboxReclaim', next)) f.sandboxReclaim = next;
|
|
361
|
+
refreshRows();
|
|
362
|
+
}),
|
|
363
|
+
settingRow(L.featurePluginCardRetry, L.featurePluginCardRetryDesc, f.pluginCardRetry !== false, async () => {
|
|
364
|
+
const next = !(f.pluginCardRetry !== false);
|
|
365
|
+
if (await setFeature('pluginCardRetry', next)) f.pluginCardRetry = next;
|
|
366
|
+
refreshRows();
|
|
367
|
+
}),
|
|
358
368
|
settingRow(L.featureWsRunning, L.featureWsRunningDesc, f.wsRunningSpin !== false, async () => {
|
|
359
369
|
const next = !(f.wsRunningSpin !== false);
|
|
360
370
|
if (await setFeature('wsRunningSpin', next)) f.wsRunningSpin = next;
|
|
361
371
|
refreshRows();
|
|
362
372
|
}),
|
|
373
|
+
settingRow(L.featureNewtabPanels, L.featureNewtabPanelsDesc, f.newtabPanels !== false, async () => {
|
|
374
|
+
const next = !(f.newtabPanels !== false);
|
|
375
|
+
if (await setFeature('newtabPanels', next)) f.newtabPanels = next;
|
|
376
|
+
refreshRows();
|
|
377
|
+
}),
|
|
378
|
+
settingRow(L.featureStatusBar, L.featureStatusBarDesc, f.statusBar !== false, async () => {
|
|
379
|
+
const next = !(f.statusBar !== false);
|
|
380
|
+
if (await setFeature('statusBar', next)) f.statusBar = next;
|
|
381
|
+
refreshRows();
|
|
382
|
+
}),
|
|
383
|
+
settingRow(L.featureTurnUsage, L.featureTurnUsageDesc, f.turnUsage !== false, async () => {
|
|
384
|
+
const next = !(f.turnUsage !== false);
|
|
385
|
+
if (await setFeature('turnUsage', next)) f.turnUsage = next;
|
|
386
|
+
refreshRows();
|
|
387
|
+
}),
|
|
363
388
|
(() => {
|
|
364
389
|
// 标题 = “自动更新 duanluan/zcode-plugins 插件”,仓库名可点击跳转仓库
|
|
365
390
|
const repoLink = h('span', {
|
|
@@ -529,7 +554,7 @@ export function openSettingsDialog() {
|
|
|
529
554
|
h('div', { class: 'shrink-0' }, btnSmall(L.hkReset, () => { void resetShortcuts(); }))),
|
|
530
555
|
hkCard,
|
|
531
556
|
);
|
|
532
|
-
const paneStyles = h('div', { role: 'tabpanel', class: 'mt-
|
|
557
|
+
const paneStyles = h('div', { role: 'tabpanel', class: 'mt-2', style: 'display:none' });
|
|
533
558
|
const panePerf = h('div', { role: 'tabpanel', class: 'mt-4', style: 'display:none' });
|
|
534
559
|
const paneAgents = h('div', { role: 'tabpanel', class: 'mt-2', style: 'display:none' });
|
|
535
560
|
const paneProxy = h('div', { role: 'tabpanel', class: 'mt-4', style: 'display:none' });
|
|
@@ -723,7 +748,125 @@ export function openSettingsDialog() {
|
|
|
723
748
|
for (const c of fontCells) c.setOptions(list);
|
|
724
749
|
})();
|
|
725
750
|
renderToolbarSwitch();
|
|
726
|
-
|
|
751
|
+
// “样式”标签页的二级结构:样式调整(原内容)/ 状态栏(统计项顺序与显隐)
|
|
752
|
+
const paneStylesSub1 = h('div', { class: 'mt-4' });
|
|
753
|
+
const paneStylesSub2 = h('div', { class: 'mt-4', style: 'display:none' });
|
|
754
|
+
const ITEM_NAMES = {
|
|
755
|
+
ctx: 'sbItemCtx', questions: 'sbItemQuestions',
|
|
756
|
+
todayTokens: 'sbItemTodayTokens', totalTokens: 'sbItemTotalTokens', tasks: 'sbItemTasks',
|
|
757
|
+
};
|
|
758
|
+
// 读取并归一化:会话组项在前、全局组项在后(组内保持保存的顺序),
|
|
759
|
+
// 保证状态栏两组之间只有一道竖线
|
|
760
|
+
const sbItems = () => {
|
|
761
|
+
const saved = config.statusBar && Array.isArray(config.statusBar.items) ? config.statusBar.items : null;
|
|
762
|
+
if (!saved) return DEFAULT_STATUS_ITEMS.map((x) => ({ ...x }));
|
|
763
|
+
const byId = new Map(saved.map((x) => [x.id, x]));
|
|
764
|
+
const list = saved.filter((x) => ITEM_NAMES[x.id]).map((x) => ({ on: x.on !== false, id: x.id }));
|
|
765
|
+
for (const d of DEFAULT_STATUS_ITEMS) if (!byId.has(d.id)) list.push({ ...d });
|
|
766
|
+
return [
|
|
767
|
+
...list.filter((x) => STATUS_SESSION_IDS.has(x.id)),
|
|
768
|
+
...list.filter((x) => STATUS_GLOBAL_IDS.has(x.id)),
|
|
769
|
+
];
|
|
770
|
+
};
|
|
771
|
+
let sbSaving = false; // 保存进行中忽略后续点击:连点会基于旧配置覆盖掉上一次的改动
|
|
772
|
+
const saveSbItems = async (next) => {
|
|
773
|
+
if (sbSaving) return;
|
|
774
|
+
sbSaving = true;
|
|
775
|
+
try {
|
|
776
|
+
const res = await rpc('/config', { method: 'POST', body: { statusBar: { items: next } } });
|
|
777
|
+
clearConfigCache();
|
|
778
|
+
window.dispatchEvent(new CustomEvent('zcodepro:config-changed'));
|
|
779
|
+
if (!res.ok) { showToast(L.failed + ': ' + errText(res), 'error'); renderSbItems(); return; }
|
|
780
|
+
if (res.config && res.config.statusBar) config.statusBar = res.config.statusBar;
|
|
781
|
+
} finally { sbSaving = false; }
|
|
782
|
+
renderSbItems();
|
|
783
|
+
};
|
|
784
|
+
const sbList = h('div', { class: 'divide-y divide-border rounded-xl border border-border' });
|
|
785
|
+
const sbGlobalList = h('div', { class: 'divide-y divide-border rounded-xl border border-border' });
|
|
786
|
+
// 每轮统计节(输入/输出/缓存,只开关不排序——显示顺序固定)
|
|
787
|
+
const TURN_NAMES = { turnIn: 'sbLabelIn', turnOut: 'sbLabelOut', turnCache: 'sbLabelCache' };
|
|
788
|
+
const turnItems = () => {
|
|
789
|
+
const saved = config.statusBar && Array.isArray(config.statusBar.turnItems) ? config.statusBar.turnItems : null;
|
|
790
|
+
if (!saved) return DEFAULT_TURN_ITEMS.map((x) => ({ ...x }));
|
|
791
|
+
const byId = new Map(saved.map((x) => [x.id, x]));
|
|
792
|
+
const list = saved.filter((x) => TURN_NAMES[x.id]).map((x) => ({ on: x.on !== false, id: x.id }));
|
|
793
|
+
for (const d of DEFAULT_TURN_ITEMS) if (!byId.has(d.id)) list.push({ ...d });
|
|
794
|
+
return list;
|
|
795
|
+
};
|
|
796
|
+
const saveTurnItems = async (next) => {
|
|
797
|
+
if (sbSaving) return;
|
|
798
|
+
sbSaving = true;
|
|
799
|
+
try {
|
|
800
|
+
const res = await rpc('/config', { method: 'POST', body: { statusBar: { turnItems: next } } });
|
|
801
|
+
clearConfigCache();
|
|
802
|
+
window.dispatchEvent(new CustomEvent('zcodepro:config-changed'));
|
|
803
|
+
if (!res.ok) { showToast(L.failed + ': ' + errText(res), 'error'); renderSbItems(); return; }
|
|
804
|
+
if (res.config && res.config.statusBar) config.statusBar = res.config.statusBar;
|
|
805
|
+
} finally { sbSaving = false; }
|
|
806
|
+
renderSbItems();
|
|
807
|
+
};
|
|
808
|
+
const turnList = h('div', { class: 'divide-y divide-border rounded-xl border border-border' });
|
|
809
|
+
const renderSbItems = () => {
|
|
810
|
+
turnList.replaceChildren(...turnItems().map((it, i) => h('div', { class: 'flex items-center gap-2 px-3 py-2' },
|
|
811
|
+
h('span', { class: 'min-w-0 flex-1 truncate text-ui-sm text-foreground-subtle' }, L[TURN_NAMES[it.id]] || it.id),
|
|
812
|
+
btnSmall(it.on ? L.sbVisible : L.sbHidden, () => { void saveTurnItems(turnItems().map((x) => x.id === it.id ? { ...x, on: !x.on } : x)); }),
|
|
813
|
+
btnSmall('↑', () => { const a = turnItems(); if (i > 0) { [a[i - 1], a[i]] = [a[i], a[i - 1]]; void saveTurnItems(a); } }),
|
|
814
|
+
btnSmall('↓', () => { const a = turnItems(); if (i < a.length - 1) { [a[i], a[i + 1]] = [a[i + 1], a[i]]; void saveTurnItems(a); } }))));
|
|
815
|
+
// 两节各列各的组;↑/↓ 只在组内移动(跨组相邻的交换会被组过滤弹开)
|
|
816
|
+
const list = sbItems();
|
|
817
|
+
const renderGroup = (el, groupIds) => {
|
|
818
|
+
const idxs = list.map((x, i) => i).filter((i) => groupIds.has(list[i].id));
|
|
819
|
+
el.replaceChildren(...idxs.map((i) => {
|
|
820
|
+
const it = list[i];
|
|
821
|
+
const swap = (dir) => {
|
|
822
|
+
const a = sbItems();
|
|
823
|
+
// 找同组相邻项的索引再交换,天然不会越组
|
|
824
|
+
const same = a.map((x, j) => j).filter((j) => groupIds.has(a[j].id));
|
|
825
|
+
const pos = same.indexOf(i);
|
|
826
|
+
const other = same[pos + dir];
|
|
827
|
+
if (other == null) return;
|
|
828
|
+
[a[i], a[other]] = [a[other], a[i]];
|
|
829
|
+
void saveSbItems(a);
|
|
830
|
+
};
|
|
831
|
+
return h('div', { class: 'flex items-center gap-2 px-3 py-2' },
|
|
832
|
+
h('span', { class: 'min-w-0 flex-1 truncate text-ui-sm text-foreground-subtle' },
|
|
833
|
+
L[ITEM_NAMES[it.id]] || it.id),
|
|
834
|
+
btnSmall(it.on ? L.sbVisible : L.sbHidden, () => { void saveSbItems(sbItems().map((x, j) => j === i ? { ...x, on: !x.on } : x)); }),
|
|
835
|
+
btnSmall('↑', () => swap(-1)),
|
|
836
|
+
btnSmall('↓', () => swap(1)));
|
|
837
|
+
}));
|
|
838
|
+
};
|
|
839
|
+
renderGroup(sbList, STATUS_SESSION_IDS);
|
|
840
|
+
renderGroup(sbGlobalList, STATUS_GLOBAL_IDS);
|
|
841
|
+
};
|
|
842
|
+
renderSbItems();
|
|
843
|
+
paneStylesSub2.append(
|
|
844
|
+
h('p', { class: 'text-ui-sm/relaxed text-foreground-subtle' }, L.featureStatusBarDesc),
|
|
845
|
+
h('div', { class: 'mt-3 mb-1 text-ui-sm font-medium' }, L.sbGroupSession),
|
|
846
|
+
sbList,
|
|
847
|
+
h('div', { class: 'mt-3 mb-1 text-ui-sm font-medium' }, L.sbGroupGlobal),
|
|
848
|
+
sbGlobalList,
|
|
849
|
+
h('div', { class: 'mt-3 mb-1 text-ui-sm font-medium' }, L.sbGroupTurn),
|
|
850
|
+
turnList);
|
|
851
|
+
let stylesSubTab = 'styles';
|
|
852
|
+
const stylesSubTablist = h('div', { role: 'tablist', 'aria-orientation': 'horizontal', class: 'zcodepro-tablist' });
|
|
853
|
+
const renderStylesSubTabs = () => stylesSubTablist.replaceChildren(...[
|
|
854
|
+
['styles', L.tabStylesPane],
|
|
855
|
+
['status', L.tabStatusPane],
|
|
856
|
+
].map(([id, label]) => h('button', {
|
|
857
|
+
type: 'button', role: 'tab', class: 'zcodepro-tab',
|
|
858
|
+
'aria-selected': String(stylesSubTab === id),
|
|
859
|
+
'data-state': stylesSubTab === id ? 'active' : 'inactive',
|
|
860
|
+
onClick: () => {
|
|
861
|
+
stylesSubTab = id;
|
|
862
|
+
paneStylesSub1.style.display = id === 'styles' ? '' : 'none';
|
|
863
|
+
paneStylesSub2.style.display = id === 'status' ? '' : 'none';
|
|
864
|
+
renderStylesSubTabs();
|
|
865
|
+
},
|
|
866
|
+
}, label)));
|
|
867
|
+
renderStylesSubTabs();
|
|
868
|
+
paneStyles.append(stylesSubTablist, paneStylesSub1, paneStylesSub2);
|
|
869
|
+
paneStylesSub1.append(
|
|
727
870
|
toolbarSwitchWrap,
|
|
728
871
|
h('div', { class: 'mt-2 flex flex-col gap-0.5 rounded-xl border border-border p-1' },
|
|
729
872
|
...fontCells.map((c) => h('div', { class: 'rounded-lg transition-colors hover:bg-surface-hover' }, c.el))),
|
|
@@ -1595,6 +1738,11 @@ export function openSettingsDialog() {
|
|
|
1595
1738
|
modeBtn('fallback', L.visionModeFallback, L.visionModeFallbackDesc),
|
|
1596
1739
|
modeBtn('pipeline', L.visionModePipeline, L.visionModePipelineDesc),
|
|
1597
1740
|
h('div', { class: 'ml-auto flex items-center gap-4' },
|
|
1741
|
+
settingRow(L.visionCardUi, '', visionCfg.cardUi !== false, async () => {
|
|
1742
|
+
visionCfg.cardUi = !(visionCfg.cardUi !== false);
|
|
1743
|
+
await persistVision();
|
|
1744
|
+
renderVision();
|
|
1745
|
+
}),
|
|
1598
1746
|
settingRow(L.visionForceIntercept, '', visionCfg.forceIntercept !== false, async () => {
|
|
1599
1747
|
visionCfg.forceIntercept = !(visionCfg.forceIntercept !== false);
|
|
1600
1748
|
await persistVision();
|
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
// 状态栏统计:主界面底部右下角一行小字,按用户配置的顺序显示统计项。
|
|
2
|
+
// 会话项(上下文占用 / 提问次数)跟随当前会话,无选中会话时自动隐藏;
|
|
3
|
+
// 全局项(今日 / 累计 token、任务总数)优先取官方使用统计(云端,5 分钟刷新),
|
|
4
|
+
// 回退本地聚合。数据 2 秒刷新一次;上下文占用读官方输入框工具栏的窗口显示
|
|
5
|
+
//(“已用 497,101 / 总量 1,000,000”),按官方同款格式渲染。
|
|
6
|
+
import { t, rpc, getConfig, isZhLocale, DEFAULT_STATUS_ITEMS } from '../core.js';
|
|
7
|
+
import { ensureStyle } from '../ui.js';
|
|
8
|
+
|
|
9
|
+
let enabled = true;
|
|
10
|
+
let items = DEFAULT_STATUS_ITEMS.map((x) => ({ ...x }));
|
|
11
|
+
let barEl = null;
|
|
12
|
+
let lastStats = null; // /stats/usage 响应缓存(失败保旧值)
|
|
13
|
+
|
|
14
|
+
// —— 工具 ——
|
|
15
|
+
|
|
16
|
+
// 官方输入框工具栏的上下文显示:title 形如“上下文已用 61,400 / 总量 1,000,000”,
|
|
17
|
+
// 解析出已用与总量;读不到返回 null(ctx 项回退按会话记录估算 token 数)。
|
|
18
|
+
// 直接在全文档找(该显示全局唯一),排除自己渲染的 title
|
|
19
|
+
function readCtx() {
|
|
20
|
+
for (const el of document.querySelectorAll('[title], [aria-label]')) {
|
|
21
|
+
if (el.closest('.zcodepro-statusbar')) continue;
|
|
22
|
+
const text = el.getAttribute('title') || el.getAttribute('aria-label') || '';
|
|
23
|
+
if (!/上下文|context/i.test(text)) continue;
|
|
24
|
+
// 格式为「已用 497,101 / 总量 1,000,000」:斜杠后可能带“总量”等文字再接数字
|
|
25
|
+
const m = text.match(/([\d][\d,]*)\s*\/[^0-9]*([\d][\d,]*)/);
|
|
26
|
+
if (!m) continue;
|
|
27
|
+
const used = Number(m[1].replace(/,/g, ''));
|
|
28
|
+
const size = Number(m[2].replace(/,/g, ''));
|
|
29
|
+
if (used > 0 && size > used) return { used, size };
|
|
30
|
+
}
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// 官方使用统计(设置 → 数据与统计 → 使用统计 的同款服务):真累计/今日/任务数,
|
|
35
|
+
// 比本地 rollout 聚合完整。云端接口低频刷新(5 分钟一次),失败静默回退本地数据
|
|
36
|
+
let usageSvc = null;
|
|
37
|
+
let official = null; // { todayTokens, totalTokens, totalSessions }
|
|
38
|
+
let officialAt = 0;
|
|
39
|
+
// 从 React fiber 树遍历(newtab-panels.js 同款)
|
|
40
|
+
function eachFiber(visit) {
|
|
41
|
+
const rootEl = document.getElementById('root');
|
|
42
|
+
const key = rootEl && Object.keys(rootEl).find((k) => k.startsWith('__reactContainer$'));
|
|
43
|
+
if (!key) return;
|
|
44
|
+
let visited = 0;
|
|
45
|
+
const walk = (f) => {
|
|
46
|
+
if (!f || visited > 200000) return false;
|
|
47
|
+
visited++;
|
|
48
|
+
if (visit(f)) return true;
|
|
49
|
+
return walk(f.child) || walk(f.sibling);
|
|
50
|
+
};
|
|
51
|
+
walk(rootEl[key]);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function findUsageService() {
|
|
55
|
+
let found = null;
|
|
56
|
+
const seen = new Set();
|
|
57
|
+
const scan = (o) => {
|
|
58
|
+
if (!o || typeof o !== 'object' || seen.has(o) || found) return;
|
|
59
|
+
seen.add(o);
|
|
60
|
+
if (typeof o.usageStatsService === 'object' && o.usageStatsService) { found = o.usageStatsService; return; }
|
|
61
|
+
for (const v of Object.values(o)) if (v && typeof v === 'object') scan(v);
|
|
62
|
+
};
|
|
63
|
+
eachFiber((f) => {
|
|
64
|
+
for (const h of [f.memoizedProps, f.memoizedState]) {
|
|
65
|
+
if (!h || typeof h !== 'object') continue;
|
|
66
|
+
scan(h);
|
|
67
|
+
if (found) return true;
|
|
68
|
+
}
|
|
69
|
+
return false;
|
|
70
|
+
});
|
|
71
|
+
return found;
|
|
72
|
+
}
|
|
73
|
+
function refreshOfficial() {
|
|
74
|
+
// 成功后 5 分钟刷新一次;尚未成功(页面刚加载服务未就绪等)30 秒重试,
|
|
75
|
+
// 避免首次失败吃满冷却期
|
|
76
|
+
const period = official ? 5 * 60_000 : 30_000;
|
|
77
|
+
if (Date.now() - officialAt < period) return;
|
|
78
|
+
officialAt = Date.now();
|
|
79
|
+
void (async () => {
|
|
80
|
+
try {
|
|
81
|
+
if (!usageSvc) usageSvc = findUsageService();
|
|
82
|
+
if (!usageSvc || typeof usageSvc.getAppUsageSnapshot !== 'function') return;
|
|
83
|
+
const timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
|
84
|
+
const [all, d7] = await Promise.all([
|
|
85
|
+
usageSvc.getAppUsageSnapshot({ range: 'all', timeZone }),
|
|
86
|
+
usageSvc.getAppUsageSnapshot({ range: '7d', timeZone }),
|
|
87
|
+
]);
|
|
88
|
+
const now = new Date();
|
|
89
|
+
const key = now.getFullYear() + '-' + String(now.getMonth() + 1).padStart(2, '0') + '-' + String(now.getDate()).padStart(2, '0');
|
|
90
|
+
let todayTokens = 0;
|
|
91
|
+
const day = ((d7 && Array.isArray(d7.dailyModelUsage)) ? d7.dailyModelUsage : []).find((d) => d.date === key);
|
|
92
|
+
if (day) for (const m of day.models || []) todayTokens += Number(m.totalTokens) || 0;
|
|
93
|
+
official = {
|
|
94
|
+
todayTokens,
|
|
95
|
+
totalTokens: Number(all && all.summary && all.summary.totalTokens) || 0,
|
|
96
|
+
totalSessions: Number(all && all.summary && all.summary.totalSessions) || 0,
|
|
97
|
+
};
|
|
98
|
+
renderBar();
|
|
99
|
+
} catch {
|
|
100
|
+
usageSvc = null; // 服务失效(页面重载等),下个周期重挖
|
|
101
|
+
}
|
|
102
|
+
})();
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// 全局组统计项(会话组之外的都算会话级):两组之间渲染竖线分隔
|
|
106
|
+
const GLOBAL_GROUP = new Set(['todayTokens', 'totalTokens', 'tasks']);
|
|
107
|
+
|
|
108
|
+
// 各统计项的 lucide 图标(12px 内联,描边风格与官方一致)
|
|
109
|
+
const SB_ICONS = {
|
|
110
|
+
ctx: '<path d="M21.21 15.89A10 10 0 1 1 8 2.83"/><path d="M22 12A10 10 0 0 0 12 2v10z"/>',
|
|
111
|
+
asks: '<path d="M7.9 20A9 9 0 1 0 4 16.1L2 22Z"/>',
|
|
112
|
+
today: '<circle cx="12" cy="12" r="4"/><path d="M12 2v2"/><path d="M12 20v2"/><path d="m4.93 4.93 1.41 1.41"/><path d="m17.66 17.66 1.41 1.41"/><path d="M2 12h2"/><path d="M20 12h2"/><path d="m6.34 17.66-1.41 1.41"/><path d="m19.07 4.93-1.41 1.41"/>',
|
|
113
|
+
total: '<ellipse cx="12" cy="5" rx="9" ry="3"/><path d="M3 5V19A9 3 0 0 0 21 19V5"/><path d="M3 12A9 3 0 0 0 21 12"/>',
|
|
114
|
+
tasks: '<rect x="3" y="5" width="6" height="6" rx="1"/><path d="m3 17 2 2 4-4"/><path d="M13 6h8"/><path d="M13 12h8"/><path d="M13 18h8"/>',
|
|
115
|
+
coins: '<circle cx="8" cy="8" r="6"/><path d="M18.09 10.37A6 6 0 1 1 10.34 18"/><path d="M7 6h1v4"/><path d="m16.71 13.88.7.71-2.82 2.82"/>',
|
|
116
|
+
};
|
|
117
|
+
const sbIcon = (name) => `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="zcodepro-sb-icon">${SB_ICONS[name]}</svg>`;
|
|
118
|
+
|
|
119
|
+
function fmtTokens(n) {
|
|
120
|
+
if (n >= 1e9) return (n / 1e9).toFixed(1) + 'B';
|
|
121
|
+
if (n >= 1e6) return (n / 1e6).toFixed(1) + 'M';
|
|
122
|
+
if (n >= 1e3) return (n / 1e3).toFixed(1) + 'k';
|
|
123
|
+
return String(n);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// 数值格式与官方“上下文容量”显示一致:中文界面按数量级用“万/亿”
|
|
127
|
+
//(48.4万、900万、3.5亿),不足一万显示原数;其它语言沿用 k/M 缩写
|
|
128
|
+
function fmtNum(n) {
|
|
129
|
+
if (!isZhLocale()) return fmtTokens(n);
|
|
130
|
+
if (n < 10000) return String(n);
|
|
131
|
+
if (n >= 100000000) {
|
|
132
|
+
const yi = Math.round((n / 100000000) * 10) / 10;
|
|
133
|
+
return (yi % 1 === 0 ? String(yi) : yi.toFixed(1)) + '亿';
|
|
134
|
+
}
|
|
135
|
+
const wan = Math.round((n / 10000) * 10) / 10;
|
|
136
|
+
return (wan % 1 === 0 ? String(wan) : wan.toFixed(1)) + '万';
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// —— 渲染 ——
|
|
140
|
+
|
|
141
|
+
// 生成一项的 { html, title, tone };数据不可得返回 null(该项隐藏)。
|
|
142
|
+
// 每项 = 图标 + 短标签 + 数值(标签跟随界面语言),悬停 title 给全名与明细
|
|
143
|
+
function itemContent(id, L) {
|
|
144
|
+
const g = lastStats && lastStats.global;
|
|
145
|
+
const label = (text) => `<em>${text}</em>`;
|
|
146
|
+
switch (id) {
|
|
147
|
+
case 'ctx': {
|
|
148
|
+
const dom = readCtx();
|
|
149
|
+
if (dom) {
|
|
150
|
+
const pct = Math.round((dom.used / dom.size) * 1000) / 10;
|
|
151
|
+
const tone = pct >= 95 ? 'danger' : pct >= 80 ? 'warn' : '';
|
|
152
|
+
return {
|
|
153
|
+
html: sbIcon('ctx') + label(L.sbLabelCtx) + `<b>${fmtNum(dom.used)}/${fmtNum(dom.size)}${isZhLocale() ? '(' : '('}${pct}%${isZhLocale() ? ')' : ')'}</b>`,
|
|
154
|
+
title: `${L.sbItemCtx}:${dom.used.toLocaleString()} / ${dom.size.toLocaleString()}`,
|
|
155
|
+
tone,
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
return null; // 官方窗口数据不在(视图切换间隙等)时该项隐藏
|
|
159
|
+
}
|
|
160
|
+
case 'questions': {
|
|
161
|
+
// 官方“对话问题导航”的真实计数(当前会话的问题总数)
|
|
162
|
+
const nav = document.querySelector('[data-testid="v4-turn-navigator"]');
|
|
163
|
+
const n = nav ? Number(nav.getAttribute('data-item-count')) : 0;
|
|
164
|
+
return n > 0 ? { html: sbIcon('asks') + label(L.sbLabelQuestions) + String(n), title: L.sbItemQuestions } : null;
|
|
165
|
+
}
|
|
166
|
+
case 'todayTokens': {
|
|
167
|
+
const n = official ? official.todayTokens : (g ? g.todayTokens : 0);
|
|
168
|
+
return n > 0 ? { html: sbIcon('today') + label(L.sbLabelToday) + fmtNum(n), title: L.sbItemTodayTokens } : null;
|
|
169
|
+
}
|
|
170
|
+
case 'totalTokens': {
|
|
171
|
+
const n = official ? official.totalTokens : (g ? g.totalTokens : 0);
|
|
172
|
+
return n > 0 ? { html: sbIcon('total') + label(L.sbLabelTotal) + fmtNum(n), title: L.sbItemTotalTokens } : null;
|
|
173
|
+
}
|
|
174
|
+
case 'tasks': {
|
|
175
|
+
const n = official ? official.totalSessions : (g ? g.tasks : 0);
|
|
176
|
+
return n > 0 ? { html: sbIcon('tasks') + label(L.sbLabelTasks) + String(n), title: L.sbItemTasks } : null;
|
|
177
|
+
}
|
|
178
|
+
default:
|
|
179
|
+
return null;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function renderBar() {
|
|
184
|
+
// 3.16.1 的输入框是 contenteditable(旧版是 textarea),两种都认
|
|
185
|
+
const ta = document.querySelector('textarea, [contenteditable=true]');
|
|
186
|
+
const host = ta && (ta.closest('form') || ta.parentElement);
|
|
187
|
+
if (!enabled || !host || !host.parentElement) {
|
|
188
|
+
if (barEl) barEl.remove();
|
|
189
|
+
barEl = null;
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
if (!barEl || !barEl.isConnected) {
|
|
193
|
+
if (!barEl) {
|
|
194
|
+
barEl = document.createElement('div');
|
|
195
|
+
barEl.className = 'zcodepro-statusbar';
|
|
196
|
+
}
|
|
197
|
+
host.parentElement.insertBefore(barEl, host.nextSibling);
|
|
198
|
+
}
|
|
199
|
+
// 新建会话视图(无选中会话)下方是官方的功能按钮区,不能往下压:上下对称
|
|
200
|
+
// 留 7px;普通会话视图下方是窗口底的官方留白,压进去贴底(同为约 7px)。
|
|
201
|
+
// 按选中会话判定视图(按钮区与状态条不在同一父容器,兄弟判断不可用)
|
|
202
|
+
const hasSession = !![...document.querySelectorAll('[data-task-item-key]')]
|
|
203
|
+
.find((e) => (e.className + '').includes('bg-selected'));
|
|
204
|
+
barEl.style.marginBottom = hasSession ? '-14px' : '0';
|
|
205
|
+
barEl.style.paddingBottom = hasSession ? '0' : '7px';
|
|
206
|
+
const L = t();
|
|
207
|
+
const parts = [];
|
|
208
|
+
let lastGroup = '';
|
|
209
|
+
for (const it of items) {
|
|
210
|
+
if (!it.on) continue;
|
|
211
|
+
const c = itemContent(it.id, L);
|
|
212
|
+
if (!c) continue;
|
|
213
|
+
const group = GLOBAL_GROUP.has(it.id) ? 'g' : 's';
|
|
214
|
+
// 会话项与全局项之间用竖线分隔(比项间的 · 更强的分界)
|
|
215
|
+
if (lastGroup && group !== lastGroup) parts.push('<span class="zcodepro-statusbar-groupsep"></span>');
|
|
216
|
+
lastGroup = group;
|
|
217
|
+
parts.push(`<span title="${c.title.replace(/"/g, '"')}"${c.tone ? ` data-tone="${c.tone}"` : ''}>${c.html}</span>`);
|
|
218
|
+
}
|
|
219
|
+
// 组内项之间用 ·,跨组竖线两侧不再叠加 ·
|
|
220
|
+
const SEP = '<span class="zcodepro-statusbar-sep">·</span>';
|
|
221
|
+
const GROUPSEP = '<span class="zcodepro-statusbar-groupsep"></span>';
|
|
222
|
+
barEl.innerHTML = parts.reduce((acc, p, i) => {
|
|
223
|
+
if (i === 0) return p;
|
|
224
|
+
if (p === GROUPSEP || parts[i - 1] === GROUPSEP) return acc + p;
|
|
225
|
+
return acc + SEP + p;
|
|
226
|
+
}, '');
|
|
227
|
+
barEl.style.display = parts.length ? '' : 'none';
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
async function tick() {
|
|
231
|
+
if (!enabled || document.hidden) return;
|
|
232
|
+
refreshOfficial();
|
|
233
|
+
const res = await rpc('/stats/usage').catch(() => null);
|
|
234
|
+
if (res && res.ok) lastStats = res;
|
|
235
|
+
renderBar();
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
export function startStatusBar() {
|
|
239
|
+
ensureStyle();
|
|
240
|
+
const refreshCfg = () => {
|
|
241
|
+
void getConfig().then((cfg) => {
|
|
242
|
+
enabled = cfg.features ? cfg.features.statusBar !== false : true;
|
|
243
|
+
const saved = cfg.statusBar && Array.isArray(cfg.statusBar.items) ? cfg.statusBar.items : null;
|
|
244
|
+
if (saved) {
|
|
245
|
+
// 保存的顺序为准(会话组在前已由设置保存时归一化;这里再归一化一次
|
|
246
|
+
// 防御旧版本保存的交错顺序);新版本新增的默认项按默认组位补齐
|
|
247
|
+
const byId = new Map(saved.map((x) => [x.id, x]));
|
|
248
|
+
const list = saved.filter((x) => DEFAULT_STATUS_ITEMS.some((d) => d.id === x.id)).map((x) => ({ ...x }));
|
|
249
|
+
const known = new Set(list.map((x) => x.id));
|
|
250
|
+
for (const d of DEFAULT_STATUS_ITEMS) {
|
|
251
|
+
if (!known.has(d.id)) list.push({ ...d });
|
|
252
|
+
}
|
|
253
|
+
items = [
|
|
254
|
+
...list.filter((x) => !GLOBAL_GROUP.has(x.id)),
|
|
255
|
+
...list.filter((x) => GLOBAL_GROUP.has(x.id)),
|
|
256
|
+
];
|
|
257
|
+
} else {
|
|
258
|
+
items = DEFAULT_STATUS_ITEMS.map((x) => ({ ...x }));
|
|
259
|
+
}
|
|
260
|
+
renderBar();
|
|
261
|
+
}).catch(() => {});
|
|
262
|
+
};
|
|
263
|
+
refreshCfg();
|
|
264
|
+
window.addEventListener('zcodepro:config-changed', refreshCfg);
|
|
265
|
+
document.addEventListener('visibilitychange', () => { if (!document.hidden) void tick(); });
|
|
266
|
+
setInterval(() => { void tick(); }, 2000);
|
|
267
|
+
// 视图切换(进新建会话、换会话等)时 DOM 变动立即重渲染(去抖 300ms):
|
|
268
|
+
// 否则状态条要等下一个 2 秒周期,期间残留上一个视图的统计项与间距
|
|
269
|
+
let rerenderTimer = 0;
|
|
270
|
+
new MutationObserver(() => {
|
|
271
|
+
if (rerenderTimer) return;
|
|
272
|
+
rerenderTimer = setTimeout(() => { rerenderTimer = 0; renderBar(); }, 300);
|
|
273
|
+
}).observe(document.body, { childList: true, subtree: true });
|
|
274
|
+
}
|