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.
@@ -0,0 +1,195 @@
1
+ // 每轮回答的 token 用量:在回答末尾操作按钮(复制/赞/踩/分叉/钩子)行的时间
2
+ // 后面追加“竖线 + 输入/输出/缓存”标注(段顺序与显隐可在设置里调)。
3
+ // 数据来自本地模型调用记录(helper 按本地分钟分桶),按“本条提问到下一条
4
+ // 提问”的区间归属到回答行;本地记录只保留最近一段,覆盖不到的旧回答不标注。
5
+ import { t, rpc, getConfig, isZhLocale } from '../core.js';
6
+
7
+ let enabled = true;
8
+ let recentTurns = []; // [{hm, in, out, cacheRead, ts}] 按时间倒序
9
+ let scanTimer = 0;
10
+ // 三段(输入/输出/缓存)的显隐与顺序:设置 → 样式 → 状态栏 → 每轮统计
11
+ let turnCfg = [
12
+ { id: 'turnIn', on: true },
13
+ { id: 'turnOut', on: true },
14
+ { id: 'turnCache', on: true },
15
+ ];
16
+ const refreshTurnItems = () => {
17
+ void getConfig().then((cfg) => {
18
+ const saved = cfg.statusBar && Array.isArray(cfg.statusBar.turnItems) ? cfg.statusBar.turnItems : null;
19
+ if (!saved) return;
20
+ const known = new Set(turnCfg.map((x) => x.id));
21
+ const list = saved.filter((x) => x && known.has(x.id)).map((x) => ({ id: x.id, on: x.on !== false }));
22
+ for (const d of turnCfg) if (!list.some((x) => x.id === d.id)) list.push({ ...d });
23
+ turnCfg = list;
24
+ }).catch(() => {});
25
+ };
26
+
27
+ // 与状态栏同款的小图标(上/下箭头、层叠缓存)
28
+ const ICONS = {
29
+ up: '<path d="m5 12 7-7 7 7"/><path d="M12 19V5"/>',
30
+ down: '<path d="M12 5v14"/><path d="m19 12-7 7-7-7"/>',
31
+ cache: '<path d="m12.83 2.18a2 2 0 0 0-1.66 0L2.6 6.08a1 1 0 0 0 0 1.83l8.58 3.91a2 2 0 0 0 1.66 0l8.58-3.9a1 1 0 0 0 0-1.83Z"/><path d="m22 17.65-9.17 4.16a2 2 0 0 1-1.66 0L2 17.65"/><path d="m22 12.65-9.17 4.16a2 2 0 0 1-1.66 0L2 12.65"/>',
32
+ };
33
+ const icon = (name) => `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="width:11px;height:11px;vertical-align:-1.5px;opacity:.6">${ICONS[name]}</svg>`;
34
+
35
+ // 与状态栏同款:中文用 万/亿,其它语言 k/M 缩写
36
+ function fmt(n) {
37
+ if (!isZhLocale()) {
38
+ if (n >= 1e9) return (n / 1e9).toFixed(1) + 'B';
39
+ if (n >= 1e6) return (n / 1e6).toFixed(1) + 'M';
40
+ if (n >= 1e3) return (n / 1e3).toFixed(1) + 'k';
41
+ return String(n);
42
+ }
43
+ if (n >= 1e8) return (n / 1e8).toFixed(1) + '亿';
44
+ if (n >= 1e4) {
45
+ const wan = Math.round((n / 1e4) * 10) / 10;
46
+ return (wan % 1 === 0 ? String(wan) : wan.toFixed(1)) + '万';
47
+ }
48
+ return String(n);
49
+ }
50
+
51
+ // 回答操作行:按结构特征识别——回答行有赞/踩按钮(v4-feedback-),用户消息行
52
+ // 没有(只有复制/编辑),比时间文本稳(官方改时间格式不影响识别)。
53
+ // 行内时间文本只用于解析该轮的提问时刻
54
+ function findRows() {
55
+ const rows = [];
56
+ for (const btn of document.querySelectorAll('[data-testid^="v4-copy-"]')) {
57
+ const row = btn.parentElement;
58
+ if (!row || row.dataset.zcodeproTurn) continue;
59
+ if (!row.querySelector('[data-testid^="v4-feedback-"]')) continue;
60
+ const timeEl = [...row.querySelectorAll('span')].find((s) => /\d{1,2}:\d{2}$/.test((s.textContent || '').trim()));
61
+ if (timeEl) { const ts = rowTimeTs((timeEl.textContent || '').trim()); if (ts != null) rows.push({ row, ts }); }
62
+ }
63
+ return rows;
64
+ }
65
+
66
+ // 当前会话 id:优先选中行,回退侧栏最新一行(与状态栏同口径)
67
+ function currentSessionId() {
68
+ const rows = [...document.querySelectorAll('[data-task-item-key]')];
69
+ const cur = rows.find((e) => (e.className + '').includes('bg-selected')) || rows[rows.length - 1];
70
+ const key = cur ? cur.getAttribute('data-task-item-key') : '';
71
+ const i = key.lastIndexOf(':sess');
72
+ return i > 0 ? key.slice(i + 1) : '';
73
+ }
74
+
75
+ function injectRow(row, bucket) {
76
+ row.dataset.zcodeproTurn = '1';
77
+ if (!bucket || bucket.in + bucket.out <= 0) return;
78
+ const L = t();
79
+ const el = document.createElement('span');
80
+ el.dataset.zcodeproTurnSpan = '1';
81
+ el.className = 'select-none text-ui-sm text-foreground-subtlest';
82
+ // 按钮行是 flex 容器:本元素作为子项会被压缩导致内容折行,锁成单行
83
+ el.style.whiteSpace = 'nowrap';
84
+ el.style.display = 'inline-flex';
85
+ el.style.alignItems = 'center';
86
+ el.style.gap = '4px'; // 组与 · 分隔之间的间距
87
+ el.style.marginLeft = '10px'; // 与左侧时间拉开
88
+ // 与状态栏同款:图标 + 文字 + 数据;缓存有量时给命中率
89
+ // 保留两位小数:长上下文会话命中率普遍 99%+,取整会全部显示 100% 失真
90
+ const cachePct = bucket.in > 0 && bucket.cacheRead > 0
91
+ ? (bucket.cacheRead / bucket.in * 100).toFixed(2) : '';
92
+ const group = (html) => `<span style="display:inline-flex;align-items:center;gap:2px">${html}</span>`;
93
+ const em = (text) => `<em style="font-style:normal;opacity:.75">${text}</em>`;
94
+ const DOT = '<span style="opacity:.45">·</span>'; // 与状态栏同款分隔
95
+ // 段按设置保存的顺序渲染,段间用 ·;首段左侧竖线与底部状态栏的组分隔同款;
96
+ // 全部隐藏时不注入(连竖线一起没有)
97
+ const sections = [];
98
+ for (const s of turnCfg) {
99
+ if (!s.on) continue;
100
+ if (s.id === 'turnIn') sections.push(group(icon('up') + em(L.sbLabelIn) + fmt(bucket.in)));
101
+ else if (s.id === 'turnOut') sections.push(group(icon('down') + em(L.sbLabelOut) + fmt(bucket.out)));
102
+ else if (s.id === 'turnCache' && cachePct) sections.push(group(icon('cache') + em(L.sbLabelCache) + cachePct + '%'));
103
+ }
104
+ if (!sections.length) { delete row.dataset.zcodeproTurn; el.remove(); return; }
105
+ const VBAR = '<span style="display:inline-block;width:1px;height:10px;background:currentColor;opacity:.3;margin:0 5px"></span>';
106
+ el.innerHTML = VBAR + sections.join(DOT);
107
+ el.title = `${L.turnUsageTitle}:${L.sbLabelIn} ${bucket.in.toLocaleString()} / ${L.sbLabelOut} ${bucket.out.toLocaleString()}`
108
+ + (bucket.cacheRead > 0 ? ` / ${L.sbLabelCache} ${bucket.cacheRead.toLocaleString()}` : '');
109
+ row.append(el); // 追加在按钮行末尾(时间之后)
110
+ }
111
+
112
+ // 回答行时间文本 → 当年本地时间的时间戳(毫秒)。
113
+ // 官方三种形态:今天“18:16”、昨天“昨天 18:16”、更早“10/21 17:41”。
114
+ // 行时间是提问时刻,调用桶是完成时刻——一轮的用量按“本条提问到下一条
115
+ // 提问之间”的区间归属(见 scan),因此这里只取可比时间戳
116
+ function rowTimeTs(text) {
117
+ const now = new Date();
118
+ let m = text.match(/^(\d{1,2}):(\d{2})$/);
119
+ if (m) return new Date(now.getFullYear(), now.getMonth(), now.getDate(), +m[1], +m[2]).getTime();
120
+ m = text.match(/^昨天\s*(\d{1,2}):(\d{2})$/);
121
+ if (m) { const y = new Date(now.getFullYear(), now.getMonth(), now.getDate() - 1); return new Date(y.getFullYear(), y.getMonth(), y.getDate(), +m[1], +m[2]).getTime(); }
122
+ m = text.match(/^(\d{1,2})\/(\d{1,2})\s*(\d{1,2}):(\d{2})$/);
123
+ if (m) return new Date(now.getFullYear(), +m[1] - 1, +m[2], +m[3], +m[4]).getTime();
124
+ return null;
125
+ }
126
+
127
+ let lastSid = '';
128
+ function scan() {
129
+ if (!enabled) return;
130
+ // 会话切换瞬间:旧会话的桶还留在 recentTurns 里,若继续归属会把别的会话
131
+ // 的调用安到新视图的旧回答上——先清掉并立即重拉(拉到前只清不注)
132
+ const sid = currentSessionId();
133
+ if (sid !== lastSid) {
134
+ lastSid = sid;
135
+ recentTurns = [];
136
+ void tick();
137
+ }
138
+ // 每轮重算:先移除上一次注入的标注并清行标记(进行中的轮数值会增长);
139
+ // 按标题前缀一并清掉旧版本格式的标注
140
+ for (const el of document.querySelectorAll('[data-zcodepro-turn-span], span[title^="本轮"], span[title^="Turn"]')) el.remove();
141
+ for (const row of document.querySelectorAll('[data-zcodepro-turn]')) delete row.dataset.zcodeproTurn;
142
+ if (document.hidden || !recentTurns.length) return;
143
+ const rows = findRows().sort((a, b) => a.ts - b.ts); // 可见行按时间升序
144
+ if (!rows.length) return;
145
+ // 区间归属:每条回答拥有“自己的提问时刻 ≤ 桶完成时刻 < 下一条提问时刻”
146
+ // 的全部调用(一轮可能跨多个分钟);最后一条收到其后所有桶(进行中的轮)。
147
+ // 行集是虚拟列表的可见窗口,同一条回答的邻居行在场时归属稳定
148
+ const agg = rows.map(() => ({ in: 0, out: 0, cacheRead: 0 }));
149
+ const dayOf = (t) => { const d = new Date(t); return d.getFullYear() * 10000 + (d.getMonth() + 1) * 100 + d.getDate(); };
150
+ for (const b of recentTurns) {
151
+ let owner = -1;
152
+ for (let i = 0; i < rows.length; i++) {
153
+ if (rows[i].ts <= b.ts) owner = i;
154
+ else break;
155
+ }
156
+ if (owner < 0) continue; // 比窗口里最早的行还早:不属于可见任何一条
157
+ // 同日约束:跨天的桶不归属(昨天的旧行不吞今天的调用——今天的提问
158
+ // 行可能只是不在当前虚拟列表窗口里)
159
+ if (dayOf(rows[owner].ts) !== dayOf(b.ts)) continue;
160
+ agg[owner].in += b.in;
161
+ agg[owner].out += b.out;
162
+ agg[owner].cacheRead += b.cacheRead;
163
+ }
164
+ for (let i = 0; i < rows.length; i++) {
165
+ if (agg[i].in + agg[i].out > 0) injectRow(rows[i].row, agg[i]);
166
+ }
167
+ }
168
+
169
+ async function tick() {
170
+ if (!enabled || document.hidden) return;
171
+ const sid = currentSessionId();
172
+ const res = await rpc('/stats/usage' + (sid ? '?session=' + encodeURIComponent(sid) : '')).catch(() => null);
173
+ if (res && res.ok && Array.isArray(res.recentTurns)) {
174
+ // 同分钟桶随时间增长,全量替换
175
+ recentTurns = res.recentTurns;
176
+ scan();
177
+ }
178
+ }
179
+
180
+ export function startTurnUsage() {
181
+ void getConfig().then((cfg) => { enabled = cfg.features ? cfg.features.turnUsage !== false : true; }).catch(() => {});
182
+ refreshTurnItems();
183
+ window.addEventListener('zcodepro:config-changed', () => {
184
+ void getConfig().then((cfg) => { enabled = cfg.features ? cfg.features.turnUsage !== false : true; }).catch(() => {});
185
+ refreshTurnItems();
186
+ });
187
+ // 回答行出现即注入(去抖);数据 5 秒刷新一次
188
+ const observer = new MutationObserver(() => {
189
+ if (scanTimer) return;
190
+ scanTimer = setTimeout(() => { scanTimer = 0; scan(); }, 1200); // 滚动/复用渲染稳定后再注
191
+ });
192
+ observer.observe(document.body, { childList: true, subtree: true });
193
+ setInterval(() => { void tick(); }, 5000);
194
+ document.addEventListener('visibilitychange', () => { if (!document.hidden) void tick(); });
195
+ }
@@ -10,10 +10,15 @@ import { startAliasWatcher } from './features/alias.js';
10
10
  import { startAliasMigrationWatcher } from './features/alias-migration.js';
