tuntu 0.1.0__py3-none-any.whl
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.
- tuntu/__init__.py +3 -0
- tuntu/__main__.py +3 -0
- tuntu/cli.py +496 -0
- tuntu/web/index.html +611 -0
- tuntu-0.1.0.dist-info/METADATA +134 -0
- tuntu-0.1.0.dist-info/RECORD +10 -0
- tuntu-0.1.0.dist-info/WHEEL +5 -0
- tuntu-0.1.0.dist-info/entry_points.txt +2 -0
- tuntu-0.1.0.dist-info/licenses/LICENSE +21 -0
- tuntu-0.1.0.dist-info/top_level.txt +1 -0
tuntu/__init__.py
ADDED
tuntu/__main__.py
ADDED
tuntu/cli.py
ADDED
|
@@ -0,0 +1,496 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""吞吐 (tuntu) — 本机 Claude Code token 用量看板。
|
|
3
|
+
|
|
4
|
+
只用 Python 标准库。读取本机的 Claude Code 会话日志,在 localhost 上
|
|
5
|
+
打开一个看板页面。数据不会离开这台电脑。
|
|
6
|
+
|
|
7
|
+
tuntu # 扫描并打开看板
|
|
8
|
+
tuntu --demo # 用虚构数据演示(适合截图)
|
|
9
|
+
|
|
10
|
+
从源码目录运行时用 python3 -m tuntu。
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
import argparse
|
|
14
|
+
import collections
|
|
15
|
+
import datetime as dt
|
|
16
|
+
import glob
|
|
17
|
+
import json
|
|
18
|
+
import os
|
|
19
|
+
import random
|
|
20
|
+
import re
|
|
21
|
+
import sys
|
|
22
|
+
import threading
|
|
23
|
+
import webbrowser
|
|
24
|
+
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
25
|
+
from urllib.parse import parse_qs, urlparse
|
|
26
|
+
|
|
27
|
+
from . import __version__ as VERSION
|
|
28
|
+
|
|
29
|
+
HERE = os.path.dirname(os.path.abspath(__file__))
|
|
30
|
+
CTX_BUCKETS = 96 # 会话详情图最多画多少根柱子
|
|
31
|
+
SCRATCH_KEY = "\x00scratch"
|
|
32
|
+
|
|
33
|
+
# 一条请求记录的字段下标
|
|
34
|
+
T, CWD, MODEL, SIDE, IN, OUT, CW, CR, H1, EFFORT = range(10)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
# ---------------------------------------------------------------- 读取日志
|
|
38
|
+
|
|
39
|
+
def find_roots(custom=None):
|
|
40
|
+
"""返回存在的日志根目录列表。"""
|
|
41
|
+
if custom:
|
|
42
|
+
cands = [custom]
|
|
43
|
+
else:
|
|
44
|
+
cands = []
|
|
45
|
+
env = os.environ.get("CLAUDE_CONFIG_DIR")
|
|
46
|
+
if env:
|
|
47
|
+
cands += [os.path.join(p.strip(), "projects") for p in env.split(",") if p.strip()]
|
|
48
|
+
cands += ["~/.claude/projects", "~/.config/claude/projects"]
|
|
49
|
+
roots = []
|
|
50
|
+
for c in cands:
|
|
51
|
+
p = os.path.abspath(os.path.expanduser(c))
|
|
52
|
+
if os.path.isdir(p) and p not in roots:
|
|
53
|
+
roots.append(p)
|
|
54
|
+
return roots
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def tidy_home(path):
|
|
58
|
+
"""把家目录换成 ~,页面上不显示用户名。"""
|
|
59
|
+
home = os.path.expanduser("~")
|
|
60
|
+
if path == home or path.startswith(home + os.sep):
|
|
61
|
+
return "~" + path[len(home):]
|
|
62
|
+
return path
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def project_key(cwd):
|
|
66
|
+
"""把工作目录归并成项目:去掉 worktree 后缀,临时会话合并成一项。"""
|
|
67
|
+
if not cwd:
|
|
68
|
+
return "?"
|
|
69
|
+
p = cwd.replace("\\", "/").rstrip("/")
|
|
70
|
+
if "/scratch-workspaces/" in p:
|
|
71
|
+
return SCRATCH_KEY
|
|
72
|
+
for marker in ("/.claude/worktrees/", "/.claude-worktrees/"):
|
|
73
|
+
i = p.find(marker)
|
|
74
|
+
if i > 0:
|
|
75
|
+
p = p[:i]
|
|
76
|
+
return p or "/"
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def encode_path(path):
|
|
80
|
+
"""Claude Code 给项目目录起名的方式:非字母数字的字符都换成 -。"""
|
|
81
|
+
return re.sub(r"[^A-Za-z0-9]", "-", path)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def resolve_dirs(cwd_seen):
|
|
85
|
+
"""把日志目录名还原成项目路径。
|
|
86
|
+
|
|
87
|
+
会话中途 cd 到子目录时 cwd 会变,所以项目按日志目录归,而不是按每条
|
|
88
|
+
记录的 cwd 归;目录名里的 - 有歧义,用记录里出现过的 cwd 反查。
|
|
89
|
+
"""
|
|
90
|
+
resolved = {}
|
|
91
|
+
for dirname, cwds in cwd_seen.items():
|
|
92
|
+
for c in cwds:
|
|
93
|
+
if encode_path(c) == dirname:
|
|
94
|
+
resolved[dirname] = c
|
|
95
|
+
break
|
|
96
|
+
by_enc = {encode_path(project_key(c)): project_key(c) for c in resolved.values()}
|
|
97
|
+
home_enc = encode_path(os.path.expanduser("~"))
|
|
98
|
+
out = {}
|
|
99
|
+
for dirname in cwd_seen:
|
|
100
|
+
if dirname in resolved:
|
|
101
|
+
out[dirname] = project_key(resolved[dirname])
|
|
102
|
+
continue
|
|
103
|
+
parent = re.split(r"--claude-worktrees-|-claude-worktrees-", dirname)[0]
|
|
104
|
+
if parent in by_enc:
|
|
105
|
+
out[dirname] = by_enc[parent]
|
|
106
|
+
elif "-scratch-workspaces-" in dirname:
|
|
107
|
+
out[dirname] = SCRATCH_KEY
|
|
108
|
+
else:
|
|
109
|
+
name = parent[len(home_enc) + 1:] if parent.startswith(home_enc + "-") else parent
|
|
110
|
+
out[dirname] = "/" + (name or parent)
|
|
111
|
+
return out
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def parse_file(path, force_side, recs, tools, proj, cwds):
|
|
115
|
+
"""解析一个 jsonl 文件,把请求写进 recs,把工具调用写进 tools。"""
|
|
116
|
+
try:
|
|
117
|
+
fh = open(path, "r", encoding="utf-8", errors="ignore")
|
|
118
|
+
except OSError:
|
|
119
|
+
return
|
|
120
|
+
with fh:
|
|
121
|
+
for line in fh:
|
|
122
|
+
if '"usage"' not in line:
|
|
123
|
+
continue
|
|
124
|
+
try:
|
|
125
|
+
d = json.loads(line)
|
|
126
|
+
except ValueError:
|
|
127
|
+
continue
|
|
128
|
+
m = d.get("message")
|
|
129
|
+
if not isinstance(m, dict):
|
|
130
|
+
continue
|
|
131
|
+
u = m.get("usage")
|
|
132
|
+
if not isinstance(u, dict):
|
|
133
|
+
continue
|
|
134
|
+
model = m.get("model") or "unknown"
|
|
135
|
+
if model == "<synthetic>":
|
|
136
|
+
continue
|
|
137
|
+
ts = d.get("timestamp")
|
|
138
|
+
if not isinstance(ts, str):
|
|
139
|
+
continue
|
|
140
|
+
try:
|
|
141
|
+
t = dt.datetime.fromisoformat(ts.replace("Z", "+00:00")).astimezone()
|
|
142
|
+
except ValueError:
|
|
143
|
+
continue
|
|
144
|
+
cwd = d.get("cwd")
|
|
145
|
+
if isinstance(cwd, str) and len(cwds) < 500:
|
|
146
|
+
cwds.add(cwd)
|
|
147
|
+
key = (m.get("id") or d.get("uuid"), d.get("requestId"))
|
|
148
|
+
cc = u.get("cache_creation")
|
|
149
|
+
h1 = cc.get("ephemeral_1h_input_tokens") if isinstance(cc, dict) else 0
|
|
150
|
+
rec = [
|
|
151
|
+
t,
|
|
152
|
+
proj,
|
|
153
|
+
model,
|
|
154
|
+
bool(force_side or d.get("isSidechain")),
|
|
155
|
+
u.get("input_tokens") or 0,
|
|
156
|
+
u.get("output_tokens") or 0,
|
|
157
|
+
u.get("cache_creation_input_tokens") or 0,
|
|
158
|
+
u.get("cache_read_input_tokens") or 0,
|
|
159
|
+
h1 or 0,
|
|
160
|
+
d.get("perTurnEffort") or d.get("effort") or None,
|
|
161
|
+
]
|
|
162
|
+
old = recs.get(key)
|
|
163
|
+
if old is not None:
|
|
164
|
+
# 同一条消息会按内容块写多行,用量取最大的那次
|
|
165
|
+
for i in (IN, OUT, CW, CR, H1):
|
|
166
|
+
rec[i] = max(rec[i], old[i])
|
|
167
|
+
rec[T] = min(rec[T], old[T])
|
|
168
|
+
recs[key] = rec
|
|
169
|
+
content = m.get("content")
|
|
170
|
+
if isinstance(content, list):
|
|
171
|
+
for c in content:
|
|
172
|
+
if isinstance(c, dict) and c.get("type") == "tool_use" and c.get("id"):
|
|
173
|
+
name = str(c.get("name") or "?")
|
|
174
|
+
if name.startswith("mcp__"):
|
|
175
|
+
name = name.split("__")[-1] or name
|
|
176
|
+
tools[c["id"]] = (t.strftime("%Y-%m-%d"), name)
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def collect(roots):
|
|
180
|
+
"""扫描所有日志,按会话归组。返回 (sessions, 文件数, 目录名到项目的映射)。"""
|
|
181
|
+
sessions = {}
|
|
182
|
+
cwd_seen = {}
|
|
183
|
+
nfiles = 0
|
|
184
|
+
for root in roots:
|
|
185
|
+
for path in sorted(glob.glob(os.path.join(root, "**", "*.jsonl"), recursive=True)):
|
|
186
|
+
rel = os.path.relpath(path, root).split(os.sep)
|
|
187
|
+
if len(rel) < 2:
|
|
188
|
+
continue
|
|
189
|
+
nfiles += 1
|
|
190
|
+
is_main = len(rel) == 2
|
|
191
|
+
sid = rel[1][:-len(".jsonl")] if is_main else rel[1]
|
|
192
|
+
s = sessions.setdefault(sid, {"recs": {}, "tools": {}})
|
|
193
|
+
parse_file(path, not is_main, s["recs"], s["tools"], rel[0], cwd_seen.setdefault(rel[0], set()))
|
|
194
|
+
return sessions, nfiles, resolve_dirs(cwd_seen)
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
# ---------------------------------------------------------------- 聚合
|
|
198
|
+
|
|
199
|
+
def aggregate(sessions, meta, dirmap=None):
|
|
200
|
+
"""把会话记录聚合成页面要用的数据。返回 (data, details)。"""
|
|
201
|
+
seen = set()
|
|
202
|
+
tool_seen = set()
|
|
203
|
+
buckets = collections.defaultdict(lambda: [0, 0, 0, 0, 0, 0])
|
|
204
|
+
tools = collections.Counter()
|
|
205
|
+
proj_ids, model_ids = {}, {}
|
|
206
|
+
sess_out, details = [], {}
|
|
207
|
+
|
|
208
|
+
dirmap = dirmap or {}
|
|
209
|
+
|
|
210
|
+
def pid(key):
|
|
211
|
+
key = dirmap.get(key, key)
|
|
212
|
+
return proj_ids.setdefault(key, len(proj_ids))
|
|
213
|
+
|
|
214
|
+
def mid(key):
|
|
215
|
+
return model_ids.setdefault(key, len(model_ids))
|
|
216
|
+
|
|
217
|
+
for sid in sorted(sessions):
|
|
218
|
+
s = sessions[sid]
|
|
219
|
+
recs = s["recs"]
|
|
220
|
+
if not recs:
|
|
221
|
+
continue
|
|
222
|
+
tok = [0, 0, 0, 0]
|
|
223
|
+
sub_req = sub_tok = 0
|
|
224
|
+
by_proj, by_model = collections.Counter(), collections.Counter()
|
|
225
|
+
main = []
|
|
226
|
+
for key, r in recs.items():
|
|
227
|
+
total = r[IN] + r[OUT] + r[CW] + r[CR]
|
|
228
|
+
for i, f in enumerate((IN, OUT, CW, CR)):
|
|
229
|
+
tok[i] += r[f]
|
|
230
|
+
by_proj[r[CWD]] += total
|
|
231
|
+
by_model[r[MODEL]] += total
|
|
232
|
+
if r[SIDE]:
|
|
233
|
+
sub_req += 1
|
|
234
|
+
sub_tok += total
|
|
235
|
+
else:
|
|
236
|
+
main.append(r)
|
|
237
|
+
# 续接或分叉的会话会带着前半段记录;总量只算一次
|
|
238
|
+
if key in seen:
|
|
239
|
+
continue
|
|
240
|
+
seen.add(key)
|
|
241
|
+
b = buckets[(r[T].strftime("%Y-%m-%d"), r[T].hour, pid(r[CWD]), mid(r[MODEL]),
|
|
242
|
+
1 if r[SIDE] else 0, r[EFFORT])]
|
|
243
|
+
b[0] += r[IN]
|
|
244
|
+
b[1] += r[OUT]
|
|
245
|
+
b[2] += r[CW]
|
|
246
|
+
b[3] += r[CR]
|
|
247
|
+
b[4] += r[H1]
|
|
248
|
+
b[5] += 1
|
|
249
|
+
for tid, (date, name) in s["tools"].items():
|
|
250
|
+
if tid in tool_seen:
|
|
251
|
+
continue
|
|
252
|
+
tool_seen.add(tid)
|
|
253
|
+
tools[(date, name)] += 1
|
|
254
|
+
|
|
255
|
+
main.sort(key=lambda r: r[T])
|
|
256
|
+
ctx = [r[IN] + r[CW] + r[CR] for r in main]
|
|
257
|
+
drops = [i for i in range(1, len(ctx)) if ctx[i - 1] > 20000 and ctx[i] < ctx[i - 1] * 0.5]
|
|
258
|
+
times = [r[T] for r in recs.values()]
|
|
259
|
+
sess_out.append({
|
|
260
|
+
"id": sid,
|
|
261
|
+
"p": pid(by_proj.most_common(1)[0][0]),
|
|
262
|
+
"m": mid(by_model.most_common(1)[0][0]),
|
|
263
|
+
"start": min(times).strftime("%Y-%m-%dT%H:%M"),
|
|
264
|
+
"end": max(times).strftime("%Y-%m-%dT%H:%M"),
|
|
265
|
+
"tok": tok,
|
|
266
|
+
"req": len(recs),
|
|
267
|
+
"subReq": sub_req,
|
|
268
|
+
"subTok": sub_tok,
|
|
269
|
+
"peak": max(ctx) if ctx else 0,
|
|
270
|
+
"drops": len(drops),
|
|
271
|
+
})
|
|
272
|
+
details[sid] = downsample(ctx, drops)
|
|
273
|
+
|
|
274
|
+
data = {
|
|
275
|
+
"meta": meta,
|
|
276
|
+
"projects": project_names(proj_ids),
|
|
277
|
+
"models": [k for k, _ in sorted(model_ids.items(), key=lambda kv: kv[1])],
|
|
278
|
+
"buckets": sorted([list(k) + v for k, v in buckets.items()], key=lambda b: (b[0], b[1])),
|
|
279
|
+
"tools": sorted([[d, n, c] for (d, n), c in tools.items()]),
|
|
280
|
+
"sessions": sess_out,
|
|
281
|
+
}
|
|
282
|
+
data["meta"]["sessions"] = len(sess_out)
|
|
283
|
+
return data, details
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def downsample(ctx, drops):
|
|
287
|
+
"""把逐次请求的上下文长度压成最多 CTX_BUCKETS 根柱子(取每段最大值)。"""
|
|
288
|
+
n = len(ctx)
|
|
289
|
+
if n == 0:
|
|
290
|
+
return {"n": 0, "per": 1, "ctx": [], "drop": []}
|
|
291
|
+
per = max(1, -(-n // CTX_BUCKETS))
|
|
292
|
+
dropset = set(drops)
|
|
293
|
+
out, flags = [], []
|
|
294
|
+
for i in range(0, n, per):
|
|
295
|
+
out.append(max(ctx[i:i + per]))
|
|
296
|
+
flags.append(1 if any(j in dropset for j in range(i, min(n, i + per))) else 0)
|
|
297
|
+
return {"n": n, "per": per, "ctx": out, "drop": flags}
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def project_names(proj_ids):
|
|
301
|
+
"""给每个项目起显示名:只用目录名,重名时带上上一级目录。"""
|
|
302
|
+
keys = [k for k, _ in sorted(proj_ids.items(), key=lambda kv: kv[1])]
|
|
303
|
+
base = collections.Counter(os.path.basename(k) for k in keys if k != SCRATCH_KEY)
|
|
304
|
+
home = os.path.expanduser("~").replace("\\", "/")
|
|
305
|
+
names = []
|
|
306
|
+
for k in keys:
|
|
307
|
+
if k == SCRATCH_KEY:
|
|
308
|
+
names.append("临时会话")
|
|
309
|
+
elif k == home:
|
|
310
|
+
names.append("~")
|
|
311
|
+
else:
|
|
312
|
+
b = os.path.basename(k) or k
|
|
313
|
+
if base[b] > 1:
|
|
314
|
+
b = os.path.basename(os.path.dirname(k)) + "/" + b
|
|
315
|
+
names.append(b)
|
|
316
|
+
return names
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
# ---------------------------------------------------------------- 演示数据
|
|
320
|
+
|
|
321
|
+
def demo_sessions(seed=7, days=45):
|
|
322
|
+
"""生成一份虚构的用量记录,结构和真实日志解析后一样。"""
|
|
323
|
+
rnd = random.Random(seed)
|
|
324
|
+
now = dt.datetime.now().astimezone().replace(minute=0, second=0, microsecond=0)
|
|
325
|
+
projects = [("/demo/moon-base", 55), ("/demo/noodle-shop", 25), ("/demo/side-quest", 14), ("/demo/dotfiles", 6)]
|
|
326
|
+
models = [("claude-opus-5-5", 80), ("claude-sonnet-5-5", 16), ("claude-haiku-5-5", 4)]
|
|
327
|
+
efforts = [("high", 50), ("medium", 40), ("low", 10)]
|
|
328
|
+
tool_names = [("Bash", 50), ("Read", 18), ("Edit", 14), ("Grep", 8), ("Write", 5), ("Glob", 3), ("WebFetch", 2)]
|
|
329
|
+
hour_w = [3, 2, 1, 0, 0, 0, 0, 1, 3, 6, 8, 8, 5, 6, 9, 10, 9, 7, 5, 6, 9, 10, 8, 5]
|
|
330
|
+
|
|
331
|
+
def pick(pairs):
|
|
332
|
+
return rnd.choices([p[0] for p in pairs], weights=[p[1] for p in pairs])[0]
|
|
333
|
+
|
|
334
|
+
sessions = {}
|
|
335
|
+
n = 0
|
|
336
|
+
for back in range(days - 1, -1, -1):
|
|
337
|
+
if back and rnd.random() < 0.12:
|
|
338
|
+
continue
|
|
339
|
+
day = (now - dt.timedelta(days=back)).replace(hour=0)
|
|
340
|
+
for _ in range(rnd.randint(1, 4)):
|
|
341
|
+
start = day + dt.timedelta(hours=rnd.choices(range(24), weights=hour_w)[0], minutes=rnd.randint(0, 59))
|
|
342
|
+
if start > now:
|
|
343
|
+
continue
|
|
344
|
+
sid = "demo-%04d" % len(sessions)
|
|
345
|
+
cwd, model = pick(projects), pick(models)
|
|
346
|
+
recs, tools = {}, {}
|
|
347
|
+
t, ctx = start, rnd.randint(15000, 30000)
|
|
348
|
+
for i in range(int(rnd.lognormvariate(4.3, 0.9)) + 5):
|
|
349
|
+
t += dt.timedelta(seconds=rnd.randint(15, 140))
|
|
350
|
+
if t > now:
|
|
351
|
+
break
|
|
352
|
+
n += 1
|
|
353
|
+
side = rnd.random() < 0.07
|
|
354
|
+
grow = rnd.randint(300, 4000)
|
|
355
|
+
if side:
|
|
356
|
+
cr, cw = rnd.randint(8000, 30000), rnd.randint(500, 6000)
|
|
357
|
+
else:
|
|
358
|
+
if ctx > rnd.randint(150000, 190000):
|
|
359
|
+
ctx = rnd.randint(25000, 40000) # 自动压缩
|
|
360
|
+
cr, cw = 0, ctx
|
|
361
|
+
else:
|
|
362
|
+
cr, cw = ctx, grow
|
|
363
|
+
ctx += grow
|
|
364
|
+
recs[("demo", n)] = [t, cwd, model, side, rnd.randint(1, 40), rnd.randint(40, 1800), cw, cr,
|
|
365
|
+
cw if rnd.random() < 0.85 else 0, pick(efforts)]
|
|
366
|
+
for k in range(rnd.choice([0, 1, 1, 1, 2])):
|
|
367
|
+
tools[("demo", n, k)] = (t.strftime("%Y-%m-%d"), pick(tool_names))
|
|
368
|
+
if recs:
|
|
369
|
+
sessions[sid] = {"recs": recs, "tools": tools}
|
|
370
|
+
return sessions
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
# ---------------------------------------------------------------- 服务
|
|
374
|
+
|
|
375
|
+
class App:
|
|
376
|
+
def __init__(self, args):
|
|
377
|
+
self.args = args
|
|
378
|
+
self.lock = threading.Lock()
|
|
379
|
+
self.data = None
|
|
380
|
+
self.details = {}
|
|
381
|
+
|
|
382
|
+
def scan(self):
|
|
383
|
+
with self.lock:
|
|
384
|
+
now = dt.datetime.now().astimezone()
|
|
385
|
+
meta = {"version": VERSION, "generated": now.strftime("%Y-%m-%dT%H:%M"), "demo": bool(self.args.demo)}
|
|
386
|
+
if self.args.demo:
|
|
387
|
+
sessions, nfiles, dirmap = demo_sessions(), 0, None
|
|
388
|
+
meta["root"] = "演示数据"
|
|
389
|
+
else:
|
|
390
|
+
roots = find_roots(self.args.dir)
|
|
391
|
+
sessions, nfiles, dirmap = collect(roots)
|
|
392
|
+
meta["root"] = "、".join(tidy_home(r) for r in roots) or "~/.claude/projects"
|
|
393
|
+
meta["files"] = nfiles
|
|
394
|
+
self.data, self.details = aggregate(sessions, meta, dirmap)
|
|
395
|
+
return self.data
|
|
396
|
+
|
|
397
|
+
|
|
398
|
+
def make_handler(app, page):
|
|
399
|
+
class Handler(BaseHTTPRequestHandler):
|
|
400
|
+
server_version = "tuntu/" + VERSION
|
|
401
|
+
|
|
402
|
+
def log_message(self, *a):
|
|
403
|
+
pass
|
|
404
|
+
|
|
405
|
+
def send(self, code, body, ctype):
|
|
406
|
+
self.send_response(code)
|
|
407
|
+
self.send_header("Content-Type", ctype)
|
|
408
|
+
self.send_header("Content-Length", str(len(body)))
|
|
409
|
+
self.send_header("Cache-Control", "no-store")
|
|
410
|
+
self.send_header("X-Content-Type-Options", "nosniff")
|
|
411
|
+
if ctype.startswith("text/html"):
|
|
412
|
+
self.send_header("Content-Security-Policy", page["csp"])
|
|
413
|
+
self.end_headers()
|
|
414
|
+
self.wfile.write(body)
|
|
415
|
+
|
|
416
|
+
def send_json(self, obj, code=200):
|
|
417
|
+
self.send(code, json.dumps(obj, ensure_ascii=False, separators=(",", ":")).encode("utf-8"),
|
|
418
|
+
"application/json; charset=utf-8")
|
|
419
|
+
|
|
420
|
+
def do_GET(self):
|
|
421
|
+
# 只接受发往本机的请求,挡掉 DNS 重绑定
|
|
422
|
+
host = (self.headers.get("Host") or "").rsplit(":", 1)[0]
|
|
423
|
+
if host not in ("127.0.0.1", "localhost"):
|
|
424
|
+
return self.send(403, b"forbidden", "text/plain")
|
|
425
|
+
url = urlparse(self.path)
|
|
426
|
+
qs = parse_qs(url.query)
|
|
427
|
+
if url.path in ("/", "/index.html"):
|
|
428
|
+
return self.send(200, page["html"], "text/html; charset=utf-8")
|
|
429
|
+
if url.path == "/api/data":
|
|
430
|
+
if "rescan" in qs or app.data is None:
|
|
431
|
+
app.scan()
|
|
432
|
+
return self.send_json(app.data)
|
|
433
|
+
if url.path == "/api/session":
|
|
434
|
+
detail = app.details.get((qs.get("id") or [""])[0])
|
|
435
|
+
if detail is None:
|
|
436
|
+
return self.send_json({"error": "not found"}, 404)
|
|
437
|
+
return self.send_json(detail)
|
|
438
|
+
return self.send(404, b"not found", "text/plain")
|
|
439
|
+
|
|
440
|
+
return Handler
|
|
441
|
+
|
|
442
|
+
|
|
443
|
+
def load_page(web_fonts):
|
|
444
|
+
with open(os.path.join(HERE, "web", "index.html"), "r", encoding="utf-8") as fh:
|
|
445
|
+
html = fh.read()
|
|
446
|
+
csp = "default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; connect-src 'self'; img-src data:"
|
|
447
|
+
fonts = ""
|
|
448
|
+
if web_fonts:
|
|
449
|
+
fonts = ('<link rel="stylesheet" href="https://fonts.googleapis.com/css2?'
|
|
450
|
+
'family=Bricolage+Grotesque:opsz,wght@12..96,500..800&family=ZCOOL+QingKe+HuangYou&display=swap">')
|
|
451
|
+
csp = csp.replace("style-src 'unsafe-inline'", "style-src 'unsafe-inline' https://fonts.googleapis.com")
|
|
452
|
+
csp += "; font-src https://fonts.gstatic.com"
|
|
453
|
+
return {"html": html.replace("<!--FONTS-->", fonts).encode("utf-8"), "csp": csp}
|
|
454
|
+
|
|
455
|
+
|
|
456
|
+
def main():
|
|
457
|
+
ap = argparse.ArgumentParser(prog="tuntu", description="吞吐 (tuntu):本机 Claude Code token 用量看板")
|
|
458
|
+
ap.add_argument("--port", type=int, default=8787, help="端口,默认 8787(被占用时自动往后找)")
|
|
459
|
+
ap.add_argument("--dir", help="日志目录,默认 ~/.claude/projects")
|
|
460
|
+
ap.add_argument("--demo", action="store_true", help="用虚构数据演示,不读取任何日志")
|
|
461
|
+
ap.add_argument("--no-open", action="store_true", help="不自动打开浏览器")
|
|
462
|
+
ap.add_argument("--web-fonts", action="store_true", help="从 Google Fonts 加载展示字体(会产生外网请求)")
|
|
463
|
+
ap.add_argument("--json", action="store_true", help="把聚合结果打印成 JSON 后退出")
|
|
464
|
+
ap.add_argument("--version", action="version", version="tuntu " + VERSION)
|
|
465
|
+
args = ap.parse_args()
|
|
466
|
+
|
|
467
|
+
app = App(args)
|
|
468
|
+
data = app.scan()
|
|
469
|
+
if args.json:
|
|
470
|
+
json.dump(data, sys.stdout, ensure_ascii=False)
|
|
471
|
+
return
|
|
472
|
+
if not args.demo and not data["buckets"]:
|
|
473
|
+
print("没有找到用量记录(%s)。可以用 --dir 指定目录,或用 --demo 看演示。" % data["meta"]["root"])
|
|
474
|
+
|
|
475
|
+
handler = make_handler(app, load_page(args.web_fonts))
|
|
476
|
+
server = None
|
|
477
|
+
for port in range(args.port, args.port + 20):
|
|
478
|
+
try:
|
|
479
|
+
server = ThreadingHTTPServer(("127.0.0.1", port), handler)
|
|
480
|
+
break
|
|
481
|
+
except OSError:
|
|
482
|
+
continue
|
|
483
|
+
if server is None:
|
|
484
|
+
sys.exit("端口 %d 到 %d 都被占用了,请用 --port 换一个。" % (args.port, args.port + 19))
|
|
485
|
+
url = "http://127.0.0.1:%d/" % server.server_address[1]
|
|
486
|
+
print("吞吐已启动:%s (Ctrl+C 退出)" % url)
|
|
487
|
+
if not args.no_open:
|
|
488
|
+
threading.Timer(0.3, webbrowser.open, args=(url,)).start()
|
|
489
|
+
try:
|
|
490
|
+
server.serve_forever()
|
|
491
|
+
except KeyboardInterrupt:
|
|
492
|
+
print("\n再见。")
|
|
493
|
+
|
|
494
|
+
|
|
495
|
+
if __name__ == "__main__":
|
|
496
|
+
main()
|
tuntu/web/index.html
ADDED
|
@@ -0,0 +1,611 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="zh-CN">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
6
|
+
<title>吞吐 · 我的 Token 都去哪了</title>
|
|
7
|
+
<!--FONTS-->
|
|
8
|
+
<style>
|
|
9
|
+
:root {
|
|
10
|
+
--ink: #111318; --bg: #EEF0F7; --muted: #3C4150; --white: #FFFFFF;
|
|
11
|
+
--yellow: #FFD43B; --blue: #3550E8; --orange: #FF7A3D; --mint: #7CE3B1; --pink: #FFB3DD; --sky: #C3CEFF;
|
|
12
|
+
--num: "Bricolage Grotesque", ui-rounded, "SF Pro Rounded", system-ui, -apple-system, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif;
|
|
13
|
+
--cn: "ZCOOL QingKe HuangYou", "Yuanti SC", "YouYuan", "PingFang SC", "Microsoft YaHei", sans-serif;
|
|
14
|
+
}
|
|
15
|
+
* { box-sizing: border-box; }
|
|
16
|
+
body { margin: 0; background: var(--bg); color: var(--ink); font-family: var(--num); font-variant-numeric: tabular-nums; }
|
|
17
|
+
a { color: inherit; }
|
|
18
|
+
button { font: inherit; color: inherit; cursor: pointer; }
|
|
19
|
+
.wrap { max-width: 1320px; margin: 0 auto; padding: 36px 24px 56px; display: flex; flex-direction: column; gap: 24px; }
|
|
20
|
+
.row { display: flex; flex-wrap: wrap; gap: 24px; }
|
|
21
|
+
.tile { min-width: 0; background: var(--white); border: 2.5px solid var(--ink); border-radius: 22px; box-shadow: 6px 6px 0 var(--ink); padding: 28px; display: flex; flex-direction: column; gap: 14px; }
|
|
22
|
+
.h { font-size: 20px; font-weight: 700; }
|
|
23
|
+
.sub { font-size: 14px; font-weight: 500; color: var(--muted); }
|
|
24
|
+
.cap { font-size: 15px; font-weight: 500; line-height: 1.5; }
|
|
25
|
+
.head { display: flex; flex-wrap: wrap; align-items: baseline; justify-content: space-between; gap: 8px; }
|
|
26
|
+
.cn { font-family: var(--cn); font-weight: 700; }
|
|
27
|
+
.scroll { overflow-x: auto; }
|
|
28
|
+
|
|
29
|
+
header.top { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 16px; }
|
|
30
|
+
.brand { display: flex; align-items: baseline; flex-wrap: wrap; gap: 14px; }
|
|
31
|
+
.brand .logo { font-size: 60px; line-height: 1; }
|
|
32
|
+
.tag { font-size: 17px; font-weight: 700; background: var(--ink); color: var(--white); padding: 6px 12px; border-radius: 999px; transform: rotate(-2deg); }
|
|
33
|
+
.tag.demo { background: var(--orange); color: var(--ink); border: 2px solid var(--ink); transform: rotate(2deg); }
|
|
34
|
+
.controls, .pills { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; }
|
|
35
|
+
.pill { min-height: 44px; padding: 0 18px; border: 2.5px solid var(--ink); border-radius: 999px; background: var(--white); font-size: 15px; font-weight: 700; display: inline-flex; align-items: center; gap: 8px; text-decoration: none; }
|
|
36
|
+
.pill[aria-pressed="true"] { background: var(--ink); color: var(--white); }
|
|
37
|
+
.pill.small { padding: 0 14px; border-width: 2px; font-size: 14px; }
|
|
38
|
+
.pill.go { background: var(--mint); box-shadow: 3px 3px 0 var(--ink); }
|
|
39
|
+
.pill.back { box-shadow: 3px 3px 0 var(--ink); }
|
|
40
|
+
.pill:focus-visible, a.srow:focus-visible { outline: 3px solid var(--blue); outline-offset: 3px; }
|
|
41
|
+
|
|
42
|
+
.hero { flex: 3 1 520px; background: var(--yellow); padding: 28px 32px; gap: 10px; }
|
|
43
|
+
.hero .line { display: flex; align-items: baseline; flex-wrap: wrap; gap: 14px; }
|
|
44
|
+
.hero .n { font-size: clamp(84px, 12vw, 172px); font-weight: 800; line-height: 0.9; letter-spacing: -0.04em; }
|
|
45
|
+
.hero .u { font-size: 72px; line-height: 1; }
|
|
46
|
+
.hero .t { font-size: 22px; font-weight: 700; }
|
|
47
|
+
.chips { display: flex; flex-wrap: wrap; gap: 10px; margin-top: 10px; }
|
|
48
|
+
.chip { font-size: 15px; font-weight: 700; background: var(--white); border: 2px solid var(--ink); border-radius: 999px; padding: 7px 14px; }
|
|
49
|
+
.chip.s { font-size: 14px; padding: 6px 14px; }
|
|
50
|
+
.side { flex: 2 1 380px; padding: 28px 32px; gap: 12px; }
|
|
51
|
+
.side .n { font-size: 84px; font-weight: 800; line-height: 0.95; letter-spacing: -0.03em; }
|
|
52
|
+
.ratio { background: var(--blue); color: var(--white); }
|
|
53
|
+
.dots { display: flex; flex-wrap: wrap; gap: 3px; }
|
|
54
|
+
.dots i { width: 7px; height: 7px; border-radius: 2px; background: var(--sky); }
|
|
55
|
+
.dots i.w { background: var(--orange); }
|
|
56
|
+
|
|
57
|
+
.hit { flex: 1 1 250px; background: var(--mint); }
|
|
58
|
+
.hit .n { font-size: 64px; font-weight: 800; line-height: 0.95; letter-spacing: -0.03em; }
|
|
59
|
+
.waffle { display: grid; grid-template-columns: repeat(10, minmax(0, 1fr)); gap: 4px; max-width: 216px; }
|
|
60
|
+
.waffle i { aspect-ratio: 1 / 1; border: 2px solid var(--ink); border-radius: 4px; }
|
|
61
|
+
.waffle i.on { background: var(--ink); }
|
|
62
|
+
.daily { flex: 3 1 460px; }
|
|
63
|
+
.bars { display: flex; align-items: flex-end; height: 236px; }
|
|
64
|
+
.bars .col { flex: 1 1 0; min-width: 0; display: flex; flex-direction: column; align-items: center; gap: 4px; }
|
|
65
|
+
.bars .tg { font-size: 12px; font-weight: 800; white-space: nowrap; height: 16px; }
|
|
66
|
+
.bars .b { width: 100%; background: var(--blue); border: 2px solid var(--ink); border-radius: 6px 6px 0 0; }
|
|
67
|
+
.bars .b.pk { background: var(--orange); }
|
|
68
|
+
.bars .b.z { border: 0; background: transparent; }
|
|
69
|
+
.xl { display: flex; }
|
|
70
|
+
.xl span { flex: 1 1 0; min-width: 0; text-align: center; font-size: 11px; font-weight: 500; color: var(--muted); white-space: nowrap; }
|
|
71
|
+
.clockt { flex: 1 1 280px; background: var(--pink); align-items: center; gap: 12px; }
|
|
72
|
+
.clock { position: relative; width: 240px; height: 240px; }
|
|
73
|
+
.clock .hb { position: absolute; left: 50%; top: 50%; width: 12px; margin-left: -6px; background: var(--ink); border: 2px solid var(--ink); border-radius: 0 0 6px 6px; transform-origin: 50% 0; }
|
|
74
|
+
.clock .hb.pk { background: var(--yellow); }
|
|
75
|
+
.clock .mid { position: absolute; left: 50%; top: 50%; width: 64px; height: 64px; margin: -32px 0 0 -32px; border-radius: 50%; background: var(--ink); color: var(--white); display: flex; align-items: center; justify-content: center; font-size: 18px; font-weight: 800; }
|
|
76
|
+
.clock .l { position: absolute; font-size: 12px; font-weight: 700; }
|
|
77
|
+
|
|
78
|
+
.badges { display: flex; flex-wrap: wrap; gap: 20px; }
|
|
79
|
+
.badge { flex: 1 1 190px; min-width: 0; border: 2.5px solid var(--ink); border-radius: 18px; box-shadow: 4px 4px 0 var(--ink); padding: 16px 18px; display: flex; flex-direction: column; gap: 4px; }
|
|
80
|
+
.badge .k { font-size: 12px; font-weight: 700; letter-spacing: 0.08em; }
|
|
81
|
+
.badge .ti { font-size: 28px; line-height: 1.15; }
|
|
82
|
+
.badge .li { font-size: 14px; font-weight: 500; }
|
|
83
|
+
.badge.locked { background: transparent !important; border-style: dashed; box-shadow: none; color: var(--muted); transform: none !important; }
|
|
84
|
+
|
|
85
|
+
.stack { display: flex; height: 34px; border: 2.5px solid var(--ink); border-radius: 10px; overflow: hidden; background: var(--bg); }
|
|
86
|
+
.legend { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(200px, 100%), 1fr)); gap: 14px; }
|
|
87
|
+
.legend .it { display: flex; gap: 10px; align-items: flex-start; }
|
|
88
|
+
.sw { width: 16px; height: 16px; border: 2px solid var(--ink); border-radius: 5px; margin-top: 2px; flex: none; }
|
|
89
|
+
.legend b { display: block; font-size: 15px; }
|
|
90
|
+
.legend span { font-size: 13px; font-weight: 500; color: var(--muted); }
|
|
91
|
+
.erow { display: flex; align-items: center; gap: 12px; font-size: 15px; font-weight: 700; }
|
|
92
|
+
.erow .nm { flex: 1 1 auto; }
|
|
93
|
+
.erow .dt { width: 160px; flex: none; text-align: right; font-size: 13px; font-weight: 500; color: var(--muted); }
|
|
94
|
+
|
|
95
|
+
.rank { display: flex; flex-direction: column; gap: 8px; }
|
|
96
|
+
.rank .top { display: flex; justify-content: space-between; align-items: baseline; gap: 12px; }
|
|
97
|
+
.rank .nm { font-size: 17px; font-weight: 800; min-width: 0; overflow-wrap: anywhere; }
|
|
98
|
+
.rank .vv { font-size: 15px; font-weight: 700; flex: none; }
|
|
99
|
+
.meter { height: 16px; border: 2px solid var(--ink); border-radius: 8px; overflow: hidden; background: var(--bg); }
|
|
100
|
+
.meter i { display: block; height: 100%; border-right: 2px solid var(--ink); }
|
|
101
|
+
.meter.thin { height: 14px; border-radius: 7px; }
|
|
102
|
+
.meter.thin i { border-right: 0; }
|
|
103
|
+
|
|
104
|
+
.heat { min-width: 560px; display: flex; flex-direction: column; gap: 4px; }
|
|
105
|
+
.heat .hr { display: flex; align-items: center; gap: 4px; }
|
|
106
|
+
.heat .wl { width: 36px; flex: none; font-size: 13px; font-weight: 700; }
|
|
107
|
+
.heat .c { flex: 1 1 0; height: 26px; border-radius: 5px; background: var(--bg); }
|
|
108
|
+
.heat .hl { flex: 1 1 0; font-size: 11px; font-weight: 500; color: var(--muted); text-align: center; }
|
|
109
|
+
.trow { display: flex; align-items: center; gap: 12px; font-size: 14px; font-weight: 700; }
|
|
110
|
+
.trow .nm { width: 118px; flex: none; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
111
|
+
.trow .meter { flex: 1 1 auto; }
|
|
112
|
+
.trow .ct { width: 60px; flex: none; text-align: right; }
|
|
113
|
+
|
|
114
|
+
.slist { min-width: 860px; display: flex; flex-direction: column; gap: 10px; padding: 0 6px 6px 0; }
|
|
115
|
+
a.srow { display: grid; grid-template-columns: 44px 1.1fr 1.7fr 0.8fr 2fr 0.9fr 0.8fr 0.7fr; gap: 14px; align-items: center; min-height: 56px; padding: 0 16px; border: 2px solid var(--ink); border-radius: 14px; box-shadow: 3px 3px 0 var(--ink); background: var(--white); text-decoration: none; font-size: 15px; font-weight: 700; }
|
|
116
|
+
a.srow:hover { background: #F7F8FC; transform: translate(-1px, -1px); box-shadow: 4px 4px 0 var(--ink); }
|
|
117
|
+
.medal { width: 36px; height: 36px; border: 2px solid var(--ink); border-radius: 50%; display: flex; align-items: center; justify-content: center; font-weight: 800; }
|
|
118
|
+
.r { text-align: right; }
|
|
119
|
+
.lt { font-weight: 500; }
|
|
120
|
+
.ell { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
121
|
+
|
|
122
|
+
.kpis { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(220px, 100%), 1fr)); gap: 20px; }
|
|
123
|
+
.kpi { border: 2.5px solid var(--ink); border-radius: 18px; box-shadow: 4px 4px 0 var(--ink); padding: 18px 22px; display: flex; flex-direction: column; gap: 4px; background: var(--white); }
|
|
124
|
+
.kpi .k { font-size: 15px; font-weight: 700; }
|
|
125
|
+
.kpi .v { font-size: 40px; font-weight: 800; letter-spacing: -0.02em; line-height: 1.05; }
|
|
126
|
+
.kpi .s { font-size: 13px; font-weight: 500; }
|
|
127
|
+
.ctxwrap { min-width: 760px; display: flex; gap: 10px; }
|
|
128
|
+
.yax { display: flex; flex-direction: column; justify-content: space-between; height: 276px; padding-top: 20px; font-size: 12px; font-weight: 700; text-align: right; width: 52px; flex: none; }
|
|
129
|
+
.ctx { display: flex; align-items: flex-end; gap: 3px; height: 276px; border-bottom: 2.5px solid var(--ink); }
|
|
130
|
+
.ctx .b { border-bottom: 0; border-radius: 5px 5px 0 0; }
|
|
131
|
+
.ctx .tg { font-size: 11px; }
|
|
132
|
+
.duo { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 14px; }
|
|
133
|
+
.duo > div { border: 2px solid var(--ink); border-radius: 14px; padding: 16px; display: flex; flex-direction: column; gap: 4px; }
|
|
134
|
+
.duo .k { font-size: 14px; font-weight: 700; }
|
|
135
|
+
.duo .v { font-size: 30px; font-weight: 800; line-height: 1.1; }
|
|
136
|
+
.duo .s { font-size: 13px; font-weight: 500; }
|
|
137
|
+
.tip { margin-top: auto; align-self: flex-start; background: var(--yellow); border: 2.5px solid var(--ink); border-radius: 16px; box-shadow: 4px 4px 0 var(--ink); padding: 14px 18px; font-size: 15px; font-weight: 700; line-height: 1.5; transform: rotate(-1.5deg); }
|
|
138
|
+
|
|
139
|
+
footer { display: flex; flex-wrap: wrap; justify-content: space-between; gap: 8px; font-size: 13px; font-weight: 500; color: var(--muted); }
|
|
140
|
+
.empty { align-items: flex-start; }
|
|
141
|
+
@media (max-width: 560px) {
|
|
142
|
+
.wrap { padding: 24px 16px 40px; }
|
|
143
|
+
.tile { padding: 20px; box-shadow: 4px 4px 0 var(--ink); }
|
|
144
|
+
.brand .logo { font-size: 48px; }
|
|
145
|
+
.side .n { font-size: 64px; }
|
|
146
|
+
.hero .u { font-size: 52px; }
|
|
147
|
+
}
|
|
148
|
+
</style>
|
|
149
|
+
</head>
|
|
150
|
+
<body>
|
|
151
|
+
<div class="wrap" id="app"><div class="tile"><div class="h">正在扫描日志…</div></div></div>
|
|
152
|
+
<script>
|
|
153
|
+
'use strict';
|
|
154
|
+
// bucket 行的字段下标:日期、小时、项目、模型、是否子代理、推理强度、新输入、输出、缓存写入、缓存读取、其中 1 小时档写入、请求数
|
|
155
|
+
const D = 0, HOUR = 1, P = 2, M = 3, SIDE = 4, EFF = 5, IN = 6, OUT = 7, CW = 8, CR = 9, H1 = 10, REQ = 11;
|
|
156
|
+
const WK = ['周一', '周二', '周三', '周四', '周五', '周六', '周日'];
|
|
157
|
+
const COLORS = { cr: 'var(--blue)', cw: 'var(--sky)', out: 'var(--orange)', inp: 'var(--ink)' };
|
|
158
|
+
const app = document.getElementById('app');
|
|
159
|
+
const state = { range: 'all', metric: 'total', busy: false };
|
|
160
|
+
let DATA = null;
|
|
161
|
+
|
|
162
|
+
const esc = (s) => String(s).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
|
|
163
|
+
const num = (n) => Math.round(n).toLocaleString('zh-CN');
|
|
164
|
+
const fmt = (n) => n >= 1e8 ? (n / 1e8).toFixed(2) + ' 亿' : n >= 1e4 ? (n / 1e4).toFixed(n >= 1e6 ? 0 : 1) + ' 万' : num(n);
|
|
165
|
+
const pct = (a, b, d = 2) => b ? (100 * a / b).toFixed(d) + '%' : '—';
|
|
166
|
+
const pad = (n) => String(n).padStart(2, '0');
|
|
167
|
+
const dstr = (d) => d.getFullYear() + '-' + pad(d.getMonth() + 1) + '-' + pad(d.getDate());
|
|
168
|
+
const parseD = (s) => new Date(+s.slice(0, 4), +s.slice(5, 7) - 1, +s.slice(8, 10));
|
|
169
|
+
const addDays = (d, n) => new Date(d.getFullYear(), d.getMonth(), d.getDate() + n);
|
|
170
|
+
const weekday = (s) => (parseD(s).getDay() + 6) % 7;
|
|
171
|
+
const md = (s) => s.slice(5, 10);
|
|
172
|
+
const sum4 = (a) => a[0] + a[1] + a[2] + a[3];
|
|
173
|
+
const hitOf = (a) => 100 * a[3] / ((a[0] + a[2] + a[3]) || 1);
|
|
174
|
+
|
|
175
|
+
function modelName(id) {
|
|
176
|
+
const parts = String(id).replace(/^claude-/, '').replace(/-\d{8}$/, '').split('-');
|
|
177
|
+
const out = [];
|
|
178
|
+
for (const p of parts) {
|
|
179
|
+
if (/^\d+$/.test(p) && out.length && /^[\d.]+$/.test(out[out.length - 1])) out[out.length - 1] += '.' + p;
|
|
180
|
+
else out.push(/^\d+$/.test(p) ? p : p.charAt(0).toUpperCase() + p.slice(1));
|
|
181
|
+
}
|
|
182
|
+
return out.join(' ');
|
|
183
|
+
}
|
|
184
|
+
function spanText(a, b) {
|
|
185
|
+
const mins = Math.max(0, (new Date(b) - new Date(a)) / 60000);
|
|
186
|
+
if (mins < 60) return [Math.max(1, Math.round(mins)), '分钟'];
|
|
187
|
+
if (mins < 72 * 60) return [Math.round(mins / 60), '小时'];
|
|
188
|
+
return [Math.round(mins / 1440), '天'];
|
|
189
|
+
}
|
|
190
|
+
const when = (s) => s.slice(5, 10) + ' ' + s.slice(11, 16);
|
|
191
|
+
|
|
192
|
+
// ---------------------------------------------------------------- 计算
|
|
193
|
+
|
|
194
|
+
function compute(range) {
|
|
195
|
+
const today = dstr(new Date());
|
|
196
|
+
const back = { '30': 29, '7': 6, '1': 0 }[range];
|
|
197
|
+
const cutoff = range === 'all' ? null : dstr(addDays(new Date(), -back));
|
|
198
|
+
const rows = cutoff ? DATA.buckets.filter((b) => b[D] >= cutoff) : DATA.buckets;
|
|
199
|
+
const S = [0, 0, 0, 0, 0]; // 新输入、输出、缓存写入、缓存读取、请求
|
|
200
|
+
let h1 = 0, sideTok = 0, sideReq = 0;
|
|
201
|
+
const perDay = new Map(), byM = new Map(), byP = new Map(), byE = new Map();
|
|
202
|
+
const hours = Array(24).fill(0);
|
|
203
|
+
const heat = WK.map(() => Array(24).fill(0));
|
|
204
|
+
const add = (map, key, b) => {
|
|
205
|
+
let a = map.get(key);
|
|
206
|
+
if (!a) map.set(key, a = [0, 0, 0, 0, 0]);
|
|
207
|
+
a[0] += b[IN]; a[1] += b[OUT]; a[2] += b[CW]; a[3] += b[CR]; a[4] += b[REQ];
|
|
208
|
+
};
|
|
209
|
+
for (const b of rows) {
|
|
210
|
+
const t = b[IN] + b[OUT] + b[CW] + b[CR];
|
|
211
|
+
S[0] += b[IN]; S[1] += b[OUT]; S[2] += b[CW]; S[3] += b[CR]; S[4] += b[REQ];
|
|
212
|
+
h1 += b[H1];
|
|
213
|
+
if (b[SIDE]) { sideTok += t; sideReq += b[REQ]; }
|
|
214
|
+
add(perDay, b[D], b); add(byM, b[M], b); add(byP, b[P], b); add(byE, b[EFF], b);
|
|
215
|
+
hours[b[HOUR]] += t;
|
|
216
|
+
heat[weekday(b[D])][b[HOUR]] += t;
|
|
217
|
+
}
|
|
218
|
+
const total = sum4(S);
|
|
219
|
+
const first = cutoff || (rows.length ? rows[0][D] : today);
|
|
220
|
+
const last = range === 'all' && rows.length ? rows[rows.length - 1][D] : today;
|
|
221
|
+
const days = [];
|
|
222
|
+
for (let d = parseD(first); dstr(d) <= last; d = addDays(d, 1)) days.push(dstr(d));
|
|
223
|
+
const tools = new Map();
|
|
224
|
+
for (const [date, name, c] of DATA.tools) if (!cutoff || date >= cutoff) tools.set(name, (tools.get(name) || 0) + c);
|
|
225
|
+
const sessions = DATA.sessions.filter((s) => !cutoff || s.end.slice(0, 10) >= cutoff)
|
|
226
|
+
.sort((a, b) => sum4(b.tok) - sum4(a.tok));
|
|
227
|
+
return { range, cutoff, S, total, h1, sideTok, sideReq, perDay, byM, byP, byE, hours, heat, days, first, last,
|
|
228
|
+
tools: [...tools.entries()].sort((a, b) => b[1] - a[1]), sessions };
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function badges() {
|
|
232
|
+
// 成就按全部记录算,不跟时间范围走
|
|
233
|
+
const all = compute('all');
|
|
234
|
+
const peakH = all.hours.indexOf(Math.max(...all.hours));
|
|
235
|
+
const habit = (peakH >= 20 || peakH <= 3) ? '夜猫子' : peakH <= 8 ? '早鸟' : peakH <= 12 ? '上午战士' : '下午茶选手';
|
|
236
|
+
const hit = hitOf(all.S);
|
|
237
|
+
let longest = null;
|
|
238
|
+
for (const s of DATA.sessions) {
|
|
239
|
+
const mins = (new Date(s.end) - new Date(s.start)) / 60000;
|
|
240
|
+
if (!longest || mins > longest.mins) longest = { mins, s };
|
|
241
|
+
}
|
|
242
|
+
const tool = all.tools[0];
|
|
243
|
+
const dates = [...all.perDay.keys()].sort();
|
|
244
|
+
let streak = 0, run = 0, prev = null;
|
|
245
|
+
for (const d of dates) {
|
|
246
|
+
run = prev && dstr(addDays(parseD(prev), 1)) === d ? run + 1 : 1;
|
|
247
|
+
streak = Math.max(streak, run);
|
|
248
|
+
prev = d;
|
|
249
|
+
}
|
|
250
|
+
const span = longest ? spanText(longest.s.start, longest.s.end) : null;
|
|
251
|
+
const club = all.total >= 1e9 ? '十亿俱乐部' : '亿元户';
|
|
252
|
+
return [
|
|
253
|
+
{ ok: all.total > 0, title: habit, line: peakH + ' 点是一天里最旺的时候', bg: 'var(--white)', rot: -2 },
|
|
254
|
+
{ ok: hit >= 95, title: '缓存大师', line: '命中率 ' + hit.toFixed(2) + '%', lock: '命中率到 95% 解锁', bg: 'var(--yellow)', rot: 1.5 },
|
|
255
|
+
{ ok: !!longest && longest.mins >= 720, title: '马拉松选手', line: span ? '单个会话前后跨了 ' + span[0] + ' ' + span[1] : '', lock: '单个会话跨满 12 小时解锁', bg: 'var(--mint)', rot: -1 },
|
|
256
|
+
{ ok: !!tool && tool[1] >= 1000, title: tool ? tool[0] + ' 狂魔' : '工具狂魔', line: tool ? '用了 ' + num(tool[1]) + ' 次' : '', lock: '同一个工具用满 1,000 次解锁', bg: 'var(--pink)', rot: 2 },
|
|
257
|
+
{ ok: streak >= 7, title: '全勤王', line: streak === dates.length ? '连续 ' + streak + ' 天,一天没断' : '最长连续 ' + streak + ' 天', lock: '连续用满 7 天解锁', bg: 'var(--sky)', rot: -1.5 },
|
|
258
|
+
{ ok: all.total >= 1e8, title: club, line: '累计吞了 ' + fmt(all.total), lock: '累计吞满 1 亿解锁', bg: 'var(--white)', rot: 1 },
|
|
259
|
+
];
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// ---------------------------------------------------------------- 页面片段
|
|
263
|
+
|
|
264
|
+
function header(extra) {
|
|
265
|
+
const meta = DATA.meta;
|
|
266
|
+
return `<header class="top">
|
|
267
|
+
<div class="brand">
|
|
268
|
+
<div class="logo cn">吞吐</div>
|
|
269
|
+
<div class="tag">我的 Token 都去哪了</div>
|
|
270
|
+
${meta.demo ? '<div class="tag demo">演示数据</div>' : ''}
|
|
271
|
+
</div>
|
|
272
|
+
<div class="controls">${extra}</div>
|
|
273
|
+
</header>`;
|
|
274
|
+
}
|
|
275
|
+
function rangePills() {
|
|
276
|
+
const opts = [['all', '全部'], ['30', '近 30 天'], ['7', '近 7 天'], ['1', '今天']];
|
|
277
|
+
return `<div class="pills" role="group" aria-label="时间范围">${opts.map(([id, label]) =>
|
|
278
|
+
`<button type="button" class="pill" data-range="${id}" aria-pressed="${state.range === id}">${label}</button>`).join('')}</div>
|
|
279
|
+
<button type="button" class="pill go" data-act="rescan">${state.busy ? '扫描中…' : '重新扫描'}</button>`;
|
|
280
|
+
}
|
|
281
|
+
function footer() {
|
|
282
|
+
const meta = DATA.meta;
|
|
283
|
+
return `<footer><div>数据只在本机,不上传 · 读取 ${esc(meta.root)} · ${num(meta.sessions)} 个会话</div>
|
|
284
|
+
<div>扫描于 ${esc(when(meta.generated))} · tuntu ${esc(meta.version)}</div></footer>`;
|
|
285
|
+
}
|
|
286
|
+
function segs(parts, base) {
|
|
287
|
+
return parts.map(([name, v, color]) =>
|
|
288
|
+
`<div title="${esc(name)} ${fmt(v)}(${pct(v, base)})" style="width:${(100 * v / (base || 1)).toFixed(3)}%;background:${color}"></div>`).join('');
|
|
289
|
+
}
|
|
290
|
+
function rankList(map, names, color, limit) {
|
|
291
|
+
const rows = [...map.entries()].map(([k, a]) => ({ name: names(k), a, t: sum4(a) })).sort((x, y) => y.t - x.t);
|
|
292
|
+
const all = rows.reduce((s, r) => s + r.t, 0);
|
|
293
|
+
let shown = rows.slice(0, limit);
|
|
294
|
+
if (rows.length > limit) {
|
|
295
|
+
const rest = rows.slice(limit).reduce((acc, r) => acc.map((v, i) => v + r.a[i]), [0, 0, 0, 0, 0]);
|
|
296
|
+
shown = shown.concat({ name: '其他 ' + (rows.length - limit) + ' 个', a: rest, t: sum4(rest) });
|
|
297
|
+
}
|
|
298
|
+
const max = shown.length ? Math.max(...shown.map((r) => r.t)) : 1;
|
|
299
|
+
return shown.map((r) => `<div class="rank">
|
|
300
|
+
<div class="top"><span class="nm">${esc(r.name)}</span><span class="vv">${fmt(r.t)} · ${pct(r.t, all, 1)}</span></div>
|
|
301
|
+
<div class="meter"><i style="width:${Math.max(1.5, 100 * r.t / max).toFixed(2)}%;background:${color}"></i></div>
|
|
302
|
+
<div class="sub">输出 ${fmt(r.a[1])} · 命中率 ${hitOf(r.a).toFixed(2)}% · ${num(r.a[4])} 次请求</div>
|
|
303
|
+
</div>`).join('') || '<div class="sub">这段时间没有记录</div>';
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
function dailyTile(c) {
|
|
307
|
+
const metric = state.metric;
|
|
308
|
+
const zero = [0, 0, 0, 0, 0];
|
|
309
|
+
const step = Math.max(1, Math.ceil(c.days.length / 92));
|
|
310
|
+
const groups = [];
|
|
311
|
+
for (let i = 0; i < c.days.length; i += step) {
|
|
312
|
+
const a = [0, 0, 0, 0, 0];
|
|
313
|
+
for (const d of c.days.slice(i, i + step)) (c.perDay.get(d) || zero).forEach((v, k) => { a[k] += v; });
|
|
314
|
+
groups.push({ date: c.days[i], a });
|
|
315
|
+
}
|
|
316
|
+
const val = (a) => metric === 'total' ? sum4(a) : metric === 'output' ? a[1] : metric === 'req' ? a[4] : (a[4] ? hitOf(a) : null);
|
|
317
|
+
const show = (v) => metric === 'req' ? num(v) + ' 次' : metric === 'hit' ? v.toFixed(1) + '%' : fmt(v);
|
|
318
|
+
const vals = groups.map((g) => val(g.a));
|
|
319
|
+
const real = vals.filter((v) => v !== null && v > 0);
|
|
320
|
+
const max = real.length ? Math.max(...real) : 1;
|
|
321
|
+
const floor = metric === 'hit' && real.length ? Math.max(0, Math.floor(Math.min(...real) / 5) * 5 - 5) : 0;
|
|
322
|
+
const peak = real.length ? vals.indexOf(max) : -1;
|
|
323
|
+
const every = Math.max(1, Math.ceil(groups.length / 8));
|
|
324
|
+
const gap = groups.length > 45 ? 2 : groups.length > 24 ? 4 : 6;
|
|
325
|
+
const cols = groups.map((g, i) => {
|
|
326
|
+
const v = vals[i];
|
|
327
|
+
const h = v ? Math.max(6, Math.round(200 * (v - floor) / ((max - floor) || 1))) : 0;
|
|
328
|
+
const tip = `${g.date}${step > 1 ? ' 起 ' + step + ' 天' : ''} · 全部 ${fmt(sum4(g.a))} · 输出 ${fmt(g.a[1])} · ${num(g.a[4])} 次请求`;
|
|
329
|
+
return `<div class="col"><div class="tg">${i === peak ? show(v) : ''}</div><div class="b${i === peak ? ' pk' : ''}${h ? '' : ' z'}" title="${esc(tip)}" style="height:${h}px"></div></div>`;
|
|
330
|
+
}).join('');
|
|
331
|
+
const labels = groups.map((g, i) => `<span>${i % every === 0 ? md(g.date) : ''}</span>`).join('');
|
|
332
|
+
const what = { total: '吞得最多', output: '写得最多', req: '请求最多', hit: '命中率最高' }[metric];
|
|
333
|
+
const caption = peak < 0 ? '这段时间没有记录。'
|
|
334
|
+
: `所选范围里${what}的${step > 1 ? '一段' : '一天'}是 ${md(groups[peak].date)}:${show(max)}。${metric === 'hit' ? '纵轴从 ' + floor + '% 起算。' : ''}`;
|
|
335
|
+
const opts = [['total', '全部'], ['output', '输出'], ['req', '请求'], ['hit', '命中率']];
|
|
336
|
+
return `<section class="tile daily">
|
|
337
|
+
<div class="head" style="align-items:center"><div class="h">每日吞吐</div>
|
|
338
|
+
<div class="pills" role="group" aria-label="指标">${opts.map(([id, label]) =>
|
|
339
|
+
`<button type="button" class="pill small" data-metric="${id}" aria-pressed="${metric === id}">${label}</button>`).join('')}</div></div>
|
|
340
|
+
<div class="scroll"><div style="min-width:${Math.min(520, 60 + groups.length * 22)}px;display:flex;flex-direction:column;gap:6px">
|
|
341
|
+
<div class="bars" style="gap:${gap}px">${cols}</div><div class="xl" style="gap:${gap}px">${labels}</div></div></div>
|
|
342
|
+
<div class="cap">${caption}</div>
|
|
343
|
+
</section>`;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
function clockTile(c) {
|
|
347
|
+
const max = Math.max(...c.hours);
|
|
348
|
+
const peak = c.hours.indexOf(max);
|
|
349
|
+
const night = [20, 21, 22, 23, 0, 1, 2].reduce((s, h) => s + c.hours[h], 0);
|
|
350
|
+
const bars = c.hours.map((v, h) => {
|
|
351
|
+
const len = max ? Math.round(8 + 74 * v / max) : 8;
|
|
352
|
+
return `<div class="hb${max && h === peak ? ' pk' : ''}" title="${h}:00 · ${fmt(v)}" style="height:${len}px;transform:rotate(${180 + h * 15}deg) translateY(36px)"></div>`;
|
|
353
|
+
}).join('');
|
|
354
|
+
return `<section class="tile clockt">
|
|
355
|
+
<div class="h" style="align-self:flex-start">你的 24 小时</div>
|
|
356
|
+
<div class="clock" role="img" aria-label="24 小时用量钟面,${peak} 点最高">${bars}
|
|
357
|
+
<div class="mid">${max ? peak + ' 点' : '—'}</div>
|
|
358
|
+
<div class="l" style="left:50%;top:-4px;transform:translateX(-50%)">0</div>
|
|
359
|
+
<div class="l" style="right:-4px;top:50%;transform:translateY(-50%)">6</div>
|
|
360
|
+
<div class="l" style="left:50%;bottom:-4px;transform:translateX(-50%)">12</div>
|
|
361
|
+
<div class="l" style="left:-8px;top:50%;transform:translateY(-50%)">18</div>
|
|
362
|
+
</div>
|
|
363
|
+
<div class="cap">${max ? `${pct(night, c.total, 0)} 的 token 花在晚 8 点到凌晨 3 点。` : '这段时间没有记录。'}</div>
|
|
364
|
+
</section>`;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
function heatTile(c) {
|
|
368
|
+
const max = Math.max(...c.heat.flat());
|
|
369
|
+
let pw = 0, ph = 0;
|
|
370
|
+
c.heat.forEach((row, w) => row.forEach((v, h) => { if (v > c.heat[pw][ph]) { pw = w; ph = h; } }));
|
|
371
|
+
const rows = c.heat.map((row, w) => `<div class="hr"><div class="wl">${WK[w]}</div>${row.map((v, h) =>
|
|
372
|
+
`<div class="c" title="${WK[w]} ${h}:00 · ${fmt(v)}"${v ? ` style="background:rgba(53,80,232,${(0.14 + 0.86 * v / max).toFixed(2)})"` : ''}></div>`).join('')}</div>`).join('');
|
|
373
|
+
const hl = Array.from({ length: 24 }, (_, h) => `<div class="hl">${h % 3 === 0 ? h : ''}</div>`).join('');
|
|
374
|
+
return `<section class="tile" style="flex:3 1 520px">
|
|
375
|
+
<div class="head"><div class="h">一周作息表</div><div class="sub">星期 × 小时,越深吞得越多</div></div>
|
|
376
|
+
<div class="scroll"><div class="heat">${rows}<div class="hr" style="margin-top:2px"><div class="wl"></div>${hl}</div></div></div>
|
|
377
|
+
<div class="cap">${max ? `最猛的一小时是${WK[pw]} ${ph} 点。` : '这段时间没有记录。'}</div>
|
|
378
|
+
</section>`;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
function effortTile(c) {
|
|
382
|
+
const order = ['max', 'xhigh', 'high', 'medium', 'low', null];
|
|
383
|
+
const names = { max: '最大', xhigh: '超高', high: '高', medium: '中', low: '低' };
|
|
384
|
+
const palette = ['var(--yellow)', 'var(--orange)', 'var(--ink)', 'var(--blue)', 'var(--sky)', 'var(--white)'];
|
|
385
|
+
const keys = [...c.byE.keys()];
|
|
386
|
+
if (!keys.some((k) => k !== null)) return '';
|
|
387
|
+
keys.sort((a, b) => (order.indexOf(a) < 0 ? 9 : order.indexOf(a)) - (order.indexOf(b) < 0 ? 9 : order.indexOf(b)));
|
|
388
|
+
const items = keys.map((k) => ({ name: k === null ? '未记录' : (names[k] || k), a: c.byE.get(k), color: palette[Math.max(0, order.indexOf(k))] }));
|
|
389
|
+
return `<section class="tile" style="flex:2 1 360px;gap:16px">
|
|
390
|
+
<div class="h">用了多大的劲</div><div class="sub">按推理强度分的 token</div>
|
|
391
|
+
<div class="stack">${segs(items.map((e) => [e.name, sum4(e.a), e.color]), c.total)}</div>
|
|
392
|
+
${items.map((e) => `<div class="erow"><span class="sw" style="margin:0;background:${e.color}"></span><div class="nm">${esc(e.name)}</div>
|
|
393
|
+
<div>${pct(sum4(e.a), c.total, 1)}</div><div class="dt">${fmt(sum4(e.a))} · ${num(e.a[4])} 次</div></div>`).join('')}
|
|
394
|
+
</section>`;
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
// ---------------------------------------------------------------- 总览页
|
|
398
|
+
|
|
399
|
+
function renderHome() {
|
|
400
|
+
const c = compute(state.range);
|
|
401
|
+
const S = c.S, total = c.total;
|
|
402
|
+
const allIn = S[0] + S[2] + S[3], fresh = S[0] + S[1] + S[2];
|
|
403
|
+
if (!DATA.buckets.length) {
|
|
404
|
+
app.innerHTML = header(rangePills()) + `<section class="tile empty"><div class="h">还没找到用量记录</div>
|
|
405
|
+
<div class="cap">在 ${esc(DATA.meta.root)} 里没有读到 Claude Code 的会话日志。可以用 <b>--dir</b> 指定目录,或者用 <b>--demo</b> 先看看演示。</div></section>` + footer();
|
|
406
|
+
return;
|
|
407
|
+
}
|
|
408
|
+
const big = total >= 1e8 ? [(total / 1e8).toFixed(2), '亿'] : total >= 1e4 ? [(total / 1e4).toFixed(total >= 1e6 ? 0 : 1), '万'] : [num(total), ''];
|
|
409
|
+
const lead = { all: `自 ${+c.first.slice(5, 7)} 月 ${+c.first.slice(8, 10)} 日起,你一共吞了`, '30': '近 30 天,你一共吞了', '7': '近 7 天,你一共吞了', '1': '今天,你一共吞了' }[c.range];
|
|
410
|
+
const nd = c.days.length || 1;
|
|
411
|
+
const chips = [`平均每秒 ${num(total / (nd * 86400))} 个,昼夜不停`, `日均 ${fmt(total / nd)}`, `${num(S[4])} 次请求`, `写出 ${fmt(S[1])}`];
|
|
412
|
+
const ratio = S[1] ? Math.round(allIn / S[1]) : 0;
|
|
413
|
+
const per = Math.max(1, Math.ceil(ratio / 480));
|
|
414
|
+
const dots = '<i></i>'.repeat(Math.round(ratio / per)) + '<i class="w"></i>';
|
|
415
|
+
const hit = allIn ? 100 * S[3] / allIn : 0;
|
|
416
|
+
const cells = Math.floor(hit);
|
|
417
|
+
const waffle = Array.from({ length: 100 }, (_, i) => `<i${i < cells ? ' class="on"' : ''}></i>`).join('');
|
|
418
|
+
const parts = [['缓存读取', S[3], COLORS.cr, '每轮重读的历史上下文'], ['缓存写入', S[2], COLORS.cw, '第一次写进缓存的内容'],
|
|
419
|
+
['输出', S[1], COLORS.out, '模型写出的回复和代码'], ['新输入', S[0], COLORS.inp, '没走缓存的输入']];
|
|
420
|
+
const facts = [];
|
|
421
|
+
if (c.h1 && S[2]) facts.push(`<div class="chip s" style="background:var(--sky)">缓存写入里 ${pct(c.h1, S[2], 1)} 是 1 小时档</div>`);
|
|
422
|
+
if (c.sideTok) facts.push(`<div class="chip s" style="background:var(--pink)">子代理吞了 ${pct(c.sideTok, total, 1)}(${fmt(c.sideTok)},${num(c.sideReq)} 次请求)</div>`);
|
|
423
|
+
const toolMax = c.tools.length ? c.tools[0][1] : 1;
|
|
424
|
+
const medals = ['var(--yellow)', 'var(--sky)', 'var(--pink)'];
|
|
425
|
+
const top = c.sessions.slice(0, 10);
|
|
426
|
+
const sMax = top.length ? sum4(top[0].tok) : 1;
|
|
427
|
+
const effort = effortTile(c);
|
|
428
|
+
|
|
429
|
+
app.innerHTML = header(rangePills()) + `
|
|
430
|
+
<div class="row">
|
|
431
|
+
<section class="tile hero">
|
|
432
|
+
<div class="h">${lead}</div>
|
|
433
|
+
<div class="line"><div class="n">${big[0]}</div><div class="u cn">${big[1]}</div><div class="t">个 token</div></div>
|
|
434
|
+
<div class="chips">${chips.map((t) => `<div class="chip">${t}</div>`).join('')}</div>
|
|
435
|
+
</section>
|
|
436
|
+
<section class="tile side ratio">
|
|
437
|
+
<div class="h">读写比</div>
|
|
438
|
+
<div class="n">${ratio ? num(ratio) + ' : 1' : '—'}</div>
|
|
439
|
+
<div class="dots" role="img" aria-label="浅色方块代表读入,橙色方块代表写出">${dots}</div>
|
|
440
|
+
<div class="cap">${ratio ? `每写出 1 个 token(橙色那一格),先读了 ${num(ratio)} 个${per > 1 ? `(每个浅色格代表 ${per} 个)` : ''}。读的几乎全走缓存。` : '这段时间没有输出。'}</div>
|
|
441
|
+
</section>
|
|
442
|
+
</div>
|
|
443
|
+
<div class="row">
|
|
444
|
+
<section class="tile hit">
|
|
445
|
+
<div class="h">缓存命中率</div>
|
|
446
|
+
<div class="n">${allIn ? hit.toFixed(2) + '%' : '—'}</div>
|
|
447
|
+
<div class="waffle" role="img" aria-label="100 格华夫图,实心格为命中">${waffle}</div>
|
|
448
|
+
<div class="cap">100 格里中了 ${cells} 格</div>
|
|
449
|
+
<div class="sub" style="color:#1D3B2C">= 缓存读取 ÷ 全部输入</div>
|
|
450
|
+
</section>
|
|
451
|
+
${dailyTile(c)}
|
|
452
|
+
${clockTile(c)}
|
|
453
|
+
</div>
|
|
454
|
+
<section class="badges" aria-label="成就">${badges().map((b) => b.ok
|
|
455
|
+
? `<div class="badge" style="background:${b.bg};transform:rotate(${b.rot}deg)"><div class="k">已解锁</div><div class="ti cn">${esc(b.title)}</div><div class="li">${esc(b.line)}</div></div>`
|
|
456
|
+
: `<div class="badge locked"><div class="k">未解锁</div><div class="ti cn">${esc(b.title)}</div><div class="li">${esc(b.lock || '')}</div></div>`).join('')}</section>
|
|
457
|
+
<div class="row">
|
|
458
|
+
<section class="tile" style="flex:3 1 520px;gap:16px">
|
|
459
|
+
<div class="h">吞下去的都是什么</div>
|
|
460
|
+
<div><div class="sub" style="margin-bottom:8px">全部 token</div><div class="stack">${segs(parts, total)}</div></div>
|
|
461
|
+
<div><div class="sub" style="margin-bottom:8px">去掉缓存读取,放大看剩下的 ${pct(fresh, total)}</div><div class="stack">${segs(parts.slice(1), fresh)}</div></div>
|
|
462
|
+
<div class="legend">${parts.map(([name, v, color, desc]) => `<div class="it"><span class="sw" style="background:${color}"></span>
|
|
463
|
+
<div><b>${name} · ${fmt(v)}</b><span>${pct(v, total)} · ${desc}</span></div></div>`).join('')}</div>
|
|
464
|
+
${facts.length ? `<div class="chips" style="margin-top:0">${facts.join('')}</div>` : ''}
|
|
465
|
+
</section>
|
|
466
|
+
${effort}
|
|
467
|
+
</div>
|
|
468
|
+
<div class="row">
|
|
469
|
+
<section class="tile" style="flex:1 1 420px;gap:18px"><div class="h">谁在干活(模型)</div>${rankList(c.byM, (k) => modelName(DATA.models[k]), 'var(--yellow)', 6)}</section>
|
|
470
|
+
<section class="tile" style="flex:1 1 420px;gap:18px"><div class="h">花在哪了(项目)</div>${rankList(c.byP, (k) => DATA.projects[k], 'var(--blue)', 8)}</section>
|
|
471
|
+
</div>
|
|
472
|
+
<div class="row">
|
|
473
|
+
${heatTile(c)}
|
|
474
|
+
<section class="tile" style="flex:2 1 360px;gap:12px">
|
|
475
|
+
<div class="head"><div class="h">最爱用的工具</div><div class="sub">调用次数</div></div>
|
|
476
|
+
${c.tools.slice(0, 10).map(([name, n]) => `<div class="trow"><div class="nm" title="${esc(name)}">${esc(name)}</div>
|
|
477
|
+
<div class="meter thin"><i style="width:${Math.max(1.5, 100 * n / toolMax).toFixed(1)}%;background:var(--orange)"></i></div><div class="ct">${num(n)}</div></div>`).join('') || '<div class="sub">这段时间没有工具调用</div>'}
|
|
478
|
+
</section>
|
|
479
|
+
</div>
|
|
480
|
+
<section class="tile" style="gap:16px">
|
|
481
|
+
<div class="head"><div class="h">会话马拉松榜</div><div class="sub">点一行看这个会话的上下文过山车</div></div>
|
|
482
|
+
<div class="scroll"><div class="slist">${top.map((s, i) => {
|
|
483
|
+
const sp = spanText(s.start, s.end);
|
|
484
|
+
return `<a class="srow" href="#/s/${encodeURIComponent(s.id)}">
|
|
485
|
+
<div class="medal" style="background:${medals[i] || 'var(--white)'}">${i + 1}</div>
|
|
486
|
+
<div class="ell" title="${esc(DATA.projects[s.p])}">${esc(DATA.projects[s.p])}</div>
|
|
487
|
+
<div class="lt">${when(s.start)} → ${when(s.end)}</div>
|
|
488
|
+
<div>${sp[0]} ${sp[1]}</div>
|
|
489
|
+
<div class="meter thin"><i style="width:${(100 * sum4(s.tok) / sMax).toFixed(1)}%;background:var(--blue)"></i></div>
|
|
490
|
+
<div class="r" style="font-weight:800">${fmt(sum4(s.tok))}</div>
|
|
491
|
+
<div class="r lt">${hitOf(s.tok).toFixed(2)}%</div>
|
|
492
|
+
<div class="r lt">${num(s.req)} 次</div></a>`;
|
|
493
|
+
}).join('') || '<div class="sub">这段时间没有会话</div>'}</div></div>
|
|
494
|
+
<div class="sub">续接或分叉出来的会话会带着前半段记录,所以各会话相加会超过总量。</div>
|
|
495
|
+
</section>` + footer();
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
// ---------------------------------------------------------------- 会话详情页
|
|
499
|
+
|
|
500
|
+
async function renderSession(id) {
|
|
501
|
+
const ranked = [...DATA.sessions].sort((a, b) => sum4(b.tok) - sum4(a.tok));
|
|
502
|
+
const idx = ranked.findIndex((s) => s.id === id);
|
|
503
|
+
const back = '<a class="pill back" href="#/">← 返回总览</a>';
|
|
504
|
+
if (idx < 0) {
|
|
505
|
+
app.innerHTML = header(back) + '<section class="tile empty"><div class="h">找不到这个会话</div><div class="cap">它可能在重新扫描后不见了。</div></section>' + footer();
|
|
506
|
+
return;
|
|
507
|
+
}
|
|
508
|
+
const s = ranked[idx];
|
|
509
|
+
let det = { n: 0, per: 1, ctx: [], drop: [] };
|
|
510
|
+
try {
|
|
511
|
+
const r = await fetch('/api/session?id=' + encodeURIComponent(id));
|
|
512
|
+
if (r.ok) det = await r.json();
|
|
513
|
+
} catch (e) { /* 详情图画不出来时,其余部分照常显示 */ }
|
|
514
|
+
if (location.hash !== '#/s/' + encodeURIComponent(id)) return;
|
|
515
|
+
|
|
516
|
+
const total = sum4(s.tok);
|
|
517
|
+
const allIn = s.tok[0] + s.tok[2] + s.tok[3];
|
|
518
|
+
const sp = spanText(s.start, s.end);
|
|
519
|
+
const all = compute('all');
|
|
520
|
+
const allRatio = all.S[1] ? Math.round((all.S[0] + all.S[2] + all.S[3]) / all.S[1]) : 0;
|
|
521
|
+
const ratio = s.tok[1] ? Math.round(allIn / s.tok[1]) : 0;
|
|
522
|
+
const mainReq = s.req - s.subReq, mainTok = total - s.subTok;
|
|
523
|
+
const top = Math.max(1, ...det.ctx);
|
|
524
|
+
const unit = top >= 2e5 ? 2e5 : top >= 4e4 ? 4e4 : 1e4;
|
|
525
|
+
const yMax = Math.ceil(top / unit) * unit;
|
|
526
|
+
const cols = det.ctx.map((v, i) => `<div class="col"><div class="tg">${det.drop[i] ? '压缩' : ''}</div>
|
|
527
|
+
<div class="b${det.drop[i] ? ' pk' : ''}" title="第 ${num(i * det.per + 1)} 次请求附近 · 上下文约 ${fmt(v)}${det.drop[i] ? ' · 这一段压缩过' : ''}" style="height:${Math.max(2, Math.round(250 * v / yMax))}px"></div></div>`).join('');
|
|
528
|
+
const parts = [['缓存读取', s.tok[3], COLORS.cr], ['缓存写入', s.tok[2], COLORS.cw], ['输出', s.tok[1], COLORS.out], ['新输入', s.tok[0], COLORS.inp]];
|
|
529
|
+
const kpis = [
|
|
530
|
+
['缓存命中率', hitOf(s.tok).toFixed(2) + '%', '整体是 ' + hitOf(all.S).toFixed(2) + '%', 'var(--mint)'],
|
|
531
|
+
['写出', fmt(s.tok[1]), '平均每次请求 ' + num(s.tok[1] / (s.req || 1)) + ' 个', 'var(--white)'],
|
|
532
|
+
['每轮读取', fmt(s.tok[3] / (s.req || 1)), '缓存读取 ÷ 请求次数', 'var(--white)'],
|
|
533
|
+
['读写比', ratio ? num(ratio) + ' : 1' : '—', '整体是 ' + (allRatio ? num(allRatio) + ' : 1' : '—'), 'var(--sky)'],
|
|
534
|
+
];
|
|
535
|
+
app.innerHTML = header(back + `<div class="tag" style="transform:none;font-size:15px">马拉松榜 第 ${idx + 1} 名 · ${esc(DATA.projects[s.p])} · ${esc(modelName(DATA.models[s.m]))}</div>`) + `
|
|
536
|
+
<div class="row">
|
|
537
|
+
<section class="tile hero">
|
|
538
|
+
<div class="h">这个会话前后跨了</div>
|
|
539
|
+
<div class="line"><div class="n">${sp[0]}</div><div class="u cn">${sp[1]}</div><div class="t">吞了 ${fmt(total)}个 token</div></div>
|
|
540
|
+
<div class="chips"><div class="chip">${when(s.start)} → ${when(s.end)}</div><div class="chip">${num(s.req)} 次请求</div><div class="chip">占全部用量的 ${pct(total, all.total, 1)}</div></div>
|
|
541
|
+
</section>
|
|
542
|
+
<section class="tile side" style="background:var(--pink);gap:10px">
|
|
543
|
+
<div class="h">上下文最高涨到</div>
|
|
544
|
+
<div class="n">${fmt(s.peak)}</div>
|
|
545
|
+
<div class="cap">${s.drops ? `中途自动压缩了 ${s.drops} 次,每次回落后再重新往上爬。` : '中途没有触发过压缩。'}</div>
|
|
546
|
+
</section>
|
|
547
|
+
</div>
|
|
548
|
+
<section class="kpis" aria-label="会话指标">${kpis.map(([k, v, sub, bg]) =>
|
|
549
|
+
`<div class="kpi" style="background:${bg}"><div class="k">${k}</div><div class="v">${v}</div><div class="s">${sub}</div></div>`).join('')}</section>
|
|
550
|
+
<section class="tile">
|
|
551
|
+
<div class="head"><div class="h">上下文过山车</div><div class="sub">每根柱子约 ${num(det.per)} 次请求 · 橙色 = 这一段压缩过</div></div>
|
|
552
|
+
${det.ctx.length ? `<div class="scroll"><div class="ctxwrap">
|
|
553
|
+
<div class="yax"><span>${fmt(yMax)}</span><span>${fmt(yMax / 2)}</span><span>0</span></div>
|
|
554
|
+
<div style="flex:1 1 auto;display:flex;flex-direction:column;gap:6px">
|
|
555
|
+
<div class="bars ctx">${cols}</div>
|
|
556
|
+
<div style="display:flex;justify-content:space-between" class="sub"><span>第 1 次请求</span><span>第 ${num(Math.ceil(det.n / 2))} 次</span><span>第 ${num(det.n)} 次</span></div>
|
|
557
|
+
</div></div></div>` : '<div class="sub">这个会话没有主线请求。</div>'}
|
|
558
|
+
<div class="cap">每次请求都要把当时的整段上下文读一遍,所以柱子越高,那一段每轮就越贵。</div>
|
|
559
|
+
</section>
|
|
560
|
+
<div class="row">
|
|
561
|
+
<section class="tile" style="flex:3 1 460px;gap:16px">
|
|
562
|
+
<div class="h">这一单吞了什么</div>
|
|
563
|
+
${parts.map(([name, v, color]) => `<div class="rank"><div class="top" style="font-size:15px;font-weight:700">
|
|
564
|
+
<span style="display:flex;align-items:center;gap:10px"><span class="sw" style="margin:0;background:${color}"></span>${name}</span><span>${fmt(v)} · ${pct(v, total)}</span></div>
|
|
565
|
+
<div class="meter"><i style="width:${Math.max(1, 100 * v / (total || 1)).toFixed(2)}%;background:${color};border-right:0"></i></div></div>`).join('')}
|
|
566
|
+
</section>
|
|
567
|
+
<section class="tile" style="flex:2 1 340px;gap:16px">
|
|
568
|
+
<div class="h">主线 vs 子代理</div>
|
|
569
|
+
<div class="duo">
|
|
570
|
+
<div style="background:var(--sky)"><div class="k">主线对话</div><div class="v">${fmt(mainTok)}</div><div class="s">${num(mainReq)} 次${mainReq ? ' · 每次约 ' + fmt(mainTok / mainReq) : ''}</div></div>
|
|
571
|
+
<div style="background:var(--mint)"><div class="k">子代理</div><div class="v">${fmt(s.subTok)}</div><div class="s">${num(s.subReq)} 次${s.subReq ? ' · 每次约 ' + fmt(s.subTok / s.subReq) : ''}</div></div>
|
|
572
|
+
</div>
|
|
573
|
+
<div class="cap">子代理每次从干净的上下文起步,单次请求通常比主线便宜。</div>
|
|
574
|
+
<div class="tip">省 token 小贴士:换任务时开个新会话,比在越来越长的会话里接着聊更省。</div>
|
|
575
|
+
</section>
|
|
576
|
+
</div>` + footer();
|
|
577
|
+
window.scrollTo(0, 0);
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
// ---------------------------------------------------------------- 启动
|
|
581
|
+
|
|
582
|
+
function route() {
|
|
583
|
+
if (!DATA) return;
|
|
584
|
+
const m = location.hash.match(/^#\/s\/(.+)$/);
|
|
585
|
+
if (m) renderSession(decodeURIComponent(m[1]));
|
|
586
|
+
else renderHome();
|
|
587
|
+
}
|
|
588
|
+
async function load(rescan) {
|
|
589
|
+
state.busy = true;
|
|
590
|
+
if (DATA) route();
|
|
591
|
+
try {
|
|
592
|
+
const r = await fetch('/api/data' + (rescan ? '?rescan=1' : ''));
|
|
593
|
+
DATA = await r.json();
|
|
594
|
+
} catch (e) {
|
|
595
|
+
if (!DATA) app.innerHTML = '<div class="tile empty"><div class="h">连不上本机服务</div><div class="cap">请确认 tuntu 还在运行,然后刷新页面。</div></div>';
|
|
596
|
+
}
|
|
597
|
+
state.busy = false;
|
|
598
|
+
route();
|
|
599
|
+
}
|
|
600
|
+
app.addEventListener('click', (e) => {
|
|
601
|
+
const b = e.target.closest('button');
|
|
602
|
+
if (!b) return;
|
|
603
|
+
if (b.dataset.range) { state.range = b.dataset.range; route(); }
|
|
604
|
+
else if (b.dataset.metric) { state.metric = b.dataset.metric; route(); }
|
|
605
|
+
else if (b.dataset.act === 'rescan' && !state.busy) load(true);
|
|
606
|
+
});
|
|
607
|
+
window.addEventListener('hashchange', route);
|
|
608
|
+
load(false);
|
|
609
|
+
</script>
|
|
610
|
+
</body>
|
|
611
|
+
</html>
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: tuntu
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: 吞吐:本机 Claude Code token 用量看板。A local, zero-dependency dashboard for Claude Code token usage.
|
|
5
|
+
License-Expression: MIT
|
|
6
|
+
Keywords: claude,claude-code,tokens,usage,dashboard
|
|
7
|
+
Classifier: Environment :: Console
|
|
8
|
+
Classifier: Environment :: Web Environment
|
|
9
|
+
Classifier: Intended Audience :: Developers
|
|
10
|
+
Classifier: Natural Language :: Chinese (Simplified)
|
|
11
|
+
Classifier: Operating System :: OS Independent
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Topic :: Utilities
|
|
14
|
+
Requires-Python: >=3.9
|
|
15
|
+
Description-Content-Type: text/markdown
|
|
16
|
+
License-File: LICENSE
|
|
17
|
+
Dynamic: license-file
|
|
18
|
+
|
|
19
|
+
# 吞吐 tuntu
|
|
20
|
+
|
|
21
|
+
我的 Token 都去哪了?——一个本机运行的 Claude Code token 用量看板。
|
|
22
|
+
|
|
23
|
+
A local, zero-dependency dashboard for your Claude Code token usage. It reads the session logs already on your machine and shows them in a playful one-page dashboard. Nothing leaves your computer. (The UI is in Chinese.)
|
|
24
|
+
|
|
25
|
+
## 运行
|
|
26
|
+
|
|
27
|
+
需要 Python 3.9 或更高版本,没有任何第三方依赖。
|
|
28
|
+
|
|
29
|
+
不用安装,一行运行(需要 [uv](https://docs.astral.sh/uv/)):
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
uvx tuntu
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
或者装成命令:
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
pipx install tuntu
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
tuntu
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
也可以直接从源码运行,不用安装:
|
|
46
|
+
|
|
47
|
+
```bash
|
|
48
|
+
git clone <本仓库地址>
|
|
49
|
+
cd <仓库目录>
|
|
50
|
+
python3 -m tuntu
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
它会扫描 `~/.claude/projects` 下的会话日志,然后在浏览器里打开 `http://127.0.0.1:8787/`。
|
|
54
|
+
|
|
55
|
+
没有日志,或者只是想先看看长什么样(也适合截图):
|
|
56
|
+
|
|
57
|
+
```bash
|
|
58
|
+
tuntu --demo
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
| 参数 | 作用 |
|
|
62
|
+
|---|---|
|
|
63
|
+
| `--demo` | 用虚构数据演示,不读取任何日志 |
|
|
64
|
+
| `--dir PATH` | 指定日志目录(默认 `~/.claude/projects`,也会识别 `CLAUDE_CONFIG_DIR`) |
|
|
65
|
+
| `--port N` | 端口,默认 8787,被占用时自动往后找 |
|
|
66
|
+
| `--no-open` | 不自动打开浏览器 |
|
|
67
|
+
| `--web-fonts` | 从 Google Fonts 加载展示字体,观感更接近设计稿(会产生外网请求) |
|
|
68
|
+
| `--json` | 把聚合结果打印成 JSON 后退出 |
|
|
69
|
+
| `--version` | 显示版本号 |
|
|
70
|
+
|
|
71
|
+
## 能看到什么
|
|
72
|
+
|
|
73
|
+
- **总量**:一共吞了多少 token,平均每秒多少,日均多少。
|
|
74
|
+
- **读写比**:每写出 1 个 token,先读了多少个。
|
|
75
|
+
- **缓存命中率**:缓存读取 ÷ 全部输入。
|
|
76
|
+
- **每日吞吐**:可以切换看全部、输出、请求次数或命中率。
|
|
77
|
+
- **24 小时钟、一周作息表**:你什么时候用得最多。
|
|
78
|
+
- **构成**:缓存读取、缓存写入、输出、新输入各占多少;缓存写入里 1 小时档的占比;子代理的占比。
|
|
79
|
+
- **推理强度、模型、项目、工具**:各自的分布。
|
|
80
|
+
- **会话马拉松榜**:最耗 token 的会话。点进去能看到上下文长度随对话增长、被自动压缩的过程。
|
|
81
|
+
- **成就贴纸**:夜猫子、缓存大师、马拉松选手……
|
|
82
|
+
|
|
83
|
+
时间范围可以切换:全部、近 30 天、近 7 天、今天。
|
|
84
|
+
|
|
85
|
+
## 隐私
|
|
86
|
+
|
|
87
|
+
- 服务只监听 `127.0.0.1`,并且拒绝 Host 不是本机的请求。
|
|
88
|
+
- 默认不发出任何外网请求,页面带有禁止外部资源的 CSP。只有加了 `--web-fonts` 才会去请求 Google Fonts。
|
|
89
|
+
- 只取用日志里的用量数字、时间、模型名、工具名和工作目录名;对话内容不会被保存、显示或发送到任何地方。
|
|
90
|
+
- 页面上的项目只显示目录名,不显示完整路径;日志目录里的家目录显示为 `~`。
|
|
91
|
+
- 不写任何文件,不做缓存,关掉就什么都不留。
|
|
92
|
+
|
|
93
|
+
分享截图前请留意:项目名和工具名来自你自己的机器。想要一张不含个人信息的图,用 `--demo`。
|
|
94
|
+
|
|
95
|
+
## 数字是怎么算的
|
|
96
|
+
|
|
97
|
+
- 数据来自 Claude Code 写在本机的会话日志(`*.jsonl`)里每次请求的 `usage` 字段。
|
|
98
|
+
- 同一条消息在日志里会出现多行,按消息去重后再累加。
|
|
99
|
+
- 续接或分叉出来的会话会带着前半段记录。算总量时每条消息只算一次;但会话榜里每个会话按它自己的全部记录算,所以各会话相加会超过总量。
|
|
100
|
+
- 项目按会话启动时的目录归类,worktree 里的会话归到原项目。
|
|
101
|
+
- “会话跨度”是第一次请求到最后一次请求之间的时间,中间可能隔了很久没用。
|
|
102
|
+
- 时间按本机时区显示。
|
|
103
|
+
- Claude Code 默认只保留一段时间的日志(由 `cleanupPeriodDays` 设置决定),更早的用量已经不在本机,这里也就看不到。
|
|
104
|
+
- 这里只统计本机的 Claude Code,不包含 claude.ai 网页或手机上的对话。
|
|
105
|
+
- 这些数字是从本机日志估算的,不是账单。以官方的用量页面为准。
|
|
106
|
+
|
|
107
|
+
## 目录结构
|
|
108
|
+
|
|
109
|
+
```
|
|
110
|
+
tuntu/cli.py 扫描日志、聚合、本机 HTTP 服务(只用标准库)
|
|
111
|
+
tuntu/web/index.html 页面(单文件,原生 JS,无依赖)
|
|
112
|
+
pyproject.toml 打包配置
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
## 发布新版本(维护者)
|
|
116
|
+
|
|
117
|
+
1. 改 `tuntu/__init__.py` 里的 `__version__`。
|
|
118
|
+
2. 构建并上传:
|
|
119
|
+
|
|
120
|
+
```bash
|
|
121
|
+
uv build
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
```bash
|
|
125
|
+
uv publish
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
## 说明
|
|
129
|
+
|
|
130
|
+
这是个人项目,与 Anthropic 无关。Claude 和 Claude Code 是 Anthropic 的商标。
|
|
131
|
+
|
|
132
|
+
## 许可证
|
|
133
|
+
|
|
134
|
+
MIT,见 [LICENSE](LICENSE)。
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
tuntu/__init__.py,sha256=vHtqaYlbdeMFwKlpAa-8kwkZ8oOVyoHGHhCzKW4Cc6M,89
|
|
2
|
+
tuntu/__main__.py,sha256=bYt9eEaoRQWdejEHFD8REx9jxVEdZptECFsV7F49Ink,30
|
|
3
|
+
tuntu/cli.py,sha256=SVf8Y9AsMb3o-Bvkbqy5Iysq0yCMjjM57BK1_Npxgto,19389
|
|
4
|
+
tuntu/web/index.html,sha256=27y_6dZEp0WgFl9a82BZzoHV44NZjVjInLg8B6KYluo,40319
|
|
5
|
+
tuntu-0.1.0.dist-info/licenses/LICENSE,sha256=K9VX4dr242WEsDy5R0lzfSUXItlhjeEMt_pUqeVKiH0,1075
|
|
6
|
+
tuntu-0.1.0.dist-info/METADATA,sha256=6AAnfdCTnd9MakrANZ7FqLIMAV7gmme4a5HvpwL04fM,5124
|
|
7
|
+
tuntu-0.1.0.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
|
|
8
|
+
tuntu-0.1.0.dist-info/entry_points.txt,sha256=T3bSvUOjTnQzGQy0tkTZMi6hqvUdqqDlQxGA_6pWSm4,41
|
|
9
|
+
tuntu-0.1.0.dist-info/top_level.txt,sha256=ZeiQNOImlf3NtyffiSBGx1Kv8dU-hHuNPG-g8LkgZw0,6
|
|
10
|
+
tuntu-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 tuntu contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
tuntu
|