提示缓存保温:输入框上方显示闲置计时与一键保温,/keepwarm N 接续保温 N 小时(上限 24),图表面板给出上下文、缓存剩余、额度与费用构成

中文 | English
Claude Code 的提示缓存闲置约 1 小时后过期,下一次请求要把整段上下文重新写入缓存,长会话一次就可能多花几美元(或多耗一截订阅额度)。这个插件在输入框上方显示缓存闲置时间,提供一键保温和定时接续保温,并用图表面板展示上下文、缓存、额度与费用构成。
| 缓存写入(近 24 小时累计) | 费用构成(近 24 小时累计) | 上下文长度(近 24 小时,按小时) |
|---|---|---|
![]() | ![]() | ![]() |
/keepwarm:立即发一次保温请求(一句极短的固定提示,让缓存续期)。/keepwarm 8:接续保温 8 小时,闲置满 50 分钟自动发一次;你一发言,时限从那一刻重新起算。上限 24 小时。/keepwarm off:停止接续保温。/keepwarm chart:打开图表面板。在终端执行下面两条命令,然后新开一个 Claude Code 会话:
claude plugin marketplace add https://github.com/Ancean/claude-keepwarm.git
claude plugin install keepwarm@ancean-plugins
在会话里也可以用 /plugin marketplace add Ancean/claude-keepwarm 和 /plugin install keepwarm@ancean-plugins;若提示读不到仓库(简写走 SSH),改用上面的 HTTPS 地址。更新:claude plugin marketplace update ancean-plugins,再 claude plugin update keepwarm@ancean-plugins。
~/.claude/skills/keepwarm/(Windows 为 C:\Users\<用户名>\.claude\skills\keepwarm\): git clone https://github.com/Ancean/claude-keepwarm.git ~/.claude/skills/keepwarm
确认路径是 ~/.claude/skills/keepwarm/.claude-plugin/plugin.json,不要多套一层文件夹。
keepwarm@skills-dir 自动加载。git pull,然后新开会话。两种方式只能选一种,否则会出现重名,后装的那份不加载。
hooks.json 中的 modules)的 Claude Code 版本。python(3.8 及以上,只用标准库)。没有 Python 时保温功能照常可用,图表面板会提示读取失败。~/.claude/projects/)中的用量数字、时间戳、模型名和压缩元数据,不读取对话内容,也不联网上传任何数据。~/.claude/keepwarm-cache/rows.json,删除后会自动重建。priceOf 中),不是账单;订阅用户可把金额理解为额度消耗的相对大小。hooks/register.tsx 1373 lines1import type { Register } from 'claude-code'
2
3// ---------- 参数 ----------
4// 缓存有效期 1 小时。闲置满 FIRE_MIN 分钟就补发一次保温,留出余量防止卡在 60 分钟边缘。
5const SHOW_MIN = 0
6const WARN_MIN = 45
7const FIRE_MIN = 50
8const MAX_HOURS = 24 // 硬上限:Claude Code 每日清理缓存的时刻未核实,任何设置都不得超过 24 小时
9const COMPACT_AT = 295000 // 读不到引擎阈值时的后备值:窗口 327680 时实测的自动压缩触发长度(中位约 299K)
10const PROMPT = '【保温】这是缓存保温请求,不要调用任何工具,不要继续之前的任务,只回复两个字:保温。'
11const PANE = 'keepwarm-chart'
12
13// 橙色主色调
14const C_MAIN = '#EA7A12'
15const C_DARK = '#9A3412'
16const C_LIGHT = '#FDBA74'
17const C_GOLD = '#D97706'
18const C_RED = '#DC2626'
19const C_TEXT = '#8a8a8a'
20const C_GRID = 'rgba(128,128,128,0.25)'
21
22// 只读本会话记录里的 usage、时间戳、模型和压缩元数据,不读对话内容
23const PY = [
24 'import sys,json,glob,os',
25 "fs=glob.glob(os.path.join(os.path.expanduser('~'),'.claude','projects','*',sys.argv[1]+'.jsonl'))",
26 'rows={};comp=[]',
27 'for f in fs:',
28 " for line in open(f,encoding='utf-8',errors='replace'):",
29 " if '\"usage\"' not in line and 'compact_boundary' not in line: continue",
30 ' try: d=json.loads(line)',
31 ' except Exception: continue',
32 " if d.get('type')=='assistant':",
33 " m=d.get('message') or {};u=m.get('usage')",
34 " if not u or not m.get('id') or m.get('model')=='<synthetic>': continue",
35 " rows.setdefault(m['id'],{'t':d.get('timestamp'),'m':m.get('model'),'i':u.get('input_tokens',0),'cw':u.get('cache_creation_input_tokens',0),'cr':u.get('cache_read_input_tokens',0),'o':u.get('output_tokens',0)})",
36 " elif d.get('subtype')=='compact_boundary':",
37 " cm=d.get('compactMetadata') or {}",
38 " comp.append({'t':d.get('timestamp'),'trig':cm.get('trigger'),'pre':cm.get('preTokens'),'post':cm.get('postTokens')})",
39 "print(json.dumps({'r':list(rows.values()),'c':comp}))",
40].join('\n')
41
42// 长期汇总:扫描全部项目的会话记录,同样只取 usage、时间戳、模型和压缩元数据;逐文件缓存到 ~/.claude/keepwarm-cache
43// 输入(标准输入):{ edges: 分格边界毫秒数组, kw: 保温时刻 };输出每格的请求数、费用拆分、缓存写入拆分、上下文与压缩次数
44const AGG_PY = String.raw`import sys,json,glob,os,bisect
45from datetime import datetime
46P=json.loads(sys.stdin.read())
47E=P['edges'];KW=sorted(P.get('kw',[]));T0=E[0];T1=E[-1]
48H=os.path.expanduser('~');R=os.path.join(H,'.claude','projects')
49CD=os.path.join(H,'.claude','keepwarm-cache');CF=os.path.join(CD,'rows.json')
50try: C=json.load(open(CF,encoding='utf-8'))
51except Exception: C={}
52def ms(s):
53 try: return int(datetime.fromisoformat(s.replace('Z','+00:00')).timestamp()*1000)
54 except Exception: return None
55def parse(f):
56 rows={};comp=[]
57 for line in open(f,encoding='utf-8',errors='replace'):
58 if '"usage"' not in line and 'compact_boundary' not in line: continue
59 try: d=json.loads(line)
60 except Exception: continue
61 if d.get('type')=='assistant':
62 m=d.get('message') or {};u=m.get('usage')
63 if not u or not m.get('id') or m.get('model')=='<synthetic>': continue
64 t=ms(d.get('timestamp') or '')
65 if t is None: continue
66 rows.setdefault(m['id'],[t,m.get('model') or '',u.get('input_tokens',0) or 0,u.get('cache_creation_input_tokens',0) or 0,u.get('cache_read_input_tokens',0) or 0,u.get('output_tokens',0) or 0])
67 elif d.get('subtype')=='compact_boundary':
68 cm=d.get('compactMetadata') or {};t=ms(d.get('timestamp') or '')
69 if t is not None: comp.append([t,cm.get('trigger'),cm.get('preTokens') or 0])
70 return [[k]+v for k,v in rows.items()],comp
71fs=glob.glob(os.path.join(R,'*','*.jsonl'))+glob.glob(os.path.join(R,'*','*','subagents','*.jsonl'))
72st=[(f,os.stat(f)) for f in fs]
73st=[x for x in st if x[1].st_mtime*1000>=T0-86400000]
74st.sort(key=lambda x:x[1].st_mtime)
75NC={};seen=set();seqs=[]
76for f,s in st:
77 key=f'{s.st_mtime_ns}:{s.st_size}'
78 c=C.get(f)
79 if not c or c['k']!=key:
80 r,cp=parse(f);c={'k':key,'r':r,'c':cp}
81 NC[f]=c
82 rs=[]
83 for x in c['r']:
84 if x[0] in seen: continue
85 seen.add(x[0]);rs.append(x[1:])
86 rs.sort(key=lambda x:x[0])
87 seqs.append((rs,[x for x in c['c']],'subagents' in f))
88try:
89 os.makedirs(CD,exist_ok=True);json.dump(NC,open(CF+'.tmp','w',encoding='utf-8'));os.replace(CF+'.tmp',CF)
90except Exception: pass
91def price(m):
92 m=m.lower()
93 if 'sonnet' in m: return 2,10
94 if 'haiku' in m: return 1,5
95 return 4,20
96nb=len(E)-1
97B=[{'n':0,'base':0,'rebuild':0,'kw':0,'cold':0,'avoided':0,'coldN':0,'kwN':0,'i':0,'cw':0,'cr':0,'o':0,'ctx':[],'cwHit':0,'cwIdle':0,'cwRe':0,'cwKw':0,'compA':0,'compM':0,'pre':0,'ses':set()} for _ in range(nb)]
98def bin_of(t):
99 if t<T0 or t>=T1: return -1
100 return bisect.bisect_right(E,t)-1
101def is_kw(t):
102 j=bisect.bisect_left(KW,t-180000)
103 return j<len(KW) and KW[j]<=t+2000
104for si,(rs,comps,sub) in enumerate(seqs):
105 ct=sorted(c[0] for c in comps)
106 for c in comps:
107 b=bin_of(c[0])
108 if b>=0:
109 if c[1]=='auto': B[b]['compA']+=1
110 else: B[b]['compM']+=1
111 B[b]['pre']=max(B[b]['pre'],c[2])
112 prev=0;lastReal=None;chain=0;chainInp=4
113 for t,m,i,cw,cr,o in rs:
114 ctx=i+cw+cr
115 if ctx<=0: prev=t;continue
116 inp,out=price(m)
117 cost=(i*inp+cr*0.05*inp+cw*2*inp+o*out)/1e6
118 gap=(t-prev)/60000 if prev else 0
119 miss=cw>0.5*ctx and ctx>30000
120 extra=cw*1.95*inp/1e6 if miss else 0
121 j=bisect.bisect_right(ct,prev);after=j<len(ct) and ct[j]<=t
122 if is_kw(t): kind='kw'
123 elif not miss: kind='hit'
124 elif after: kind='compact'
125 elif gap>=60 and not sub: kind='idle'
126 else: kind='other'
127 b=bin_of(t);q=B[b] if b>=0 else None
128 if kind=='kw':
129 if q: q['kw']+=cost;q['kwN']+=1;q['cwKw']+=cw
130 chain=ctx;chainInp=inp
131 else:
132 if chain>0 and lastReal is not None and t-lastReal>=3600000 and gap<60 and q: q['avoided']+=chain*1.95*chainInp/1e6
133 chain=0;lastReal=t
134 if q:
135 if kind=='idle': q['cold']+=extra;q['coldN']+=1;q['cwIdle']+=cw
136 elif kind=='hit': q['cwHit']+=cw
137 else: q['rebuild']+=extra;q['cwRe']+=cw
138 q['base']+=cost-extra
139 if q:
140 q['n']+=1;q['i']+=i;q['cw']+=cw;q['cr']+=cr;q['o']+=o;q['ses'].add(si)
141 if not sub: q['ctx'].append(ctx)
142 prev=t
143out=[]
144for q in B:
145 c=sorted(q.pop('ctx'));q['ses']=len(q['ses'])
146 q['ctxMax']=c[-1] if c else 0;q['ctxMed']=c[len(c)//2] if c else 0
147 for k in ('base','rebuild','kw','cold','avoided'): q[k]=round(q[k],4)
148 out.append(q)
149print(json.dumps({'b':out,'files':len(st)}))`
150
151// ---------- 模块状态 ----------
152let lastAt: number | null = null // 最近一次任何请求(含保温)
153let lastReal: number | null = null // 最近一次你本人的请求,保温时长从这里起算
154let hours = 0 // 接续保温的小时数,0 为关闭
155let busy = false
156let sent = 0
157let kwTimes: number[] = [] // 本次加载以来发出的保温请求时刻
158let view = 'line' // line | cost | ctx
159let data: any = null // { reqs, comps }
160let loadError = ''
161let compactAt = COMPACT_AT // 打开图表时从引擎读取本会话的自动压缩阈值
162let paneOpen = false
163let span = 'ses' // ses 本会话 | hour | day | week | month
164let agg: any = null // { b: 每格汇总, edges, unit }
165let aggAt = 0
166let aggBusy = false
167let aggError = ''
168let cumBy: Record<string, boolean> = { hour: true, d7: true } // 每档默认:短范围看累计,长范围看每段
169
170// ---------- 纯函数 ----------
171const p2 = (n: number) => String(n).padStart(2, '0')
172const hm = (t: number) => {
173 const d = new Date(t)
174 return `${p2(d.getHours())}:${p2(d.getMinutes())}`
175}
176const md = (t: number) => {
177 const d = new Date(t)
178 return `${p2(d.getMonth() + 1)}/${p2(d.getDate())}`
179}
180const kfmt = (n: number) => (n === 0 ? '0' : `${Math.round(n / 1000)}K`)
181const usd = (v: number) => `${v < 0 ? '−' : ''}$${Math.abs(v) < 10 ? Math.abs(v).toFixed(2) : Math.abs(v).toFixed(1)}`
182const dur = (min: number) => (min >= 2880 ? `${Math.round(min / 1440)} 天` : min >= 120 ? `${Math.round(min / 60)} 小时` : `${Math.round(min)} 分`)
183const HOUR = 3600000
184const KIND_NAME: Record<string, string> = { hit: '命中', kw: '保温', idle: '冷读取', compact: '压缩后重建', other: '重写' }
185
186function priceOf(model: string) {
187 const m = (model || '').toLowerCase()
188 if (m.includes('sonnet')) return { inp: 2, out: 10 }
189 if (m.includes('haiku')) return { inp: 1, out: 5 }
190 return { inp: 4, out: 20 } // opus 及未识别:按 Opus 5.5 列表价
191}
192
193// 每次请求分类:hit 命中;kw 保温;idle 闲置超过 1 小时后的冷读取(保温可避免);
194// compact 压缩后重建、other 其他整段重写(如换模型),这两类保温无法避免
195function prep(raw: any) {
196 const comps = (raw.c || []).map((x: any) => ({ t: Date.parse(x.t), trig: x.trig, pre: x.pre || 0 })).filter((x: any) => Number.isFinite(x.t))
197 const reqs = (raw.r || [])
198 .map((x: any) => ({ t: Date.parse(x.t), m: x.m || '', i: x.i, cw: x.cw, cr: x.cr, o: x.o }))
199 .filter((x: any) => Number.isFinite(x.t) && x.i + x.cw + x.cr > 0)
200 .sort((a: any, b: any) => a.t - b.t)
201 let prev = 0
202 for (const q of reqs) {
203 q.ctx = q.i + q.cw + q.cr
204 q.inp = priceOf(q.m).inp
205 q.cost = (q.i * q.inp + q.cr * 0.05 * q.inp + q.cw * 2 * q.inp + q.o * priceOf(q.m).out) / 1e6
206 q.gap = prev ? (q.t - prev) / 60000 : 0
207 const isMiss = q.cw > 0.5 * q.ctx && q.ctx > 30000
208 q.extra = isMiss ? (q.cw * 1.95 * q.inp) / 1e6 : 0
209 const afterCompact = comps.some((c: any) => c.t > prev && c.t <= q.t)
210 if (kwTimes.some(k => q.t >= k - 2000 && q.t <= k + 180000)) q.kind = 'kw'
211 else if (!isMiss) q.kind = 'hit'
212 else if (afterCompact) q.kind = 'compact'
213 else if (q.gap >= 60) q.kind = 'idle'
214 else q.kind = 'other'
215 prev = q.t
216 }
217 return { reqs, comps }
218}
219
220// 费用账本:逐次累计正常费用、无法避免的重建、保温花费、冷读取多付,以及保温避免的冷读取
221function ledger(rs: any[]) {
222 let base = 0
223 let rebuild = 0
224 let kw = 0
225 let cold = 0
226 let avoided = 0
227 let coldN = 0
228 let kwN = 0
229 let lastRealT: number | null = null
230 let chainCtx = 0
231 let chainInp = 4
232 const rows = rs.map((q: any) => {
233 if (q.kind === 'kw') {
234 kw += q.cost
235 kwN += 1
236 chainCtx = q.ctx
237 chainInp = q.inp
238 } else {
239 // 一串保温之后的第一句真实请求:若离上一句真实请求已满 1 小时、而缓存仍热,记为避免了一次冷读取
240 if (chainCtx > 0 && lastRealT !== null && q.t - lastRealT >= HOUR && q.gap < 60) avoided += (chainCtx * 1.95 * chainInp) / 1e6
241 chainCtx = 0
242 lastRealT = q.t
243 if (q.kind === 'idle') {
244 cold += q.extra
245 coldN += 1
246 } else rebuild += q.extra
247 base += q.cost - q.extra
248 }
249 return { t: q.t, s1: base, s2: base + rebuild, s3: base + rebuild + kw, s4: base + rebuild + kw + cold, cf: base + rebuild + cold + avoided }
250 })
251 return { rows, base, rebuild, kw, cold, avoided, coldN, kwN }
252}
253
254function down(a: any[], n: number): any[] {
255 if (a.length <= n) return a
256 const step = a.length / n
257 const out: any[] = []
258 for (let i = 0; i < n; i++) out.push(a[Math.floor(i * step)])
259 out.push(a[a.length - 1])
260 return out
261}
262
263// 纵轴刻度取 1、2、2.5、5 乘 10 的整数次幂,至多 n 格
264function nice(v: number, n = 4) {
265 const raw = Math.max(v, 1e-9) / n
266 const mag = Math.pow(10, Math.floor(Math.log10(raw)))
267 let step = 10 * mag
268 for (const m of [1, 2, 2.5, 5]) {
269 if (m * mag >= raw) {
270 step = m * mag
271 break
272 }
273 }
274 return { max: Math.ceil(v / step - 1e-9) * step, step }
275}
276
277// 界面文字用中性色并随深浅主题切换;橙色只用于数据
278const FONT = "system-ui,-apple-system,'Segoe UI','Microsoft YaHei','PingFang SC',sans-serif"
279const STYLE =
280 '<style>.p{fill:#1f2328}.s{fill:#656d76}.g{stroke:rgba(128,128,128,.2)}.ax{stroke:rgba(128,128,128,.55)}' +
281 '.bg{fill:rgba(128,128,128,.07)}.tr{fill:rgba(128,128,128,.2)}.hl{fill:#9A3412}.ring{stroke:#f6f6f6}' +
282 '@media (prefers-color-scheme:dark){.p{fill:#e6edf3}.s{fill:#9da5ae}.hl{fill:#FDBA74}.ring{stroke:#2a2a2a}}</style>'
283const DEFS =
284 '<defs>' +
285 `<linearGradient id="warm" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="${C_MAIN}" stop-opacity=".45"/><stop offset="1" stop-color="${C_MAIN}" stop-opacity=".05"/></linearGradient>` +
286 `<linearGradient id="soft" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="${C_MAIN}" stop-opacity=".18"/><stop offset="1" stop-color="${C_MAIN}" stop-opacity=".03"/></linearGradient>` +
287 '<pattern id="hatch" width="6" height="6" patternUnits="userSpaceOnUse" patternTransform="rotate(45)"><rect width="6" height="6" fill="rgba(128,128,128,.06)"/><line x1="0" y1="0" x2="0" y2="6" stroke="rgba(128,128,128,.32)" stroke-width="1.6"/></pattern>' +
288 '</defs>'
289
290type G = { w: number; h: number; x0: number; x1: number; y0: number; y1: number }
291
292// 标记尺寸取画布两倍:面板按标记宽度铺满所在栏,高度按比例,放大面板时图随之放大
293function doc(w: number, h: number, inner: string, card = true) {
294 return (
295 `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${w} ${h}" width="${w * 2}" height="${h * 2}" font-family="${FONT}" font-size="12">` +
296 STYLE +
297 DEFS +
298 (card ? `<rect x="0" y="0" width="${w}" height="${h}" rx="12" class="bg"/>` : '') +
299 inner +
300 '</svg>'
301 )
302}
303
304function head(title: string, sub: string) {
305 return `<text x="16" y="27" class="p" font-size="15" font-weight="600">${title}</text><text x="16" y="45" class="s">${sub}</text>`
306}
307
308function yAxis(g: G, max: number, step: number, fmt: (v: number) => string) {
309 let s = ''
310 for (let v = 0; v <= max + 1e-9; v += step) {
311 const y = g.y1 - (v / max) * (g.y1 - g.y0)
312 s += `<line x1="${g.x0}" y1="${y}" x2="${g.x1}" y2="${y}" class="${v === 0 ? 'ax' : 'g'}"/>`
313 s += `<text x="${g.x0 - 6}" y="${y + 4}" text-anchor="end" class="s" font-size="11">${fmt(v)}</text>`
314 }
315 return s
316}
317
318function hline(g: G, y: number, color: string, label: string, below = false) {
319 return (
320 `<line x1="${g.x0}" y1="${y}" x2="${g.x1}" y2="${y}" stroke="${color}" stroke-width="1.2" stroke-dasharray="5 4"/>` +
321 `<text x="${g.x0 + 4}" y="${below ? y + 14 : y - 5}" fill="${color}" font-size="11" font-weight="600">${label}</text>`
322 )
323}
324
325// 实时间轴:刻度按本地时间对齐
326function timeAxis(g: G, t0: number, t1: number) {
327 const span = t1 - t0
328 let step = 24 * HOUR
329 for (const c of [30 * 60000, HOUR, 2 * HOUR, 3 * HOUR, 6 * HOUR, 12 * HOUR]) {
330 if (span / c <= 8) {
331 step = c
332 break
333 }
334 }
335 const o = new Date(t0).getTimezoneOffset() * 60000
336 let s = ''
337 for (let t = Math.ceil((t0 - o) / step) * step + o; t <= t1; t += step) {
338 const x = g.x0 + ((t - t0) / span) * (g.x1 - g.x0)
339 s += `<line x1="${x}" y1="${g.y1}" x2="${x}" y2="${g.y1 + 4}" class="ax"/>`
340 s += `<text x="${x}" y="${g.y1 + 17}" text-anchor="middle" class="s" font-size="11">${hm(t)}</text>`
341 }
342 return s
343}
344
345// 压缩空闲:超过 30 分钟的空闲一律按 30 分钟宽度画;超过 60 分钟的记为断点
346function pack(rs: any[], g: G) {
347 const CAP = 30 * 60000
348 const pos: number[] = []
349 const breaks: { i: number; gap: number }[] = []
350 let acc = 0
351 for (let i = 0; i < rs.length; i++) {
352 if (i > 0) {
353 const d = rs[i].t - rs[i - 1].t
354 acc += Math.min(d, CAP)
355 if (d > HOUR) breaks.push({ i, gap: d / 60000 })
356 }
357 pos.push(acc)
358 }
359 const end = Math.max(acc, 1)
360 const xs = pos.map(p => g.x0 + (p / end) * (g.x1 - g.x0))
361 return { xs, breaks }
362}
363
364// 压缩轴的刻度:每段活动的起点优先,其次每小时第一次请求;标签相距不足 46 就跳过
365function packAxis(g: G, rs: any[], xs: number[], breaks: { i: number }[]) {
366 const cand: { x: number; t: number; pri: number }[] = [{ x: xs[0], t: rs[0].t, pri: 0 }]
367 for (const b of breaks) cand.push({ x: xs[b.i], t: rs[b.i].t, pri: 1 })
368 cand.push({ x: xs[xs.length - 1], t: rs[rs.length - 1].t, pri: 1 })
369 let lastHour = Math.floor(rs[0].t / HOUR)
370 for (let i = 1; i < rs.length; i++) {
371 const h = Math.floor(rs[i].t / HOUR)
372 if (h !== lastHour) cand.push({ x: xs[i], t: rs[i].t, pri: 2 })
373 lastHour = h
374 }
375 cand.sort((a, b) => a.pri - b.pri || a.x - b.x)
376 const kept: { x: number; t: number }[] = []
377 for (const c of cand) if (kept.every(k => Math.abs(k.x - c.x) >= 46)) kept.push(c)
378 kept.sort((a, b) => a.x - b.x)
379 let s = ''
380 let day = ''
381 for (const k of kept) {
382 s += `<line x1="${k.x}" y1="${g.y1}" x2="${k.x}" y2="${g.y1 + 4}" class="ax"/>`
383 s += `<text x="${k.x}" y="${g.y1 + 17}" text-anchor="middle" class="s" font-size="11">${hm(k.t)}</text>`
384 if (md(k.t) !== day) {
385 day = md(k.t)
386 s += `<text x="${k.x}" y="${g.y1 + 31}" text-anchor="middle" class="s" font-size="10.5" font-weight="600">${day}</text>`
387 }
388 }
389 return s
390}
391
392function breakMarks(g: G, xs: number[], breaks: { i: number; gap: number }[]) {
393 let s = ''
394 let last = -100
395 for (const b of breaks) {
396 const x = (xs[b.i - 1] + xs[b.i]) / 2
397 s += `<line x1="${x}" y1="${g.y0}" x2="${x}" y2="${g.y1}" class="g" stroke-dasharray="2 3"/>`
398 if (x - last > 56) {
399 s += `<text x="${x}" y="${g.y0 - 6}" text-anchor="middle" class="s" font-size="10.5">闲 ${dur(b.gap)}</text>`
400 last = x
401 }
402 }
403 return s
404}
405
406// 文字宽度粗估:中日韩字符按字号计,其余按 0.56 倍
407function textW(t: string, size: number) {
408 let w = 0
409 for (const ch of t) w += ch.charCodeAt(0) > 255 ? size : size * 0.56
410 return w
411}
412
413const dia = (x: number, y: number, r: number, fill: string, stroke: string) =>
414 `<path d="M${x} ${y - r}L${x + r} ${y}L${x} ${y + r}L${x - r} ${y}Z" fill="${fill}" stroke="${stroke}" stroke-width="1.4"/>`
415
416// 图例符号:area 渐变面积、hatch 斜纹、band 实心色块、line 实线、dash 虚线、dot 圆点、dia 菱形、odia 空心菱形、tri 三角
417function swatch(kind: string, color: string, x: number, y: number) {
418 if (kind === 'area') return `<rect x="${x}" y="${y - 9}" width="12" height="10" rx="2" fill="url(#warm)"/><line x1="${x}" y1="${y - 9}" x2="${x + 12}" y2="${y - 9}" stroke="${color}" stroke-width="1.6"/>`
419 if (kind === 'hatch') return `<rect x="${x}" y="${y - 9}" width="12" height="10" rx="2" fill="url(#hatch)"/>`
420 if (kind === 'band') return `<rect x="${x}" y="${y - 9}" width="12" height="10" rx="2" fill="${color}"/>`
421 if (kind === 'dot') return `<circle cx="${x + 6}" cy="${y - 4}" r="3.6" fill="${color}"/>`
422 if (kind === 'dia') return dia(x + 6, y - 4, 4.6, color, 'none')
423 if (kind === 'odia') return dia(x + 6, y - 4, 4.2, 'none', color)
424 if (kind === 'tri') return `<path d="M${x + 1} ${y - 9}H${x + 11}L${x + 6} ${y}Z" fill="${color}"/>`
425 return `<line x1="${x}" y1="${y - 4}" x2="${x + 12}" y2="${y - 4}" stroke="${color}" stroke-width="2"${kind === 'dash' ? ' stroke-dasharray="3 2"' : ''}/>`
426}
427
428function legend(h: number, items: string[][]) {
429 let s = ''
430 let x = 16
431 const y = h - 14
432 for (const [kind, color, label] of items) {
433 s += swatch(kind, color, x, y)
434 s += `<text x="${x + 17}" y="${y}" class="s" font-size="11">${label}</text>`
435 x += 17 + textW(label, 11) + 16
436 }
437 return s
438}
439
440// 右侧直接标注:按 y 排序后错开,避免文字相叠
441function endLabels(g: G, items: { y: number; text: string; color: string }[]) {
442 const sorted = [...items].sort((a, b) => a.y - b.y)
443 let last = -100
444 let s = ''
445 for (const it of sorted) {
446 const y = Math.max(it.y, last + 15)
447 last = y
448 const paint = it.color === C_DARK ? 'class="hl"' : `fill="${it.color}"`
449 s += `<text x="${g.x1 + 8}" y="${y + 4}" ${paint} font-size="11.5" font-weight="600">${it.text}</text>`
450 }
451 return s
452}
453
454// 图一:缓存时间线(最近 3 小时到未来 1 小时)。面积是缓存里存着的上下文:缓存有效时为橙色渐变,过期后为斜纹
455function svgLine(d: any, now: number) {
456 const g: G = { w: 480, h: 340, x0: 46, x1: 464, y0: 70, y1: 262 }
457 const t0 = now - 3 * HOUR
458 const t1 = now + HOUR
459 const sx = (t: number) => g.x0 + ((Math.min(Math.max(t, t0), t1) - t0) / (t1 - t0)) * (g.x1 - g.x0)
460 const rs = d.reqs
461 let first = rs.findIndex((q: any) => q.t >= t0)
462 if (first < 0) first = rs.length
463 const from = Math.max(0, first - 1)
464 const vis = rs.slice(from)
465 const top = vis.reduce((m: number, q: any) => Math.max(m, q.ctx), 0)
466 const { max, step } = nice(Math.max(top, compactAt) * 1.06)
467 const sy = (v: number) => g.y1 - (v / max) * (g.y1 - g.y0)
468 let s = head('缓存时间线', '面积高度为缓存里的上下文;橙色为缓存有效,斜纹为已过期') + yAxis(g, max, step, kfmt) + timeAxis(g, t0, t1)
469
470 // 把每次请求到下一次请求之间切成“有效”和“过期”两段
471 const pieces: { a: number; b: number; ctx: number; warm: boolean }[] = []
472 for (let k = 0; k < vis.length; k++) {
473 const q = vis[k]
474 const end = k + 1 < vis.length ? vis[k + 1].t : now
475 const warmEnd = Math.min(end, q.t + HOUR)
476 if (warmEnd > t0 && warmEnd > q.t) pieces.push({ a: Math.max(q.t, t0), b: warmEnd, ctx: q.ctx, warm: true })
477 if (end > warmEnd && end > t0) pieces.push({ a: Math.max(warmEnd, t0), b: end, ctx: q.ctx, warm: false })
478 }
479 // 有效段连成整片面积,避免相邻矩形之间出现细缝
480 let run: typeof pieces = []
481 const flush = () => {
482 if (!run.length) return
483 let path = `M${sx(run[0].a)} ${g.y1}`
484 let edge = ''
485 for (const p of run) {
486 path += `L${sx(p.a)} ${sy(p.ctx)}L${sx(p.b)} ${sy(p.ctx)}`
487 edge += `${edge ? 'L' : 'M'}${sx(p.a)} ${sy(p.ctx)}L${sx(p.b)} ${sy(p.ctx)}`
488 }
489 path += `L${sx(run[run.length - 1].b)} ${g.y1}Z`
490 s += `<path d="${path}" fill="url(#warm)"/><path d="${edge}" fill="none" stroke="${C_MAIN}" stroke-width="1.8" stroke-linejoin="round"/>`
491 run = []
492 }
493 for (const p of pieces) {
494 const contiguous = run.length && Math.abs(run[run.length - 1].b - p.a) < 1
495 if (p.warm && (contiguous || !run.length)) run.push(p)
496 else {
497 flush()
498 if (p.warm) run.push(p)
499 else {
500 const xa = sx(p.a)
501 const xb = sx(p.b)
502 s += `<rect x="${xa}" y="${sy(p.ctx)}" width="${Math.max(0, xb - xa)}" height="${g.y1 - sy(p.ctx)}" fill="url(#hatch)"/>`
503 s += `<line x1="${xa}" y1="${sy(p.ctx)}" x2="${xb}" y2="${sy(p.ctx)}" stroke="rgba(128,128,128,.6)" stroke-width="1.2" stroke-dasharray="3 3"/>`
504 if (xb - xa > 76) s += `<text x="${(xa + xb) / 2}" y="${(sy(p.ctx) + g.y1) / 2 + 4}" text-anchor="middle" class="s" font-size="11">缓存已过期</text>`
505 }
506 }
507 }
508 flush()
509
510 // 未来:缓存还能保持到何时(接续保温开启时延伸到计划的最后一次保温之后 1 小时)
511 const last = rs.length ? rs[rs.length - 1] : null
512 const exp = last ? last.t + HOUR : now
513 if (last && exp > now) {
514 const planEnd = hours > 0 && lastReal !== null ? lastReal + hours * HOUR + HOUR : exp
515 const fEnd = Math.min(t1, Math.max(exp, planEnd))
516 s += `<rect x="${sx(now)}" y="${sy(last.ctx)}" width="${sx(fEnd) - sx(now)}" height="${g.y1 - sy(last.ctx)}" fill="url(#soft)"/>`
517 s += `<line x1="${sx(now)}" y1="${sy(last.ctx)}" x2="${sx(fEnd)}" y2="${sy(last.ctx)}" stroke="${C_MAIN}" stroke-width="1.4" stroke-dasharray="4 3"/>`
518 }
519 s += hline(g, sy(compactAt), C_GOLD, `自动压缩 ${kfmt(compactAt)}`)
520
521 // 请求刻痕与事件标记
522 for (const q of vis) if (q.t >= t0) s += `<line x1="${sx(q.t)}" y1="${g.y1}" x2="${sx(q.t)}" y2="${g.y1 - 4}" stroke="${q.kind === 'hit' ? C_MAIN : q.kind === 'kw' ? C_GOLD : C_DARK}" stroke-width="0.8" opacity=".55"/>`
523 let lastLabel = -100
524 for (const q of vis) {
525 if (q.t < t0) continue
526 const x = sx(q.t)
527 const y = sy(q.ctx)
528 if (q.kind === 'kw') s += dia(x, y, 5, C_GOLD, '#fff')
529 else if (q.kind !== 'hit') {
530 s += `<circle cx="${x}" cy="${y}" r="4" fill="${C_DARK}" class="ring" stroke-width="1.5"/>`
531 if (x - lastLabel > 74) {
532 const text = q.kind === 'idle' ? `冷读取 +${usd(q.extra)}` : q.kind === 'compact' ? '压缩后重建' : '整段重写'
533 s += `<text x="${x}" y="${y - 9}" text-anchor="middle" class="hl" font-size="10.5" font-weight="600">${text}</text>`
534 lastLabel = x
535 }
536 }
537 }
538 if (hours > 0 && lastReal !== null && last) {
539 const end = lastReal + hours * HOUR
540 for (let t = Math.max(now, last.t + FIRE_MIN * 60000); t < end && t <= t1; t += FIRE_MIN * 60000) s += dia(sx(t), sy(last.ctx), 5, 'none', C_GOLD)
541 }
542
543 // 现在与到期
544 s += `<line x1="${sx(now)}" y1="${g.y0 - 2}" x2="${sx(now)}" y2="${g.y1}" class="ax" stroke-width="1.2"/>`
545 const expired = exp < now
546 s += `<text x="${sx(now) + (expired ? 5 : -5)}" y="${g.y0 - 6}" text-anchor="${expired ? 'start' : 'end'}" class="p" font-size="11" font-weight="600">现在 ${hm(now)}</text>`
547 if (last && exp > t0) {
548 s += `<line x1="${sx(exp)}" y1="${g.y0 - 2}" x2="${sx(exp)}" y2="${g.y1}" stroke="${C_RED}" stroke-width="1.2" stroke-dasharray="4 3"/>`
549 s += `<text x="${sx(exp) + (expired ? 5 : -5)}" y="${g.y0 + 14}" text-anchor="${expired ? 'start' : 'end'}" fill="${C_RED}" font-size="11" font-weight="600">缓存到期 ${hm(exp)}</text>`
550 }
551 // 当前上下文标在“现在”线右侧的预计区里;缓存已过期时标在有效段末端内侧
552 if (last) {
553 const lx = exp > now ? sx(now) + 6 : sx(exp) - 6
554 s += `<text x="${lx}" y="${sy(last.ctx) + 17}" text-anchor="${exp > now ? 'start' : 'end'}" class="hl" font-size="12" font-weight="700">${kfmt(last.ctx)}</text>`
555 }
556 s += legend(g.h, [['area', C_MAIN, '缓存有效'], ['hatch', '', '已过期'], ['dot', C_DARK, '冷读取'], ['dia', C_GOLD, '保温'], ['odia', C_GOLD, '计划保温']])
557 return doc(g.w, g.h, s)
558}
559
560// 图二:费用构成(空闲时段压缩)。自下而上:正常费用、无法避免的重建、保温花费、冷读取多付
561function svgCost(d: any) {
562 const g: G = { w: 480, h: 362, x0: 46, x1: 396, y0: 124, y1: 276 }
563 const rs = d.reqs
564 if (!rs.length) return doc(g.w, g.h, head('费用构成', '没有读到请求记录'))
565 const L = ledger(rs)
566 const { xs, breaks } = pack(rs, g)
567 const pts = L.rows.map((r: any, i: number) => ({ ...r, x: xs[i] }))
568 const total = L.base + L.rebuild + L.kw + L.cold
569 const { max, step } = nice(Math.max(total, total - L.kw + L.avoided, 0.01) * 1.05)
570 const sy = (v: number) => g.y1 - (v / max) * (g.y1 - g.y0)
571 const sp = down(pts, 300)
572 const line = (key: string) => sp.map((p: any) => `${p.x.toFixed(1)},${sy(p[key]).toFixed(1)}`)
573 const band = (lo: string, hi: string, fill: string) => {
574 const upper = line(hi)
575 const lower = lo ? line(lo).reverse() : [`${sp[sp.length - 1].x.toFixed(1)},${g.y1}`, `${sp[0].x.toFixed(1)},${g.y1}`]
576 return `<polygon points="${upper.join(' ')} ${lower.join(' ')}" fill="${fill}"/>`
577 }
578
579 let s = head('费用构成', '按列表价估算,不是账单;空闲超过 30 分钟的时段已压缩')
580 // 顶部三项读数,颜色与图中色带一一对应
581 const net = L.avoided - L.kw
582 const kpi = [
583 { kind: 'band', color: C_DARK, label: '冷读取多付', value: usd(L.cold), sub: `${L.coldN} 次` },
584 { kind: 'band', color: C_GOLD, label: '保温花费', value: usd(L.kw), sub: `${L.kwN} 次` },
585 { kind: 'dash', color: C_DARK, label: net >= 0 ? '保温净省' : '保温净花', value: usd(Math.abs(net)), sub: `避免 ${usd(L.avoided)}` },
586 ]
587 kpi.forEach((k, j) => {
588 const x = 16 + j * 152
589 s += swatch(k.kind, k.color, x, 72) + `<text x="${x + 17}" y="72" class="s" font-size="11">${k.label}</text>`
590 s += `<text x="${x}" y="97" class="p" font-size="18" font-weight="700">${k.value}</text>`
591 s += `<text x="${x + textW(k.value, 18) + 6}" y="97" class="s" font-size="11">${k.sub}</text>`
592 })
593 s += yAxis(g, max, step, v => `$${Number.isInteger(Math.round(v * 100) / 100) ? v.toFixed(0) : v.toFixed(v < 1 ? 2 : 1)}`) + breakMarks(g, xs, breaks) + packAxis(g, rs, xs, breaks)
594 s += band('', 's1', 'url(#warm)')
595 if (L.rebuild > 0) s += band('s1', 's2', 'rgba(128,128,128,.45)')
596 if (L.kw > 0) s += band('s2', 's3', C_GOLD)
597 if (L.cold > 0) s += band('s3', 's4', C_DARK)
598 s += `<polyline fill="none" stroke="${C_MAIN}" stroke-width="2.2" stroke-linejoin="round" points="${line('s4').join(' ')}"/>`
599 const labels = [{ y: sy(total), text: `总 ${usd(total)}`, color: C_MAIN }]
600 if (L.kw > 0 || L.avoided > 0) {
601 const k = Math.max(0, pts.findIndex((p: any) => Math.abs(p.cf - p.s4) > 0.005) - 1)
602 const cf = pts.slice(k).map((p: any) => `${p.x.toFixed(1)},${sy(p.cf).toFixed(1)}`).join(' ')
603 s += `<polyline fill="none" stroke="${C_DARK}" stroke-width="1.6" stroke-dasharray="5 3" stroke-linejoin="round" points="${cf}"/>`
604 labels.push({ y: sy(total - L.kw + L.avoided), text: `不保温 ${usd(total - L.kw + L.avoided)}`, color: C_DARK })
605 }
606 // 每次冷读取在总费用线上标出多付的钱
607 let lastX = -100
608 pts.forEach((p: any, i: number) => {
609 if (rs[i].kind !== 'idle') return
610 s += `<circle cx="${p.x}" cy="${sy(p.s4)}" r="3.6" fill="${C_DARK}" class="ring" stroke-width="1.4"/>`
611 if (p.x - lastX > 44) {
612 const nearEdge = p.x > g.x1 - 30
613 s += `<text x="${nearEdge ? p.x - 7 : p.x}" y="${sy(p.s4) - (nearEdge ? 3 : 8)}" text-anchor="${nearEdge ? 'end' : 'middle'}" class="hl" font-size="10.5" font-weight="600">+${usd(rs[i].extra)}</text>`
614 lastX = p.x
615 }
616 })
617 s += endLabels(g, labels)
618 s += legend(g.h, [['area', C_MAIN, '正常对话'], ['band', 'rgba(128,128,128,.45)', '压缩或换模型后重建'], ['dash', C_DARK, '不保温时估算']])
619 return doc(g.w, g.h, s)
620}
621
622// 图三:上下文长度(空闲时段压缩,按活动段断开)
623function svgCtx(d: any) {
624 const g: G = { w: 480, h: 340, x0: 46, x1: 464, y0: 70, y1: 256 }
625 const rs = d.reqs
626 if (!rs.length) return doc(g.w, g.h, head('上下文长度', '没有读到请求记录'))
627 const { xs, breaks } = pack(rs, g)
628 const top = rs.reduce((m: number, q: any) => Math.max(m, q.ctx), 0)
629 const { max, step } = nice(Math.max(top, compactAt) * 1.06)
630 const sy = (v: number) => g.y1 - (v / max) * (g.y1 - g.y0)
631 let s = head('上下文长度', '三角为压缩发生处,尖端指向压缩前长度') + yAxis(g, max, step, kfmt) + breakMarks(g, xs, breaks) + packAxis(g, rs, xs, breaks)
632 const cuts = [0, ...breaks.map(b => b.i), rs.length]
633 for (let k = 0; k + 1 < cuts.length; k++) {
634 const idx: number[] = []
635 for (let i = cuts[k]; i < cuts[k + 1]; i++) idx.push(i)
636 const sp = down(idx, 200)
637 if (sp.length === 1) {
638 s += `<circle cx="${xs[sp[0]]}" cy="${sy(rs[sp[0]].ctx)}" r="2.5" fill="${C_MAIN}"/>`
639 continue
640 }
641 const line = sp.map((i: number) => `${xs[i].toFixed(1)},${sy(rs[i].ctx).toFixed(1)}`).join(' ')
642 s += `<polygon points="${xs[sp[0]].toFixed(1)},${g.y1} ${line} ${xs[sp[sp.length - 1]].toFixed(1)},${g.y1}" fill="url(#warm)"/>`
643 s += `<polyline fill="none" stroke="${C_MAIN}" stroke-width="2" stroke-linejoin="round" points="${line}"/>`
644 }
645 s += hline(g, sy(compactAt), C_GOLD, `自动压缩 ${kfmt(compactAt)}`)
646 for (const m of d.comps) {
647 let i = rs.findIndex((q: any) => q.t >= m.t)
648 if (i < 0) i = rs.length - 1
649 const x = xs[i]
650 const y = sy(m.pre)
651 const auto = m.trig === 'auto'
652 s += `<path d="M${x - 6} ${y - 10}H${x + 6}L${x} ${y}Z" fill="${auto ? C_DARK : C_TEXT}"/>`
653 s += `<text x="${x}" y="${y - 14}" text-anchor="middle" ${auto ? 'class="hl"' : 'class="s"'} font-size="10.5" font-weight="600">${kfmt(m.pre)}</text>`
654 }
655 const last = rs[rs.length - 1]
656 s += `<circle cx="${xs[rs.length - 1]}" cy="${sy(last.ctx)}" r="3.6" fill="${C_DARK}" class="ring" stroke-width="1.4"/>`
657 s += `<text x="${xs[rs.length - 1] - 7}" y="${sy(last.ctx) - 8}" text-anchor="end" class="hl" font-size="11.5" font-weight="700">${kfmt(last.ctx)}</text>`
658 s += legend(g.h, [['area', C_MAIN, '上下文长度'], ['tri', C_DARK, '自动压缩'], ['tri', C_TEXT, '手动压缩'], ['dash', C_GOLD, '压缩阈值']])
659 return doc(g.w, g.h, s)
660}
661
662// ---------- 长期汇总:按小时、天、周、月分格 ----------
663const SPANS: Record<string, { n: number; desc: string; name: string }> = {
664 hour: { n: 24, desc: '近 24 小时,按小时', name: '近 24 小时' },
665 d7: { n: 7, desc: '近 7 天,按天', name: '近 7 天' },
666 day: { n: 30, desc: '近 30 天,按天', name: '近 30 天' },
667 week: { n: 12, desc: '近 12 周,按周', name: '近 12 周' },
668 month: { n: 12, desc: '近 12 个月,按月', name: '近 12 个月' },
669}
670const C_BASE = '#F6B26B'
671const C_REB = 'rgba(128,128,128,.45)'
672
673// 分格边界按本地日历对齐(周从周一起),最后一格含现在
674function edgesOf(unit: string, now: number) {
675 const d = new Date(now)
676 if (unit === 'hour') d.setMinutes(0, 0, 0)
677 else {
678 d.setHours(0, 0, 0, 0)
679 if (unit === 'week') d.setDate(d.getDate() - ((d.getDay() + 6) % 7))
680 if (unit === 'month') d.setDate(1)
681 }
682 const out: number[] = []
683 for (let k = 1 - SPANS[unit].n; k <= 1; k++) {
684 const x = new Date(d.getTime())
685 if (unit === 'hour') x.setHours(x.getHours() + k)
686 else if (unit === 'day' || unit === 'd7') x.setDate(x.getDate() + k)
687 else if (unit === 'week') x.setDate(x.getDate() + 7 * k)
688 else x.setMonth(x.getMonth() + k)
689 out.push(x.getTime())
690 }
691 return out
692}
693
694function binName(unit: string, t: number) {
695 const d = new Date(t)
696 if (unit === 'hour') return `${md(t)} ${p2(d.getHours())}时`
697 if (unit === 'week') return `${md(t)} 起一周`
698 if (unit === 'month') return `${d.getFullYear()}-${p2(d.getMonth() + 1)}`
699 return md(t)
700}
701
702// 柱状图横轴:标签从最后一格往前每隔若干格标一个,至多 8 个;按小时时换日处加日期,按月时换年处加年份
703function barAxis(g: G, unit: string, edges: number[]) {
704 const nb = edges.length - 1
705 const bw = (g.x1 - g.x0) / nb
706 const every = Math.ceil(nb / 8)
707 let s = ''
708 let sub = ''
709 for (let k = 0; k < nb; k++) {
710 if ((nb - 1 - k) % every !== 0) continue
711 const x = g.x0 + (k + 0.5) * bw
712 const d = new Date(edges[k])
713 const main = unit === 'hour' ? `${p2(d.getHours())}时` : unit === 'month' ? `${d.getMonth() + 1}月` : md(edges[k])
714 s += `<line x1="${x}" y1="${g.y1}" x2="${x}" y2="${g.y1 + 4}" class="ax"/>`
715 s += `<text x="${x}" y="${g.y1 + 17}" text-anchor="middle" class="s" font-size="11">${main}</text>`
716 const second = unit === 'hour' ? md(edges[k]) : unit === 'month' ? `${d.getFullYear()}` : ''
717 if (second && second !== sub) {
718 sub = second
719 s += `<text x="${x}" y="${g.y1 + 31}" text-anchor="middle" class="s" font-size="10.5" font-weight="600">${second}</text>`
720 }
721 }
722 return { s, bw, cx: (k: number) => g.x0 + (k + 0.5) * bw }
723}
724
725// 一根堆叠柱:自下而上逐段画,段间留 0.6 像素缝
726function stack(x: number, w: number, sy: (v: number) => number, parts: { v: number; fill: string }[]) {
727 let s = ''
728 let acc = 0
729 for (const p of parts) {
730 if (p.v <= 0) continue
731 const yb = sy(acc)
732 const yt = sy(acc + p.v)
733 s += `<rect x="${x - w / 2}" y="${yt}" width="${w}" height="${Math.max(0.6, yb - yt - (acc > 0 ? 0.6 : 0))}" fill="${p.fill}"/>`
734 acc += p.v
735 }
736 return s
737}
738
739// 柱顶小圆标:圆里写次数,柱太窄时只画圆
740function badge(x: number, y: number, n: number, bw: number) {
741 const r = bw >= 13 ? 6.5 : 4
742 return `<circle cx="${x}" cy="${y - r - 2}" r="${r}" fill="${C_DARK}" class="ring" stroke-width="1.2"/>` + (r >= 6 ? `<text x="${x}" y="${y - r + 1.5}" text-anchor="middle" fill="#fff" font-size="9" font-weight="700">${n}</text>` : '')
743}
744
745function kpiRow(cold: number, coldN: number, kw: number, kwN: number, avoided: number) {
746 const net = avoided - kw
747 const kpi = [
748 { kind: 'band', color: C_DARK, label: '冷读取多付', value: usd(cold), sub: `${coldN} 次` },
749 { kind: 'band', color: C_GOLD, label: '保温花费', value: usd(kw), sub: `${kwN} 次` },
750 { kind: 'dash', color: C_DARK, label: net >= 0 ? '保温净省' : '保温净花', value: usd(Math.abs(net)), sub: `避免 ${usd(avoided)}` },
751 ]
752 let s = ''
753 kpi.forEach((k, j) => {
754 const x = 16 + j * 152
755 s += swatch(k.kind, k.color, x, 72) + `<text x="${x + 17}" y="72" class="s" font-size="11">${k.label}</text>`
756 s += `<text x="${x}" y="97" class="p" font-size="18" font-weight="700">${k.value}</text>`
757 s += `<text x="${x + textW(k.value, 18) + 6}" y="97" class="s" font-size="11">${k.sub}</text>`
758 })
759 return s
760}
761
762const mfmt = (v: number) => (v === 0 ? '0' : v >= 1e6 ? `${+(v / 1e6).toFixed(2)}M` : kfmt(v))
763const tot = (b: any) => b.base + b.rebuild + b.kw + b.cold
764const sumOf = (bs: any[], k: string) => bs.reduce((m: number, b: any) => m + b[k], 0)
765
766// 汇总图一:缓存写入按原因拆分。写入单价是读取的 40 倍,冷读取和重建都表现为写入突增
767function aggWrite(a: any) {
768 const g: G = { w: 480, h: 340, x0: 46, x1: 464, y0: 70, y1: 262 }
769 const bs = a.b
770 const { s: ax, bw, cx } = barAxis(g, a.unit, a.edges)
771 const top = bs.reduce((m: number, b: any) => Math.max(m, b.cw), 0)
772 const { max, step } = nice(Math.max(top, 1000) * 1.12)
773 const sy = (v: number) => g.y1 - (v / max) * (g.y1 - g.y0)
774 const all = sumOf(bs, 'cw')
775 const share = all > 0 ? Math.round((100 * (sumOf(bs, 'cwIdle') + sumOf(bs, 'cwRe'))) / all) : 0
776 let s = head('缓存写入', `全部项目 · ${SPANS[a.unit].desc};整段重写占写入 ${share}%`) + yAxis(g, max, step, mfmt) + ax
777 bs.forEach((b: any, k: number) => {
778 if (b.cw <= 0) return
779 s += stack(cx(k), bw * 0.66, sy, [
780 { v: b.cwHit, fill: C_BASE },
781 { v: b.cwRe, fill: C_REB },
782 { v: b.cwKw, fill: C_GOLD },
783 { v: b.cwIdle, fill: C_DARK },
784 ])
785 if (b.coldN > 0) s += badge(cx(k), sy(b.cw), b.coldN, bw)
786 })
787 s += legend(g.h, [['band', C_BASE, '对话增长'], ['band', C_REB, '压缩或换模型重建'], ['band', C_GOLD, '保温'], ['dot', C_DARK, '冷读取(数字为次数)']])
788 return doc(g.w, g.h, s)
789}
790
791// 汇总图二:每格费用构成,虚线短横为不保温时的估算
792function aggCost(a: any) {
793 const g: G = { w: 480, h: 354, x0: 46, x1: 464, y0: 124, y1: 292 }
794 const bs = a.b
795 const { s: ax, bw, cx } = barAxis(g, a.unit, a.edges)
796 const all = bs.reduce((m: number, b: any) => m + tot(b), 0)
797 const top = bs.reduce((m: number, b: any) => Math.max(m, tot(b), tot(b) - b.kw + b.avoided), 0)
798 const { max, step } = nice(Math.max(top, 0.01) * 1.12)
799 const sy = (v: number) => g.y1 - (v / max) * (g.y1 - g.y0)
800 let s = head('费用构成', `全部项目 · ${SPANS[a.unit].desc};列表价估算,合计 ${usd(all)}`)
801 s += kpiRow(sumOf(bs, 'cold'), sumOf(bs, 'coldN'), sumOf(bs, 'kw'), sumOf(bs, 'kwN'), sumOf(bs, 'avoided'))
802 s += yAxis(g, max, step, v => `$${Number.isInteger(Math.round(v * 100) / 100) ? v.toFixed(0) : v.toFixed(v < 1 ? 2 : 1)}`) + ax
803 let peak = -1
804 bs.forEach((b: any, k: number) => {
805 const t = tot(b)
806 if (t <= 0) return
807 if (peak < 0 || t > tot(bs[peak])) peak = k
808 s += stack(cx(k), bw * 0.66, sy, [
809 { v: b.base, fill: C_BASE },
810 { v: b.rebuild, fill: C_REB },
811 { v: b.kw, fill: C_GOLD },
812 { v: b.cold, fill: C_DARK },
813 ])
814 if (b.kw > 0 || b.avoided > 0) {
815 const y = sy(t - b.kw + b.avoided)
816 s += `<line x1="${cx(k) - bw * 0.45}" y1="${y}" x2="${cx(k) + bw * 0.45}" y2="${y}" stroke="${C_DARK}" stroke-width="1.6" stroke-dasharray="3 2"/>`
817 }
818 })
819 if (peak >= 0) {
820 const x = cx(peak)
821 const anchor = x > g.x1 - 30 ? 'end' : x < g.x0 + 30 ? 'start' : 'middle'
822 s += `<text x="${x}" y="${sy(tot(bs[peak])) - 6}" text-anchor="${anchor}" class="hl" font-size="11" font-weight="600">${usd(tot(bs[peak]))}</text>`
823 }
824 s += legend(g.h, [['band', C_BASE, '正常对话'], ['band', C_REB, '压缩或换模型后重建'], ['dash', C_DARK, '不保温时估算']])
825 return doc(g.w, g.h, s)
826}
827
828// 汇总图三:每格最长上下文(柱)与中位数(圆点),柱顶三角为压缩次数
829function aggCtx(a: any) {
830 const g: G = { w: 480, h: 340, x0: 46, x1: 464, y0: 70, y1: 256 }
831 const bs = a.b
832 const { s: ax, bw, cx } = barAxis(g, a.unit, a.edges)
833 const top = bs.reduce((m: number, b: any) => Math.max(m, b.ctxMax), 0)
834 const { max, step } = nice(Math.max(top, compactAt) * 1.12)
835 const sy = (v: number) => g.y1 - (v / max) * (g.y1 - g.y0)
836 const comps = sumOf(bs, 'compA') + sumOf(bs, 'compM')
837 let s = head('上下文长度', `全部项目 · ${SPANS[a.unit].desc};共压缩 ${comps} 次`) + yAxis(g, max, step, mfmt) + ax
838 const w = bw * 0.66
839 bs.forEach((b: any, k: number) => {
840 if (b.ctxMax <= 0) return
841 const x = cx(k)
842 s += `<rect x="${x - w / 2}" y="${sy(b.ctxMax)}" width="${w}" height="${g.y1 - sy(b.ctxMax)}" fill="url(#warm)"/>`
843 s += `<line x1="${x - w / 2}" y1="${sy(b.ctxMax)}" x2="${x + w / 2}" y2="${sy(b.ctxMax)}" stroke="${C_MAIN}" stroke-width="2"/>`
844 s += `<circle cx="${x}" cy="${sy(b.ctxMed)}" r="${Math.min(3.4, bw * 0.22)}" fill="${C_DARK}" class="ring" stroke-width="1"/>`
845 const n = b.compA + b.compM
846 if (n > 0) {
847 const y = sy(b.ctxMax) - 3
848 s += `<path d="M${x - 4} ${y - 6}H${x + 4}L${x} ${y}Z" fill="${b.compA > 0 ? C_DARK : C_TEXT}"/>`
849 if (bw >= 12) s += `<text x="${x}" y="${y - 9}" text-anchor="middle" class="hl" font-size="9.5" font-weight="600">${n}</text>`
850 }
851 })
852 // 压缩标记都挤在阈值线上方,阈值文字改放线下
853 s += hline(g, sy(compactAt), C_GOLD, `自动压缩 ${kfmt(compactAt)}`, true)
854 s += legend(g.h, [['area', C_MAIN, '最长上下文'], ['dot', C_DARK, '中位数'], ['tri', C_DARK, '压缩次数'], ['dash', C_GOLD, '压缩阈值']])
855 return doc(g.w, g.h, s)
856}
857
858// 7 天档的累计图用逐小时细格,柱状图仍按天
859function hourlyEdges(t0: number, t1: number) {
860 const out: number[] = []
861 const x = new Date(t0)
862 while (x.getTime() <= t1) {
863 out.push(x.getTime())
864 x.setHours(x.getHours() + 1)
865 }
866 return out
867}
868
869// 累计图横轴:按小时的档每 3 小时一个刻度、换日处加日期;7 天档在每天零点标日期;其余档同柱状图
870function cumAxis(g: G, unit: string, edges: number[], sx: (t: number) => number) {
871 const nb = edges.length - 1
872 const idx: number[] = []
873 if (unit === 'd7') {
874 for (let k = 0; k <= nb; k++) if (new Date(edges[k]).getHours() === 0) idx.push(k)
875 } else {
876 const every = Math.ceil(nb / 8)
877 for (let k = 0; k <= nb; k += every) idx.push(k)
878 }
879 let s = ''
880 let sub = ''
881 for (const k of idx) {
882 const t = edges[k]
883 const x = sx(t)
884 const d = new Date(t)
885 const main = unit === 'hour' ? `${p2(d.getHours())}时` : unit === 'month' ? `${d.getMonth() + 1}月` : md(t)
886 s += `<line x1="${x}" y1="${g.y1}" x2="${x}" y2="${g.y1 + 4}" class="ax"/>`
887 s += `<text x="${x}" y="${g.y1 + 17}" text-anchor="middle" class="s" font-size="11">${main}</text>`
888 const second = unit === 'hour' ? md(t) : unit === 'month' ? `${d.getFullYear()}` : ''
889 if (second && second !== sub) {
890 sub = second
891 s += `<text x="${x}" y="${g.y1 + 31}" text-anchor="middle" class="s" font-size="10.5" font-weight="600">${second}</text>`
892 }
893 }
894 return s
895}
896
897// 累计图:费用(kind=cost)或缓存写入(kind=write),从范围起点累加到现在,自下而上分层与本会话费用图一致
898function aggCum(a: any, kind: string) {
899 const f = a.fine || a
900 const now = a.at
901 const isCost = kind === 'cost'
902 const g: G = isCost ? { w: 480, h: 354, x0: 46, x1: 396, y0: 124, y1: 276 } : { w: 480, h: 340, x0: 46, x1: 396, y0: 70, y1: 256 }
903 const e0 = f.edges[0]
904 const eN = f.edges[f.edges.length - 1]
905 const sx = (t: number) => g.x0 + ((t - e0) / (eN - e0)) * (g.x1 - g.x0)
906 const K = isCost ? ['base', 'rebuild', 'kw', 'cold'] : ['cwHit', 'cwRe', 'cwKw', 'cwIdle']
907 const acc = [0, 0, 0, 0]
908 let kwAcc = 0
909 let avAcc = 0
910 const pts: any[] = [{ t: e0, s1: 0, s2: 0, s3: 0, s4: 0, cf: 0, cold: 0 }]
911 f.b.forEach((b: any, k: number) => {
912 if (f.edges[k] >= now) return
913 K.forEach((key, j) => (acc[j] += b[key]))
914 kwAcc += b.kw
915 avAcc += b.avoided
916 const s4 = acc[0] + acc[1] + acc[2] + acc[3]
917 pts.push({ t: Math.min(f.edges[k + 1], now), s1: acc[0], s2: acc[0] + acc[1], s3: acc[0] + acc[1] + acc[2], s4, cf: s4 - kwAcc + avAcc, cold: b.coldN })
918 })
919 const last = pts[pts.length - 1]
920 const showCf = isCost && (kwAcc > 0 || avAcc > 0)
921 const { max, step } = nice(Math.max(last.s4, showCf ? last.cf : 0, isCost ? 0.01 : 1000) * 1.08)
922 const sy = (v: number) => g.y1 - (v / max) * (g.y1 - g.y0)
923 const line = (key: string) => pts.map((p: any) => `${sx(p.t).toFixed(1)},${sy(p[key]).toFixed(1)}`)
924 const band = (lo: string, hi: string, fill: string) => {
925 const lower = lo ? line(lo).reverse() : [`${sx(last.t).toFixed(1)},${g.y1}`, `${sx(e0).toFixed(1)},${g.y1}`]
926 return `<polygon points="${line(hi).join(' ')} ${lower.join(' ')}" fill="${fill}"/>`
927 }
928 const fmt = isCost ? usd : mfmt
929 let s = ''
930 if (isCost) {
931 s += head('费用构成', `全部项目 · ${SPANS[a.unit].name}累计;列表价估算,合计 ${usd(last.s4)}`)
932 s += kpiRow(acc[3], sumOf(f.b, 'coldN'), acc[2], sumOf(f.b, 'kwN'), avAcc)
933 s += yAxis(g, max, step, v => `$${Number.isInteger(Math.round(v * 100) / 100) ? v.toFixed(0) : v.toFixed(v < 1 ? 2 : 1)}`)
934 } else {
935 const share = last.s4 > 0 ? Math.round((100 * (acc[1] + acc[3])) / last.s4) : 0
936 s += head('缓存写入', `全部项目 · ${SPANS[a.unit].name}累计;整段重写占写入 ${share}%`) + yAxis(g, max, step, mfmt)
937 }
938 s += cumAxis(g, a.unit, f.edges, sx)
939 s += band('', 's1', 'url(#warm)')
940 if (acc[1] > 0) s += band('s1', 's2', C_REB)
941 if (acc[2] > 0) s += band('s2', 's3', C_GOLD)
942 if (acc[3] > 0) s += band('s3', 's4', C_DARK)
943 s += `<polyline fill="none" stroke="${C_MAIN}" stroke-width="2.2" stroke-linejoin="round" points="${line('s4').join(' ')}"/>`
944 const labels = [{ y: sy(last.s4), text: `${isCost ? '总' : '共'} ${fmt(last.s4)}`, color: C_MAIN }]
945 if (showCf) {
946 const k = Math.max(0, pts.findIndex((p: any) => Math.abs(p.cf - p.s4) > 0.005) - 1)
947 s += `<polyline fill="none" stroke="${C_DARK}" stroke-width="1.6" stroke-dasharray="5 3" stroke-linejoin="round" points="${line('cf').slice(k).join(' ')}"/>`
948 labels.push({ y: sy(last.cf), text: `不保温 ${usd(last.cf)}`, color: C_DARK })
949 }
950 // 有冷读取的时段在总量线上打点
951 for (const p of pts) if (p.cold > 0) s += `<circle cx="${sx(p.t)}" cy="${sy(p.s4)}" r="3.2" fill="${C_DARK}" class="ring" stroke-width="1.2"/>`
952 // 现在:累计线停在这里,右侧是范围内尚未到来的时间
953 if (now < eN) {
954 s += `<line x1="${sx(now)}" y1="${g.y0}" x2="${sx(now)}" y2="${g.y1}" class="ax" stroke-dasharray="2 3"/>`
955 }
956 s += endLabels(g, labels)
957 s += isCost
958 ? legend(g.h, [['area', C_MAIN, '正常对话'], ['band', C_REB, '压缩或换模型后重建'], ['dash', C_DARK, '不保温时估算']])
959 : legend(g.h, [['area', C_MAIN, '对话增长'], ['band', C_REB, '重建'], ['band', C_GOLD, '保温'], ['band', C_DARK, '冷读取重写'], ['dot', C_DARK, '有冷读取']])
960 return doc(g.w, g.h, s)
961}
962
963function aggTable(a: any) {
964 let t = '| 时段 | 请求 | 费用 | 冷读取 |\n|:--|--:|--:|--:|\n'
965 const rows = a.b.map((b: any, k: number) => ({ b, t: a.edges[k] })).filter((r: any) => r.b.n > 0).slice(-8).reverse()
966 for (const r of rows) t += `| ${binName(a.unit, r.t)} | ${r.b.n} | ${usd(tot(r.b))} | ${r.b.coldN} |\n`
967 return rows.length ? t : '这段时间没有请求记录。'
968}
969
970function resetLabel(iso: string | undefined, now: number) {
971 const t = iso ? Date.parse(iso) : NaN
972 if (!Number.isFinite(t)) return ''
973 return t - now < 20 * HOUR ? `${hm(t)} 重置` : `${md(t)} ${hm(t)} 重置`
974}
975
976// 顶部四格:上下文、缓存剩余、5 小时额度、本周额度
977function tiles(d: any, now: number, u: any) {
978 const rs = d.reqs
979 const last = rs.length ? rs[rs.length - 1] : null
980 const ctx = (u && u.context && u.context.tokens) || (last ? last.ctx : 0)
981 const left = last ? Math.ceil((last.t + HOUR - now) / 60000) : 0
982 const rl = (u && u.rateLimits) || []
983 const quota = (label: string, kind: string) => {
984 const r = rl.find((x: any) => x.kind === kind)
985 if (!r) return { label, value: '暂无读数', aside: '下次回复后更新', frac: 0, color: C_MAIN }
986 const rest = Math.max(0, 100 - r.percentUsed)
987 return { label, value: `剩 ${Math.round(rest)}%`, aside: resetLabel(r.resetsAt, now), frac: rest / 100, color: rest < 10 ? C_RED : C_MAIN }
988 }
989 return [
990 { label: '上下文', value: kfmt(ctx), aside: `压缩线 ${kfmt(compactAt)}`, frac: ctx / compactAt, color: ctx > 0.9 * compactAt ? C_RED : C_MAIN },
991 {
992 label: hours > 0 ? '缓存剩余 · 接续保温中' : '缓存剩余',
993 value: left > 0 ? `${left} 分钟` : '已过期',
994 aside: last ? (left > 0 ? `${hm(last.t + HOUR)} 到期` : '下次请求将冷读取') : '',
995 frac: Math.max(0, left) / 60,
996 color: left <= 10 ? C_RED : C_MAIN,
997 },
998 quota('5 小时额度', 'five_hour'),
999 quota('本周额度', 'seven_day'),
1000 ]
1001}
1002
1003function svgTiles(ts: any[]) {
1004 const w = 480
1005 const gap = 10
1006 const tw = (w - gap) / 2
1007 const th = 84
1008 const rows = Math.ceil(ts.length / 2)
1009 const h = rows * th + (rows - 1) * gap
1010 let s = ''
1011 ts.forEach((t: any, k: number) => {
1012 const x = (k % 2) * (tw + gap)
1013 const y = Math.floor(k / 2) * (th + gap)
1014 const bw = tw - 32
1015 const f = Math.max(0, Math.min(1, t.frac))
1016 s += `<rect x="${x}" y="${y}" width="${tw}" height="${th}" rx="12" class="bg"/>`
1017 s += `<text x="${x + 16}" y="${y + 25}" class="s">${t.label}</text>`
1018 s += `<text x="${x + 16}" y="${y + 54}" class="p" font-size="22" font-weight="600">${t.value}</text>`
1019 s += `<text x="${x + tw - 16}" y="${y + 54}" text-anchor="end" class="s" font-size="11.5">${t.aside}</text>`
1020 s += `<rect x="${x + 16}" y="${y + 66}" width="${bw}" height="6" rx="3" class="tr"/>`
1021 if (f > 0) s += `<rect x="${x + 16}" y="${y + 66}" width="${Math.max(6, bw * f)}" height="6" rx="3" fill="${t.color}"/>`
1022 })
1023 return doc(w, h, s, false)
1024}
1025
1026// 明细只留四列,窄面板也不出横向滚动
1027function recentTable(d: any) {
1028 let t = '| 时间 | 类型 | 上下文 | 费用 |\n|:--|:--|--:|--:|\n'
1029 for (const q of d.reqs.slice(-8).reverse()) t += `| ${hm(q.t)} | ${KIND_NAME[q.kind] || q.kind} | ${kfmt(q.ctx)} | ${usd(q.cost)} |\n`
1030 return t
1031}
1032
1033function summaryLines(d: any, now: number, u: any) {
1034 return tiles(d, now, u).map((t: any) => `${t.label}:${t.value}${t.aside ? `(${t.aside})` : ''}`)
1035}
1036
1037// ---------- 带 $ 的函数 ----------
1038async function loadData($: any) {
1039 loadError = ''
1040 try {
1041 const sid = await $.session.id()
1042 const r = await $.process.run(['python', '-c', PY, sid], { timeoutMs: 60000 })
1043 if (r.exitCode !== 0) throw new Error((r.stderr || '').slice(0, 200) || `退出码 ${r.exitCode}`)
1044 data = prep(JSON.parse(r.stdout))
1045 } catch (err: any) {
1046 data = null
1047 loadError = String(err && err.message ? err.message : err)
1048 }
1049 try {
1050 const u = await $.session.usage({ breakdown: 'summary' })
1051 const th = u && u.context && u.context.breakdown ? u.context.breakdown.autoCompactThreshold : undefined
1052 if (typeof th === 'number' && th > 0) compactAt = th
1053 } catch (err) {
1054 // 读不到阈值时沿用后备值
1055 }
1056}
1057
1058// 长期汇总:扫描全部项目的会话记录(只取用量与压缩元数据),按时段分格;五分钟内重复查看直接用上次结果
1059async function loadAgg($: any, force = false) {
1060 const want = span
1061 if (want === 'ses' || aggBusy) return
1062 const now = await $.clock.now()
1063 if (!force && agg && agg.unit === want && now - aggAt < 5 * 60000) return
1064 aggBusy = true
1065 aggError = ''
1066 $.ui.invalidate('ui.render')
1067 try {
1068 const kw: number[] = []
1069 for (const k of (await $.store.keys()).filter((k: string) => k.startsWith('kw:'))) {
1070 const v = await $.store.get(k)
1071 if (Array.isArray(v)) for (const x of v) if (typeof x === 'number') kw.push(x)
1072 }
1073 const edges = edgesOf(want, now)
1074 const main = await runAgg($, edges, kw)
1075 const fineEdges = want === 'd7' ? hourlyEdges(edges[0], edges[edges.length - 1]) : null
1076 const fine = fineEdges ? { ...(await runAgg($, fineEdges, kw)), edges: fineEdges } : null
1077 agg = { ...main, edges, unit: want, at: now, fine }
1078 aggAt = now
1079 } catch (err: any) {
1080 agg = null
1081 aggError = String(err && err.message ? err.message : err)
1082 }
1083 aggBusy = false
1084 $.ui.invalidate('ui.render')
1085 if (span !== want && span !== 'ses') void loadAgg($)
1086}
1087
1088async function runAgg($: any, edges: number[], kw: number[]) {
1089 const r = await $.process.run(['python', '-c', AGG_PY], { stdin: JSON.stringify({ edges, kw }), timeoutMs: 300000 })
1090 if (r.exitCode !== 0) throw new Error((r.stderr || '').slice(0, 200) || `退出码 ${r.exitCode}`)
1091 return JSON.parse(r.stdout)
1092}
1093
1094async function readUsage($: any) {
1095 try {
1096 return await $.session.usage()
1097 } catch (err) {
1098 return null
1099 }
1100}
1101
1102// 面板开着时每轮结束后重读记录,图与明细跟着更新
1103async function refreshIfOpen($: any) {
1104 if (!paneOpen) return
1105 await loadData($)
1106 $.ui.invalidate('ui.render')
1107 void loadAgg($)
1108}
1109
1110async function openChart($: any, force = false) {
1111 await loadData($)
1112 paneOpen = true
1113 await $.ui.open({ id: PANE, title: '保温图表', closeOnEscape: true })
1114 $.ui.invalidate('ui.render')
1115 void loadAgg($, force)
1116}
1117
1118// 保温时刻存进插件自己的持久存储,重载后仍能在图上认出保温请求;只记时刻,不读对话内容
1119async function loadKw($: any) {
1120 try {
1121 const v = await $.store.get(`kw:${await $.session.id()}`)
1122 if (Array.isArray(v)) kwTimes = v.filter((x: unknown) => typeof x === 'number')
1123 } catch (err) {
1124 // 读不到时只影响图上的保温标记
1125 }
1126}
1127
1128async function saveKw($: any) {
1129 try {
1130 await $.store.set(`kw:${await $.session.id()}`, kwTimes)
1131 const keys = (await $.store.keys()).filter((k: string) => k.startsWith('kw:'))
1132 for (const k of keys.slice(0, Math.max(0, keys.length - 30))) await $.store.delete(k)
1133 } catch (err) {
1134 // 存不下时只影响图上的保温标记
1135 }
1136}
1137
1138// 重载或撤回后模块变量会清空:用记录里最近一次请求的时刻接上,读不到就当作刚活动过
1139async function initLast($: any) {
1140 await loadKw($)
1141 await loadData($)
1142 const rs = data ? data.reqs : []
1143 lastAt = rs.length ? rs[rs.length - 1].t : await $.clock.now()
1144 $.ui.invalidate('ui.render')
1145}
1146
1147async function fireKeepWarm($: any) {
1148 // 发出前先记时刻:插件自己的 $.prompt.submit 不经过本插件的 prompt.submit 钩子(claude plugin test 实测)
1149 const t = await $.clock.now()
1150 kwTimes = [...kwTimes, t].slice(-200)
1151 void saveKw($)
1152 try {
1153 await $.prompt.submit({ text: PROMPT, asUser: true })
1154 } catch (err) {
1155 kwTimes = kwTimes.filter(k => k !== t)
1156 void saveKw($)
1157 $.ui.toast(`保温请求未发出:${String((err as any)?.message || err).slice(0, 80)}`)
1158 }
1159}
1160
1161async function everyMinute($: any) {
1162 if (lastAt === null) return
1163 const t = await $.clock.now()
1164 const idle = Math.floor((t - lastAt) / 60000)
1165 if (hours > 0 && lastReal !== null) {
1166 if (t >= lastReal + hours * 3600000) {
1167 hours = 0
1168 $.ui.toast(`接续保温已到时限并停止,共发送 ${sent} 次`)
1169 } else if (!busy && idle >= FIRE_MIN) {
1170 sent += 1
1171 void fireKeepWarm($)
1172 }
1173 }
1174 const left = lastReal === null ? 0 : Math.max(0, Math.ceil((lastReal + hours * 3600000 - t) / 60000))
1175 $.ui.status(hours > 0 ? `保温中,剩 ${Math.floor(left / 60)}h${left % 60}m,已发 ${sent} 次` : idle >= 15 ? `缓存闲置 ${idle} 分` : undefined)
1176 $.ui.invalidate('ui.render')
1177}
1178
1179export const register: Register = on => {
1180 const leftMin = (t: number) => (lastReal === null ? 0 : Math.max(0, Math.ceil((lastReal + hours * 3600000 - t) / 60000)))
1181
1182 on('session.start', async ($, e, next) => {
1183 await $.command.register({
1184 name: 'keepwarm',
1185 description: '/keepwarm 发一次保温;/keepwarm 8 接续保温 8 小时(上限 24);/keepwarm off 停止;/keepwarm chart 打开图表',
1186 })
1187 $.clock.every(60000, () => {
1188 void everyMinute($)
1189 })
1190 void initLast($)
1191 return next(e)
1192 })
1193
1194 on('command.run', { command: 'keepwarm' }, async ($, e) => {
1195 const arg = e.args.trim().toLowerCase()
1196 if (arg === '') {
1197 // 命令钩子里直接提交会被宿主拒绝(会等待本钩子占着的回合),改为命令返回后再发
1198 $.clock.after(500, () => {
1199 void fireKeepWarm($)
1200 })