11
11
  import { startTaskOrderWatcher } from './features/task-order.js';
12
12
  import { startSessionSwitch } from './features/session-switch.js';
13
+ import { startNewtabPanels } from './features/newtab-panels.js';
14
+ import { startStatusBar } from './features/status-bar.js';
15
+ import { startTurnUsage } from './features/turn-usage.js';
13
16
  import { startTitleLock } from './features/title-lock.js';
14
17
  import { startTitleGen } from './features/title-gen.js';
15
18
  import { startWsRunningSpin } from './features/ws-running.js';
16
19
  import { startIdleReclaim } from './features/idle-reclaim.js';
20
+ import { startSandboxReclaim } from './features/sandbox-reclaim.js';
21
+ import { startPluginCardRetry } from './features/plugin-card-retry.js';
17
22
  import { startToolbarIcons } from './features/toolbar-icons.js';
18
23
  import { startPinnedCollapse } from './features/pinned-collapse.js';
19
24
  import { startStyleAdjustments } from './features/styles.js';
@@ -57,7 +62,12 @@ import { ensureStyle } from './ui.js';
57
62
  try { startImageMenu(); } catch { /* ignore */ }
58
63
  try { startWsRunningSpin(); } catch { /* ignore */ }
59
64
  try { startIdleReclaim(); } catch { /* ignore */ }
65
+ try { startSandboxReclaim(); } catch { /* ignore */ }
66
+ try { startPluginCardRetry(); } catch { /* ignore */ }
60
67
  try { startToolbarIcons(); } catch { /* ignore */ }
68
+ try { startNewtabPanels(); } catch { /* ignore */ }
69
+ try { startStatusBar(); } catch { /* ignore */ }
70
+ try { startTurnUsage(); } catch { /* ignore */ }
61
71
  try { startPinnedCollapse(); } catch { /* ignore */ }
62
72
  try { startStyleAdjustments(); } catch { /* ignore */ }
63
73
  try { startSettingsEntry(); } catch { /* ignore */ }
package/src/inject/ui.js CHANGED
@@ -630,6 +630,56 @@ export function ensureStyle() {
630
630
  text-align: center;
631
631
  color: var(--color-muted-foreground, #888);
632
632
  }
633
+ /* 状态栏统计(status-bar.js):输入框下方右对齐的一行小字,每项带 lucide 小图标。
634
+ 上下间距由脚本按视图控制(status-bar.js:下方有内容时对称 7px,
635
+ 下方是窗底留白时负 margin 压入贴底) */
636
+ .zcodepro-statusbar {
637
+ display: flex;
638
+ justify-content: flex-end;
639
+ align-items: center;
640
+ gap: 8px;
641
+ padding: 7px 14px 0;
642
+ font-size: 11px;
643
+ line-height: 1.4;
644
+ color: var(--color-muted-foreground, #888);
645
+ user-select: none;
646
+ pointer-events: none;
647
+ }
648
+ .zcodepro-statusbar span[data-tone='warn'] { color: var(--color-amber-500, #f59e0b); }
649
+ .zcodepro-statusbar span[data-tone='danger'] { color: var(--color-red-500, #ef4444); }
650
+ .zcodepro-statusbar-sep { opacity: 0.45; }
651
+ /* 会话组与全局组之间的分界竖线 */
652
+ .zcodepro-statusbar-groupsep {
653
+ width: 1px;
654
+ height: 10px;
655
+ background: currentColor;
656
+ opacity: 0.3;
657
+ margin: 0 2px;
658
+ }
659
+ /* 每项内部用 flex 对齐:图标、标签、数值垂直居中,间距统一 */
660
+ .zcodepro-statusbar span[title] {
661
+ display: inline-flex;
662
+ align-items: center;
663
+ gap: 3px;
664
+ white-space: nowrap;
665
+ }
666
+ .zcodepro-sb-icon {
667
+ width: 12px;
668
+ height: 12px;
669
+ flex-shrink: 0;
670
+ opacity: 0.6;
671
+ }
672
+ .zcodepro-statusbar b { font-weight: 600; color: var(--color-foreground-subtle, #666); }
673
+ .zcodepro-statusbar i {
674
+ font-style: normal;
675
+ opacity: 0.7;
676
+ font-size: 10px;
677
+ }
678
+ /* 项内短标签(如“上下文”“累计”),弱化不抢数值 */
679
+ .zcodepro-statusbar em {
680
+ font-style: normal;
681
+ opacity: 0.75;
682
+ }
633
683
  .zcodepro-switcher-ws {
634
684
  font-size: 11px;
635
685
  color: var(--color-muted-foreground, #888);