usage-cli 0.29.32__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.
- adapters/__init__.py +5 -0
- adapters/agy.py +68 -0
- adapters/claude.py +215 -0
- adapters/codex.py +209 -0
- adapters/rate_limits.py +76 -0
- adapters/registry.py +17 -0
- adapters/types.py +139 -0
- agy_disk_cache.py +135 -0
- agy_loader.py +416 -0
- agy_quota_probe.py +748 -0
- agy_window_keeper.py +185 -0
- analyzer/__init__.py +5 -0
- analyzer/aggregator.py +139 -0
- analyzer/blocks.py +80 -0
- analyzer/diagnoser.py +638 -0
- analyzer/insights.py +277 -0
- analyzer/persona_loader.py +199 -0
- analyzer/reporter.py +989 -0
- analyzer/subscription.py +108 -0
- burn_rate.py +75 -0
- cache_quarantine.py +50 -0
- codex_disk_cache.py +227 -0
- codex_events.py +136 -0
- codex_fork_replay.py +111 -0
- codex_loader.py +1426 -0
- codex_paths.py +20 -0
- critter_frames.py +26 -0
- discussion_bridge.py +1196 -0
- discussion_cli.py +844 -0
- discussion_session.py +622 -0
- discussion_usage.py +13 -0
- discussion_window.py +955 -0
- disk_cache_common.py +132 -0
- disk_cache_lifecycle.py +39 -0
- doctor.py +452 -0
- fsevents_watch.py +207 -0
- history_disk_cache.py +110 -0
- history_loader.py +416 -0
- i18n.py +88 -0
- jsonl_limits.py +17 -0
- jsonl_utils.py +40 -0
- login_item.py +154 -0
- main.py +387 -0
- menubar.py +1201 -0
- menubar_actions.py +204 -0
- menubar_agy.py +193 -0
- menubar_chrome.py +156 -0
- menubar_menu.py +169 -0
- menubar_notify.py +102 -0
- menubar_popover.py +233 -0
- menubar_prefs.py +118 -0
- menubar_refresh.py +285 -0
- menubar_state.py +1200 -0
- menubar_title.py +157 -0
- menubar_update.py +123 -0
- panel_window.py +78 -0
- panel_window_state.py +159 -0
- panels/__init__.py +186 -0
- panels/base.py +83 -0
- panels/dynamic_height.py +140 -0
- panels/payload.py +178 -0
- panels/web_panel.py +513 -0
- panels/window_drag.py +56 -0
- prefs.py +44 -0
- pricing.py +452 -0
- project_resolver.py +112 -0
- service_status.py +383 -0
- session_hooks.py +1154 -0
- setup_app.py +171 -0
- setup_hook.py +1011 -0
- statusline_settings.py +160 -0
- talent_market_bridge.py +243 -0
- time_utils.py +24 -0
- tui.py +288 -0
- tui_sprite.py +206 -0
- ui/__init__.py +5 -0
- ui/html_report.py +923 -0
- ui/report_scripts.py +251 -0
- ui/report_styles.py +370 -0
- ui/tables.py +888 -0
- update_checker.py +156 -0
- update_gate.py +66 -0
- update_release_notes.py +49 -0
- usage_cli-0.29.32.data/data/share/usage/i18n.json +2427 -0
- usage_cli-0.29.32.dist-info/METADATA +223 -0
- usage_cli-0.29.32.dist-info/RECORD +109 -0
- usage_cli-0.29.32.dist-info/WHEEL +5 -0
- usage_cli-0.29.32.dist-info/entry_points.txt +3 -0
- usage_cli-0.29.32.dist-info/licenses/LICENSE +663 -0
- usage_cli-0.29.32.dist-info/top_level.txt +80 -0
- usage_cli.py +827 -0
- usage_client.py +487 -0
- usage_diagnosis_snapshot.py +143 -0
- usage_dir_sweeper.py +100 -0
- usage_lang.py +79 -0
- usage_logging.py +75 -0
- usage_notifications.py +96 -0
- usage_rate.py +97 -0
- usage_session_resume.py +913 -0
- usage_statusline.py +810 -0
- usage_statusline_agy.py +397 -0
- usage_statusline_forwarder.py +88 -0
- usage_terse_mode.py +223 -0
- usage_terse_reminder.py +151 -0
- win_login_item.py +53 -0
- window_keeper.py +264 -0
- windows_watch.py +443 -0
- wintray.py +2014 -0
- wintray_menu.py +136 -0
ui/html_report.py
ADDED
|
@@ -0,0 +1,923 @@
|
|
|
1
|
+
# SPDX-License-Identifier: AGPL-3.0-only
|
|
2
|
+
# Copyright (C) 2026 lollapalooza <https://github.com/aqua5230>
|
|
3
|
+
#
|
|
4
|
+
# Part of "usage". Free software licensed under the GNU Affero General Public
|
|
5
|
+
# License v3.0 only; see the LICENSE file for full terms and the warranty disclaimer.
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import base64
|
|
10
|
+
import html
|
|
11
|
+
import json
|
|
12
|
+
import math
|
|
13
|
+
import os
|
|
14
|
+
import csv
|
|
15
|
+
import subprocess
|
|
16
|
+
import sys
|
|
17
|
+
import webbrowser
|
|
18
|
+
from datetime import date, datetime
|
|
19
|
+
from functools import lru_cache
|
|
20
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
21
|
+
from io import StringIO
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
from typing import Any, Mapping, cast
|
|
24
|
+
|
|
25
|
+
from analyzer.reporter import (
|
|
26
|
+
AgentReportRow,
|
|
27
|
+
DailyTrendPoint,
|
|
28
|
+
ReportData,
|
|
29
|
+
SummaryReportData,
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
from i18n import _t as _i18n_t, packaged_resource_path
|
|
33
|
+
from usage_lang import detect_lang
|
|
34
|
+
from ui.report_scripts import HTML_TO_IMAGE_UMD, REPORT_JS_TEMPLATE
|
|
35
|
+
from ui.report_styles import REPORT_CSS
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
# Rough USD→TWD rate for the zh-TW cost hint only. A display estimate (prefixed
|
|
40
|
+
# with ≈), not a live FX lookup — bump it if it drifts too far from reality.
|
|
41
|
+
_USD_TO_TWD = 32
|
|
42
|
+
|
|
43
|
+
def _t(lang: str, key: str, **kwargs: object) -> str:
|
|
44
|
+
return _i18n_t(lang, f"report_{key}", **kwargs)
|
|
45
|
+
|
|
46
|
+
def _fmt_tokens(value: int) -> str:
|
|
47
|
+
if value >= 1_000_000_000:
|
|
48
|
+
return f"{value / 1_000_000_000:.2f}B"
|
|
49
|
+
if value >= 1_000_000:
|
|
50
|
+
return f"{value / 1_000_000:.1f}M"
|
|
51
|
+
if value >= 1_000:
|
|
52
|
+
return f"{value / 1_000:.1f}K"
|
|
53
|
+
return str(value)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _fmt_cost(value: float | None) -> str:
|
|
57
|
+
if value is None:
|
|
58
|
+
return "—"
|
|
59
|
+
return f"${value:,.4f}" if 0 < value < 1 else f"${value:,.2f}"
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _fmt_duration(minutes: float) -> str:
|
|
63
|
+
if minutes >= 60:
|
|
64
|
+
return f"{int(minutes // 60)}h {int(minutes % 60)}m"
|
|
65
|
+
return f"{int(minutes)}m"
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _fmt_int(value: int) -> str:
|
|
69
|
+
return f"{value:,}"
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _version() -> str:
|
|
73
|
+
try:
|
|
74
|
+
return version("usage-cli")
|
|
75
|
+
except PackageNotFoundError:
|
|
76
|
+
return "dev"
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _detect_lang(env: Mapping[str, str] | None = None) -> str:
|
|
80
|
+
return detect_lang(env)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _escape(value: object) -> str:
|
|
85
|
+
return html.escape(str(value))
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _display_name(value: object, lang: str) -> str:
|
|
89
|
+
text = str(value) if value else _t(lang, "unknown")
|
|
90
|
+
return _t(lang, "unknown") if text == "unknown" else text
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _localized_text(value: object, lang: str) -> str:
|
|
94
|
+
if not isinstance(value, dict):
|
|
95
|
+
return ""
|
|
96
|
+
for key in (lang, "en"):
|
|
97
|
+
localized = value.get(key)
|
|
98
|
+
if isinstance(localized, str) and localized:
|
|
99
|
+
return localized
|
|
100
|
+
for localized in value.values():
|
|
101
|
+
if isinstance(localized, str) and localized:
|
|
102
|
+
return localized
|
|
103
|
+
return ""
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _section(title: str, body: str, class_name: str = "") -> str:
|
|
107
|
+
classes = "section" if not class_name else f"section {class_name}"
|
|
108
|
+
return f"""
|
|
109
|
+
<section class="{classes}">
|
|
110
|
+
<div class="prompt"><span>[usage]></span> {html.escape(title)}</div>
|
|
111
|
+
<div class="rule" aria-hidden="true">────────────────────────────────────────────────────────</div>
|
|
112
|
+
{body}
|
|
113
|
+
</section>
|
|
114
|
+
"""
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _empty_line(label: str) -> str:
|
|
118
|
+
return f'<div class="empty">→ {html.escape(label)}</div>'
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _rank_line(name: str, pct: float, tokens: int, cost: float | None, lang: str) -> str:
|
|
122
|
+
return (
|
|
123
|
+
'<div class="rank-line">'
|
|
124
|
+
f'<span class="arrow">→</span><span class="name">{html.escape(name)}</span>'
|
|
125
|
+
f'<span class="pct" data-label="{_escape(_t(lang, "share"))}">{pct:>5.1f}%</span>'
|
|
126
|
+
f'<span class="tokens" data-label="{_escape(_t(lang, "tokens"))}">{_fmt_tokens(tokens)}</span>'
|
|
127
|
+
f'<span class="cost" data-label="{_escape(_t(lang, "cost"))}">{_fmt_cost(cost)}</span>'
|
|
128
|
+
"</div>"
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _parse_daily_date(value: object) -> date:
|
|
133
|
+
if isinstance(value, date):
|
|
134
|
+
return value
|
|
135
|
+
return date.fromisoformat(str(value)[:10])
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _month_label(month: int, lang: str) -> str:
|
|
139
|
+
return _t(lang, f"contribution_month_{month}")
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _estimate_books(tokens: int) -> int:
|
|
143
|
+
return max(1, round(tokens / 80_000)) if tokens > 0 else 0
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
@lru_cache(maxsize=4)
|
|
147
|
+
def _sprite_data_uri(beast: str) -> str:
|
|
148
|
+
asset_path = packaged_resource_path(
|
|
149
|
+
f"critters/{beast}/wrapped.png",
|
|
150
|
+
Path(__file__).resolve().parent.parent
|
|
151
|
+
/ "assets"
|
|
152
|
+
/ "critters"
|
|
153
|
+
/ beast
|
|
154
|
+
/ "wrapped.png",
|
|
155
|
+
)
|
|
156
|
+
encoded = base64.b64encode(asset_path.read_bytes()).decode("ascii")
|
|
157
|
+
return f"data:image/png;base64,{encoded}"
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _weekly_trend(daily: list[DailyTrendPoint]) -> list[dict[str, int | float]]:
|
|
161
|
+
weekly: dict[tuple[int, int], dict[str, int | float]] = {}
|
|
162
|
+
for day in daily:
|
|
163
|
+
parsed = _parse_daily_date(day["date"])
|
|
164
|
+
iso_year, iso_week, _weekday = parsed.isocalendar()
|
|
165
|
+
key = (iso_year, iso_week)
|
|
166
|
+
bucket = weekly.setdefault(key, {"year": iso_year, "week": iso_week, "tokens": 0, "cost": 0.0})
|
|
167
|
+
bucket["tokens"] = int(bucket["tokens"]) + int(day.get("tokens", 0))
|
|
168
|
+
bucket["cost"] = float(bucket["cost"]) + float(day.get("cost", 0.0))
|
|
169
|
+
return [weekly[key] for key in sorted(weekly)]
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def _trend_summary(weekly: list[dict[str, int | float]], lang: str) -> str:
|
|
173
|
+
if len(weekly) < 2:
|
|
174
|
+
return f"→ {_t(lang, 'trend_compare_first')}"
|
|
175
|
+
|
|
176
|
+
current = int(weekly[-1]["tokens"])
|
|
177
|
+
previous = int(weekly[-2]["tokens"])
|
|
178
|
+
if previous == 0:
|
|
179
|
+
if current == 0:
|
|
180
|
+
return f"→ {_t(lang, 'trend_compare_flat')}"
|
|
181
|
+
return f"→ {_t(lang, 'trend_compare_new')}"
|
|
182
|
+
|
|
183
|
+
pct = round((current - previous) / previous * 100)
|
|
184
|
+
if abs(pct) <= 5:
|
|
185
|
+
return f"→ {_t(lang, 'trend_compare_flat')}"
|
|
186
|
+
if pct > 0:
|
|
187
|
+
return f"→ {_t(lang, 'trend_compare_up', ratio=f'{current / previous:.1f}')}"
|
|
188
|
+
return f"→ {_t(lang, 'trend_compare_down', pct=abs(pct))}"
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
_PALETTE = [
|
|
192
|
+
"#5abfa0", "#8f86c9", "#e0885a", "#78cdb2",
|
|
193
|
+
"#aaa3d4", "#dca080", "#3f9f82", "#7168ad",
|
|
194
|
+
]
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def _trend_delta(current: int, previous: int, lang: str) -> tuple[str, str]:
|
|
198
|
+
if previous == 0:
|
|
199
|
+
if current == 0:
|
|
200
|
+
return "flat", "→ 0%"
|
|
201
|
+
return "up", f"↗ {_t(lang, 'trend_marker_new')}"
|
|
202
|
+
|
|
203
|
+
pct = round((current - previous) / previous * 100)
|
|
204
|
+
if abs(pct) <= 5:
|
|
205
|
+
return "flat", "→ 0%"
|
|
206
|
+
if pct > 0:
|
|
207
|
+
return "up", f"↗ +{pct}%"
|
|
208
|
+
return "down", f"↘ {pct}%"
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def _trend_ascii(daily: list[DailyTrendPoint], lang: str) -> str:
|
|
212
|
+
weekly = _weekly_trend(daily)
|
|
213
|
+
max_tokens = max((int(week["tokens"]) for week in weekly), default=0)
|
|
214
|
+
rows = []
|
|
215
|
+
for idx, week in enumerate(weekly):
|
|
216
|
+
tokens = int(week["tokens"])
|
|
217
|
+
filled = max(1, round(tokens / max_tokens * 12)) if max_tokens and tokens else 0
|
|
218
|
+
bar = "█" * filled
|
|
219
|
+
delta_html = '<span class="delta flat"></span>'
|
|
220
|
+
if idx > 0:
|
|
221
|
+
delta_class, delta_label = _trend_delta(tokens, int(weekly[idx - 1]["tokens"]), lang)
|
|
222
|
+
delta_html = f'<span class="delta {delta_class}">{_escape(delta_label)}</span>'
|
|
223
|
+
rows.append(
|
|
224
|
+
'<div class="trend-row">'
|
|
225
|
+
f'<span class="week">W{int(week["week"])}</span>'
|
|
226
|
+
f'<b>{bar}</b>'
|
|
227
|
+
f'<em>{_fmt_tokens(tokens)}</em>'
|
|
228
|
+
f"{delta_html}"
|
|
229
|
+
"</div>"
|
|
230
|
+
)
|
|
231
|
+
if not rows:
|
|
232
|
+
return _empty_line(_t(lang, "empty_daily"))
|
|
233
|
+
|
|
234
|
+
trend_rows = "".join(rows)
|
|
235
|
+
summary = f'<div class="trend-summary">{_escape(_trend_summary(weekly, lang))}</div>'
|
|
236
|
+
return f'<div class="trend">{trend_rows}{summary}</div>'
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def _hour_histogram_html(histogram: list[int]) -> str:
|
|
240
|
+
values = [max(0, int(value)) for value in histogram[:24]]
|
|
241
|
+
if len(values) < 24:
|
|
242
|
+
values.extend([0] * (24 - len(values)))
|
|
243
|
+
max_count = max(values, default=0)
|
|
244
|
+
bars = []
|
|
245
|
+
for hour, count in enumerate(values):
|
|
246
|
+
height = max(6, round(count / max_count * 100)) if max_count and count else 0
|
|
247
|
+
class_name = "persona-hour is-peak" if max_count and count == max_count else "persona-hour"
|
|
248
|
+
bars.append(
|
|
249
|
+
f'<div class="{class_name}"'
|
|
250
|
+
f' title="{hour:02d}:00 {count}"'
|
|
251
|
+
f' aria-label="{hour:02d}:00 {count}">'
|
|
252
|
+
f'<span style="height:{height}%"></span>'
|
|
253
|
+
f'<em>{hour:02d}</em>'
|
|
254
|
+
"</div>"
|
|
255
|
+
)
|
|
256
|
+
return f'<div class="persona-hours">{"".join(bars)}</div>'
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def _persona_body(persona: Mapping[str, object] | None, lang: str) -> str:
|
|
260
|
+
if persona is None:
|
|
261
|
+
return _empty_line(_t(lang, "persona_empty"))
|
|
262
|
+
|
|
263
|
+
raw_histogram = persona.get("hour_histogram", [])
|
|
264
|
+
histogram = raw_histogram if isinstance(raw_histogram, list) else []
|
|
265
|
+
values = [max(0, int(value)) if isinstance(value, int) else 0 for value in histogram[:24]]
|
|
266
|
+
if len(values) < 24:
|
|
267
|
+
values.extend([0] * (24 - len(values)))
|
|
268
|
+
if not any(values):
|
|
269
|
+
return _empty_line(_t(lang, "persona_empty"))
|
|
270
|
+
|
|
271
|
+
peak_hours = sorted(
|
|
272
|
+
((count, hour) for hour, count in enumerate(values) if count > 0),
|
|
273
|
+
key=lambda item: (-item[0], item[1]),
|
|
274
|
+
)[:2]
|
|
275
|
+
h1 = f"{peak_hours[0][1]:02d}:00"
|
|
276
|
+
h2 = (
|
|
277
|
+
_t(lang, "persona_caption_second", h2=f"{peak_hours[1][1]:02d}:00")
|
|
278
|
+
if len(peak_hours) > 1
|
|
279
|
+
else ""
|
|
280
|
+
)
|
|
281
|
+
caption = _t(lang, "persona_caption", h1=h1, h2=h2)
|
|
282
|
+
return (
|
|
283
|
+
'<div class="persona-card">'
|
|
284
|
+
f'<h3>{_escape(_t(lang, "persona_active_hours"))}</h3>'
|
|
285
|
+
f'<p class="persona-caption">{_escape(caption)}</p>'
|
|
286
|
+
f'{_hour_histogram_html(values)}'
|
|
287
|
+
'</div>'
|
|
288
|
+
)
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def _donut_svg(items: list[tuple[str, int]], lang: str) -> str:
|
|
292
|
+
data = [(name, tok) for name, tok in items if tok > 0]
|
|
293
|
+
if not data:
|
|
294
|
+
return ""
|
|
295
|
+
total = sum(tok for _, tok in data)
|
|
296
|
+
shown = data[:6]
|
|
297
|
+
rest = sum(tok for _, tok in data[6:])
|
|
298
|
+
if rest > 0:
|
|
299
|
+
shown = [*shown, (_t(lang, "chart_other"), rest)]
|
|
300
|
+
|
|
301
|
+
cx = cy = 80.0
|
|
302
|
+
radius = 60.0
|
|
303
|
+
circ = 2 * math.pi * radius
|
|
304
|
+
segs: list[str] = []
|
|
305
|
+
legend: list[str] = []
|
|
306
|
+
offset = 0.0
|
|
307
|
+
for idx, (name, tok) in enumerate(shown):
|
|
308
|
+
frac = tok / total
|
|
309
|
+
seg_len = circ * frac
|
|
310
|
+
color = _PALETTE[idx % len(_PALETTE)]
|
|
311
|
+
segs.append(
|
|
312
|
+
f'<circle cx="{cx}" cy="{cy}" r="{radius}" fill="none" stroke="{color}" '
|
|
313
|
+
f'stroke-width="22" stroke-dasharray="{seg_len:.2f} {circ - seg_len:.2f}" '
|
|
314
|
+
f'stroke-dashoffset="{-offset:.2f}" transform="rotate(-90 {cx} {cy})"/>'
|
|
315
|
+
)
|
|
316
|
+
offset += seg_len
|
|
317
|
+
legend.append(
|
|
318
|
+
f'<li><span class="dot" style="background:{color}"></span>'
|
|
319
|
+
f'<span class="lg-name">{html.escape(name)}</span>'
|
|
320
|
+
f'<span class="lg-pct">{frac * 100:.1f}%</span></li>'
|
|
321
|
+
)
|
|
322
|
+
center = (
|
|
323
|
+
f'<text x="{cx}" y="{cy - 3}" class="donut-total" text-anchor="middle">{_fmt_tokens(total)}</text>'
|
|
324
|
+
f'<text x="{cx}" y="{cy + 15}" class="donut-sub" text-anchor="middle">tokens</text>'
|
|
325
|
+
)
|
|
326
|
+
return (
|
|
327
|
+
'<div class="donut-wrap">'
|
|
328
|
+
f'<svg class="donut" viewBox="0 0 160 160" role="img" '
|
|
329
|
+
f'aria-label="{_escape(_t(lang, "project_section"))}">{"".join(segs)}{center}</svg>'
|
|
330
|
+
f'<ul class="donut-legend">{"".join(legend)}</ul>'
|
|
331
|
+
'</div>'
|
|
332
|
+
)
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
def _tools_body(
|
|
336
|
+
subs: list[dict[str, str | None]],
|
|
337
|
+
agents: list[AgentReportRow],
|
|
338
|
+
lang: str,
|
|
339
|
+
) -> str:
|
|
340
|
+
"""One card per tool, joining subscription plan with usage by tool name."""
|
|
341
|
+
by_name = {str(sub.get("agent", "")): sub for sub in subs}
|
|
342
|
+
seen: set[str] = set()
|
|
343
|
+
rows: list[str] = []
|
|
344
|
+
|
|
345
|
+
def _plan_html(sub: dict[str, str | None] | None) -> str:
|
|
346
|
+
if not sub:
|
|
347
|
+
return ""
|
|
348
|
+
plan = sub.get("plan")
|
|
349
|
+
since = sub.get("since")
|
|
350
|
+
since_html = (
|
|
351
|
+
f'<span class="sub-since" data-mask>{_escape(_t(lang, "sub_since"))} {_escape(since)}</span>'
|
|
352
|
+
if since
|
|
353
|
+
else ""
|
|
354
|
+
)
|
|
355
|
+
plan_html = f'<span class="sub-plan">{_escape(str(plan))}</span>' if plan else ""
|
|
356
|
+
return plan_html + since_html
|
|
357
|
+
|
|
358
|
+
def _row(name: str, plan_html: str, stats_html: str) -> str:
|
|
359
|
+
return (
|
|
360
|
+
'<div class="tool-row">'
|
|
361
|
+
f'<div class="tool-head"><span class="sub-agent">{_escape(name)}</span>{plan_html}</div>'
|
|
362
|
+
f"{stats_html}"
|
|
363
|
+
"</div>"
|
|
364
|
+
)
|
|
365
|
+
|
|
366
|
+
for agent in agents:
|
|
367
|
+
name = _display_name(agent["name"], lang)
|
|
368
|
+
seen.add(str(agent["name"]))
|
|
369
|
+
stats_html = (
|
|
370
|
+
f'<span class="pct" data-label="{_escape(_t(lang, "share"))}">{float(agent["pct"]):.1f}%</span>'
|
|
371
|
+
f'<span class="tokens" data-label="{_escape(_t(lang, "tokens"))}">{_fmt_tokens(int(agent["tokens"]))}</span>'
|
|
372
|
+
f'<span class="cost" data-label="{_escape(_t(lang, "cost"))}">{_fmt_cost(float(agent["cost"]))}</span>'
|
|
373
|
+
)
|
|
374
|
+
rows.append(_row(name, _plan_html(by_name.get(str(agent["name"]))), stats_html))
|
|
375
|
+
|
|
376
|
+
# Subscriptions for tools that have no usage in this period still get a card.
|
|
377
|
+
for sub_name, sub in by_name.items():
|
|
378
|
+
if sub_name in seen or not sub_name:
|
|
379
|
+
continue
|
|
380
|
+
rows.append(_row(sub_name, _plan_html(sub), "<span></span><span></span><span></span>"))
|
|
381
|
+
|
|
382
|
+
if not rows:
|
|
383
|
+
return _empty_line(_t(lang, "sub_empty"))
|
|
384
|
+
head = (
|
|
385
|
+
'<div class="tools-head">'
|
|
386
|
+
"<span></span>"
|
|
387
|
+
f'<span>{_escape(_t(lang, "share"))}</span>'
|
|
388
|
+
f'<span>{_escape(_t(lang, "tokens"))}</span>'
|
|
389
|
+
f'<span>{_escape(_t(lang, "cost"))}</span>'
|
|
390
|
+
"</div>"
|
|
391
|
+
)
|
|
392
|
+
return f'<div class="tools">{head}{"".join(rows)}</div>'
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
def _narrative(data: ReportData, lang: str) -> str:
|
|
396
|
+
summary = data["summary"]
|
|
397
|
+
daily = data.get("daily_trend", [])
|
|
398
|
+
peak_date = data.get("date_to", "---- -- --")
|
|
399
|
+
peak_tokens = 0
|
|
400
|
+
if daily:
|
|
401
|
+
peak = max(daily, key=lambda day: int(day["tokens"]))
|
|
402
|
+
peak_date = peak["date"]
|
|
403
|
+
peak_tokens = peak["tokens"]
|
|
404
|
+
top_model = data.get("by_model", [{}])[0].get("model", _t(lang, "unknown")) if data.get("by_model") else _t(lang, "unknown")
|
|
405
|
+
return _t(
|
|
406
|
+
lang,
|
|
407
|
+
"narrative",
|
|
408
|
+
tokens=_fmt_tokens(int(summary["total_tokens"])),
|
|
409
|
+
projects=len(data.get("by_project", [])),
|
|
410
|
+
peak_date=str(peak_date),
|
|
411
|
+
peak_tokens=_fmt_tokens(int(peak_tokens)),
|
|
412
|
+
top_model=_display_name(top_model, lang),
|
|
413
|
+
)
|
|
414
|
+
|
|
415
|
+
|
|
416
|
+
def _cost_value(cost_usd: float, lang: str) -> tuple[str, str]:
|
|
417
|
+
main = _fmt_cost(cost_usd)
|
|
418
|
+
sub = f"≈ NT${cost_usd * _USD_TO_TWD:,.0f}" if lang == "zh-TW" else ""
|
|
419
|
+
return main, sub
|
|
420
|
+
|
|
421
|
+
|
|
422
|
+
def _render_cards_section(cards: list[tuple[str, str, str]]) -> str:
|
|
423
|
+
return f"""<section class="cards">{''.join(f'<div class="card"><span>{html.escape(label)}</span><b>{html.escape(value)}</b>' + (f'<i>{html.escape(sub)}</i>' if sub else '') + '</div>' for label, value, sub in cards)}</section>"""
|
|
424
|
+
|
|
425
|
+
|
|
426
|
+
def _summary_cards(summary: SummaryReportData, lang: str) -> list[tuple[str, str, str]]:
|
|
427
|
+
total_tokens = int(summary["total_tokens"])
|
|
428
|
+
messages = int(summary["messages"])
|
|
429
|
+
cost_main, cost_sub = _cost_value(float(summary["cost_usd"]), lang)
|
|
430
|
+
tokens_per_msg = total_tokens // messages if messages else 0
|
|
431
|
+
return [
|
|
432
|
+
(_t(lang, "kpi_tokens"), f"{total_tokens:,}", f"≈ {_fmt_tokens(total_tokens)}"),
|
|
433
|
+
(_t(lang, "kpi_cost"), cost_main, cost_sub),
|
|
434
|
+
(_t(lang, "kpi_sessions"), f'{int(summary["sessions"]):,}', ""),
|
|
435
|
+
(_t(lang, "kpi_messages"), f'{messages:,}', ""),
|
|
436
|
+
(_t(lang, "kpi_active"), f'{int(summary["active_days"])}/{int(summary["total_days"])}', ""),
|
|
437
|
+
(_t(lang, "kpi_productivity"), f"{tokens_per_msg:,}", _t(lang, "kpi_productivity_unit")),
|
|
438
|
+
]
|
|
439
|
+
|
|
440
|
+
|
|
441
|
+
def _render_header(data: ReportData, lang: str, title: str, generated_at: str) -> str:
|
|
442
|
+
return f"""<header>
|
|
443
|
+
<div>
|
|
444
|
+
<div class="eyebrow"><span>$ usage report</span> --period {html.escape(str(data["period_label"]))}<span class="cursor">_</span></div>
|
|
445
|
+
<h1>{html.escape(title)}</h1>
|
|
446
|
+
<p class="narrative">{html.escape(_narrative(data, lang))}</p>
|
|
447
|
+
</div>
|
|
448
|
+
<div class="header-actions">
|
|
449
|
+
<div class="meta">{html.escape(_t(lang, "generated"))} {html.escape(generated_at)}<br>usage {_escape(_t(lang, "version"))} {_escape(_version())}</div>
|
|
450
|
+
<button class="share-trigger" type="button" data-share-open><span aria-hidden="true">↗</span>{html.escape(_t(lang, "share_button_label"))}</button>
|
|
451
|
+
</div>
|
|
452
|
+
</header>"""
|
|
453
|
+
|
|
454
|
+
|
|
455
|
+
def _render_share_dialog(lang: str) -> str:
|
|
456
|
+
return f"""<dialog class="share-dialog" data-share-dialog>
|
|
457
|
+
<div class="share-modal">
|
|
458
|
+
<button class="share-close" type="button" data-share-close aria-label="{html.escape(_t(lang, "share_close"))}">×</button>
|
|
459
|
+
<h2>{html.escape(_t(lang, "share_modal_title"))}</h2>
|
|
460
|
+
<section class="share-section">
|
|
461
|
+
<h3>{html.escape(_t(lang, "share_file_title"))}</h3>
|
|
462
|
+
<label class="share-file-mask"><input type="checkbox" data-share-file-mask checked> {html.escape(_t(lang, "share_file_mask_toggle"))}</label>
|
|
463
|
+
<div class="share-file-actions">
|
|
464
|
+
<button class="share-action" type="button" data-share-file="download"><span class="share-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 3v12m0 0 4-4m-4 4-4-4M5 19h14"/></svg></span>{html.escape(_t(lang, "share_download_html"))}</button>
|
|
465
|
+
<button class="share-action" type="button" data-share-file="csv"><span class="share-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 20V10h4v10M10 20V4h4v16M16 20v-7h4v7M3 20h18"/></svg></span>{html.escape(_t(lang, "share_download_csv"))}</button>
|
|
466
|
+
<button class="share-action" type="button" data-share-file="png"><span class="share-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="4" width="18" height="16" rx="2"/><circle cx="8.5" cy="9" r="1.5"/><path d="m4 17 5-5 4 4 2-2 5 5"/></svg></span>{html.escape(_t(lang, "share_download_png"))}</button>
|
|
467
|
+
</div>
|
|
468
|
+
<p class="share-file-hint">{html.escape(_t(lang, "share_file_hint"))}</p>
|
|
469
|
+
</section>
|
|
470
|
+
<div class="share-toast" data-share-toast role="status" aria-live="polite"></div>
|
|
471
|
+
</div>
|
|
472
|
+
</dialog>"""
|
|
473
|
+
|
|
474
|
+
|
|
475
|
+
def _render_project_section(data: Mapping[str, Any], lang: str) -> str:
|
|
476
|
+
project_rows = [
|
|
477
|
+
_rank_line(
|
|
478
|
+
_display_name(project["project"], lang),
|
|
479
|
+
float(project["pct"]),
|
|
480
|
+
int(project["tokens"]),
|
|
481
|
+
float(project["cost"]),
|
|
482
|
+
lang,
|
|
483
|
+
)
|
|
484
|
+
for project in data.get("by_project", [])
|
|
485
|
+
]
|
|
486
|
+
project_rows_html = "".join(project_rows)
|
|
487
|
+
project_donut = _donut_svg(
|
|
488
|
+
[(_display_name(project["project"], lang), int(project["tokens"])) for project in data.get("by_project", [])],
|
|
489
|
+
lang,
|
|
490
|
+
)
|
|
491
|
+
project_body = (
|
|
492
|
+
project_donut
|
|
493
|
+
+ f'<div class="rank-head"><span></span><span>{_escape(_t(lang, "project"))}</span><span>{_escape(_t(lang, "share"))}</span><span>{_escape(_t(lang, "tokens"))}</span><span>{_escape(_t(lang, "cost"))}</span></div>'
|
|
494
|
+
+ f'<div class="rank-list">{project_rows_html}</div>'
|
|
495
|
+
if project_rows
|
|
496
|
+
else _empty_line(_t(lang, "empty_projects"))
|
|
497
|
+
)
|
|
498
|
+
return _section(_t(lang, "project_section"), project_body, "project-section")
|
|
499
|
+
|
|
500
|
+
|
|
501
|
+
def _render_model_section(data: Mapping[str, Any], lang: str) -> str:
|
|
502
|
+
model_rows = [
|
|
503
|
+
_rank_line(
|
|
504
|
+
_display_name(model["model"], lang),
|
|
505
|
+
float(model["pct"]),
|
|
506
|
+
int(model["tokens"]),
|
|
507
|
+
None if not model.get("cost_known", True) else float(model["cost"]),
|
|
508
|
+
lang,
|
|
509
|
+
)
|
|
510
|
+
for model in data.get("by_model", [])
|
|
511
|
+
]
|
|
512
|
+
model_rows_html = "".join(model_rows)
|
|
513
|
+
model_body = (
|
|
514
|
+
f'<div class="rank-head"><span></span><span>{_escape(_t(lang, "model"))}</span><span>{_escape(_t(lang, "share"))}</span><span>{_escape(_t(lang, "tokens"))}</span><span>{_escape(_t(lang, "cost"))}</span></div>'
|
|
515
|
+
f'<div class="rank-list">{model_rows_html}</div>'
|
|
516
|
+
if model_rows
|
|
517
|
+
else _empty_line(_t(lang, "empty_models"))
|
|
518
|
+
)
|
|
519
|
+
return _section(_t(lang, "model_section"), model_body)
|
|
520
|
+
|
|
521
|
+
|
|
522
|
+
def _render_tools_section(data: Mapping[str, Any], lang: str) -> str:
|
|
523
|
+
tools_body = _tools_body(data.get("subscriptions", []), data.get("by_agent", []), lang)
|
|
524
|
+
return _section(_t(lang, "tools_section"), tools_body, "tools-section")
|
|
525
|
+
|
|
526
|
+
|
|
527
|
+
def _render_insight_note(
|
|
528
|
+
component: dict[str, Any], lang: str, mask_labels: Mapping[str, str]
|
|
529
|
+
) -> str:
|
|
530
|
+
return (
|
|
531
|
+
'<div class="insight-note">'
|
|
532
|
+
f'{_t(lang, component["key"], **_insight_kwargs(component, mask_labels))}'
|
|
533
|
+
'</div>'
|
|
534
|
+
)
|
|
535
|
+
|
|
536
|
+
|
|
537
|
+
def _render_insight_action(
|
|
538
|
+
component: dict[str, Any], lang: str, mask_labels: Mapping[str, str]
|
|
539
|
+
) -> str:
|
|
540
|
+
return (
|
|
541
|
+
'<div class="insight-action">'
|
|
542
|
+
f'{_t(lang, component["key"], **_insight_kwargs(component, mask_labels))}'
|
|
543
|
+
'</div>'
|
|
544
|
+
)
|
|
545
|
+
|
|
546
|
+
|
|
547
|
+
def _insight_kwargs(
|
|
548
|
+
component: dict[str, Any], mask_labels: Mapping[str, str]
|
|
549
|
+
) -> dict[str, object]:
|
|
550
|
+
kwargs: dict[str, object] = {}
|
|
551
|
+
for key, value in component.items():
|
|
552
|
+
if key in {"key", "type", "direction", "delta_pct"}:
|
|
553
|
+
continue
|
|
554
|
+
if key == "tokens" or key == "mean_tokens":
|
|
555
|
+
kwargs[key] = _fmt_tokens(int(value))
|
|
556
|
+
elif key == "cost_usd":
|
|
557
|
+
kwargs[key] = _fmt_cost(float(value))
|
|
558
|
+
elif key == "project":
|
|
559
|
+
kwargs[key] = (
|
|
560
|
+
f'<span class="insight-project" '
|
|
561
|
+
f'data-mask-as="{_escape(mask_labels.get(str(value), "Project"))}">'
|
|
562
|
+
f"{_escape(value)}</span>"
|
|
563
|
+
)
|
|
564
|
+
elif key in {"model", "date"}:
|
|
565
|
+
kwargs[key] = _escape(value)
|
|
566
|
+
else:
|
|
567
|
+
kwargs[key] = value
|
|
568
|
+
return kwargs
|
|
569
|
+
|
|
570
|
+
|
|
571
|
+
def _render_insight_surface(data: Mapping[str, Any], lang: str) -> str:
|
|
572
|
+
from analyzer.insights import build_insights
|
|
573
|
+
|
|
574
|
+
components = build_insights(dict(data))
|
|
575
|
+
mask_labels = {
|
|
576
|
+
str(project["project"]): f"Project {index}"
|
|
577
|
+
for index, project in enumerate(data.get("by_project", []), start=1)
|
|
578
|
+
}
|
|
579
|
+
quiet = f'<div class="insight-note">{_t(lang, "insights_quiet")}</div>'
|
|
580
|
+
if not components:
|
|
581
|
+
return _section(_t(lang, "insights_section"), quiet, "insights-section")
|
|
582
|
+
|
|
583
|
+
renderers = {
|
|
584
|
+
"change_headline": _render_insight_note,
|
|
585
|
+
"spike": _render_insight_note,
|
|
586
|
+
"shift": _render_insight_note,
|
|
587
|
+
"pace_note": _render_insight_note,
|
|
588
|
+
"action": _render_insight_action,
|
|
589
|
+
}
|
|
590
|
+
body = "".join(
|
|
591
|
+
renderer(component, lang, mask_labels)
|
|
592
|
+
for component in components
|
|
593
|
+
if (renderer := renderers.get(str(component.get("type")))) is not None
|
|
594
|
+
)
|
|
595
|
+
if not body:
|
|
596
|
+
body = quiet
|
|
597
|
+
return _section(_t(lang, "insights_section"), body, "insights-section")
|
|
598
|
+
|
|
599
|
+
|
|
600
|
+
def _render_trend_section(data: Mapping[str, Any], lang: str) -> str:
|
|
601
|
+
return _section(_t(lang, "trend_section"), _trend_ascii(data.get("daily_trend", []), lang))
|
|
602
|
+
|
|
603
|
+
|
|
604
|
+
def _render_contribution_section(data: Mapping[str, Any], lang: str) -> str:
|
|
605
|
+
contribution = data.get("contribution")
|
|
606
|
+
if not isinstance(contribution, dict) or int(contribution.get("active_days", 0)) <= 0:
|
|
607
|
+
return ""
|
|
608
|
+
|
|
609
|
+
raw_weeks = contribution.get("weeks", [])
|
|
610
|
+
weeks = [
|
|
611
|
+
week for week in raw_weeks
|
|
612
|
+
if isinstance(week, list) and len(week) == 7
|
|
613
|
+
]
|
|
614
|
+
if not weeks:
|
|
615
|
+
return ""
|
|
616
|
+
|
|
617
|
+
month_labels: list[str] = []
|
|
618
|
+
seen_month: int | None = None
|
|
619
|
+
last_label_col = -3
|
|
620
|
+
for col, week in enumerate(weeks):
|
|
621
|
+
parsed = _parse_daily_date(week[0].get("date", ""))
|
|
622
|
+
label = ""
|
|
623
|
+
if parsed.month != seen_month and col - last_label_col >= 3:
|
|
624
|
+
label = _month_label(parsed.month, lang)
|
|
625
|
+
last_label_col = col
|
|
626
|
+
seen_month = parsed.month
|
|
627
|
+
month_labels.append(label)
|
|
628
|
+
|
|
629
|
+
grid_cells: list[str] = []
|
|
630
|
+
for week in weeks:
|
|
631
|
+
for cell in week:
|
|
632
|
+
cell_date = str(cell.get("date", ""))
|
|
633
|
+
tokens = int(cell.get("tokens", 0))
|
|
634
|
+
level = max(0, min(4, int(cell.get("level", 0))))
|
|
635
|
+
title = _t(lang, "contribution_cell_title", date=cell_date, tokens=_fmt_int(tokens))
|
|
636
|
+
grid_cells.append(
|
|
637
|
+
f'<span class="contribution-cell level-{level}" title="{_escape(title)}" '
|
|
638
|
+
f'aria-label="{_escape(title)}"></span>'
|
|
639
|
+
)
|
|
640
|
+
|
|
641
|
+
busiest_day = contribution.get("busiest_day")
|
|
642
|
+
busiest_value = "—"
|
|
643
|
+
if isinstance(busiest_day, dict):
|
|
644
|
+
busiest_value = (
|
|
645
|
+
f'{_escape(busiest_day.get("date", ""))} · '
|
|
646
|
+
f'{_escape(_fmt_tokens(int(busiest_day.get("tokens", 0))))}'
|
|
647
|
+
)
|
|
648
|
+
|
|
649
|
+
days_unit = _escape(_t(lang, "contribution_days_unit"))
|
|
650
|
+
current_streak = (
|
|
651
|
+
f'{_escape(_fmt_int(int(contribution.get("current_streak", 0))))} {days_unit}'
|
|
652
|
+
)
|
|
653
|
+
longest_streak = (
|
|
654
|
+
f'{_escape(_fmt_int(int(contribution.get("longest_streak", 0))))} {days_unit}'
|
|
655
|
+
)
|
|
656
|
+
stats = [
|
|
657
|
+
(_t(lang, "contribution_current_streak"), current_streak),
|
|
658
|
+
(_t(lang, "contribution_longest_streak"), longest_streak),
|
|
659
|
+
(_t(lang, "contribution_busiest_day"), busiest_value),
|
|
660
|
+
]
|
|
661
|
+
stats_html = "".join(
|
|
662
|
+
'<div class="contribution-stat">'
|
|
663
|
+
f'<span>{_escape(label)}</span><b>{value}</b>'
|
|
664
|
+
"</div>"
|
|
665
|
+
for label, value in stats
|
|
666
|
+
)
|
|
667
|
+
month_html = "".join(
|
|
668
|
+
f'<span>{_escape(label)}</span>' for label in month_labels
|
|
669
|
+
)
|
|
670
|
+
legend_cells = "".join(
|
|
671
|
+
f'<span class="contribution-cell level-{level}" aria-hidden="true"></span>'
|
|
672
|
+
for level in range(5)
|
|
673
|
+
)
|
|
674
|
+
body = (
|
|
675
|
+
'<div class="contribution-wrap">'
|
|
676
|
+
f'<div class="contribution-heatmap" style="--weeks:{len(weeks)}">'
|
|
677
|
+
f'<div class="contribution-months">{month_html}</div>'
|
|
678
|
+
'<div class="contribution-board">'
|
|
679
|
+
'<div class="contribution-days">'
|
|
680
|
+
'<span></span>'
|
|
681
|
+
f'<span>{_escape(_t(lang, "contribution_mon"))}</span>'
|
|
682
|
+
'<span></span>'
|
|
683
|
+
f'<span>{_escape(_t(lang, "contribution_wed"))}</span>'
|
|
684
|
+
'<span></span>'
|
|
685
|
+
f'<span>{_escape(_t(lang, "contribution_fri"))}</span>'
|
|
686
|
+
'<span></span>'
|
|
687
|
+
'</div>'
|
|
688
|
+
f'<div class="contribution-grid">{ "".join(grid_cells) }</div>'
|
|
689
|
+
'</div>'
|
|
690
|
+
'<div class="contribution-legend">'
|
|
691
|
+
f'<span>{_escape(_t(lang, "contribution_less"))}</span>'
|
|
692
|
+
f'{legend_cells}'
|
|
693
|
+
f'<span>{_escape(_t(lang, "contribution_more"))}</span>'
|
|
694
|
+
'</div>'
|
|
695
|
+
'</div>'
|
|
696
|
+
f'<div class="contribution-stats">{stats_html}</div>'
|
|
697
|
+
'</div>'
|
|
698
|
+
)
|
|
699
|
+
return _section(_t(lang, "contribution_section"), body, "contribution-section")
|
|
700
|
+
|
|
701
|
+
|
|
702
|
+
def _render_recent_titles_section(data: Mapping[str, Any], lang: str) -> str:
|
|
703
|
+
persona = data.get("persona")
|
|
704
|
+
if not isinstance(persona, Mapping):
|
|
705
|
+
return ""
|
|
706
|
+
raw_titles = persona.get("recent_titles", [])
|
|
707
|
+
if not isinstance(raw_titles, list):
|
|
708
|
+
return ""
|
|
709
|
+
titles = [title for title in raw_titles if isinstance(title, str) and title.strip()]
|
|
710
|
+
if not titles:
|
|
711
|
+
return ""
|
|
712
|
+
rows = "".join(
|
|
713
|
+
f'<div class="recent-title" data-mask>→ {_escape(title)}</div>'
|
|
714
|
+
for title in titles
|
|
715
|
+
)
|
|
716
|
+
return _section(
|
|
717
|
+
_t(lang, "recent_titles_heading"),
|
|
718
|
+
f'<div class="recent-titles">{rows}</div>',
|
|
719
|
+
"recent-titles-section",
|
|
720
|
+
)
|
|
721
|
+
|
|
722
|
+
|
|
723
|
+
def _render_wrapped_section(data: Mapping[str, Any], lang: str) -> str:
|
|
724
|
+
wrapped = data.get("wrapped")
|
|
725
|
+
if not isinstance(wrapped, dict):
|
|
726
|
+
return ""
|
|
727
|
+
|
|
728
|
+
beast = wrapped.get("beast")
|
|
729
|
+
if beast not in {"phoenix", "dragon"}:
|
|
730
|
+
return ""
|
|
731
|
+
|
|
732
|
+
beast_name = _t(lang, f"wrapped_beast_{beast}_title")
|
|
733
|
+
beast_caption = _t(lang, f"wrapped_beast_{beast}_caption")
|
|
734
|
+
books = _estimate_books(int(wrapped.get("total_tokens", 0)))
|
|
735
|
+
top_project = _display_name(wrapped.get("top_project"), lang)
|
|
736
|
+
top_model = _display_name(wrapped.get("top_model"), lang)
|
|
737
|
+
body = (
|
|
738
|
+
'<div class="wrapped-card">'
|
|
739
|
+
'<div class="wrapped-copy">'
|
|
740
|
+
f'<div class="wrapped-kicker">{_escape(_t(lang, "wrapped_year_badge", year=wrapped.get("year_label", "")))}</div>'
|
|
741
|
+
f'<h3>{_escape(beast_name)}</h3>'
|
|
742
|
+
f'<p class="wrapped-beast-line">{_escape(beast_caption)}</p>'
|
|
743
|
+
f'<div class="wrapped-total">{_escape(_fmt_int(int(wrapped.get("total_tokens", 0))))}</div>'
|
|
744
|
+
f'<p class="wrapped-total-label">{_escape(_t(lang, "wrapped_total_tokens"))}</p>'
|
|
745
|
+
f'<p class="wrapped-analogy">{_escape(_t(lang, "wrapped_books_equivalent", books=_fmt_int(books)))}</p>'
|
|
746
|
+
'</div>'
|
|
747
|
+
'<div class="wrapped-art">'
|
|
748
|
+
f'<img src="{_escape(_sprite_data_uri(str(beast)))}" alt="{_escape(beast_name)}">'
|
|
749
|
+
'</div>'
|
|
750
|
+
'<div class="wrapped-metrics">'
|
|
751
|
+
f'<div class="wrapped-metric"><span>{_escape(_t(lang, "wrapped_total_cost"))}</span><b>{_escape(_fmt_cost(float(wrapped.get("total_cost", 0.0))))}</b></div>'
|
|
752
|
+
f'<div class="wrapped-metric"><span>{_escape(_t(lang, "wrapped_active_days"))}</span><b>{_escape(_fmt_int(int(wrapped.get("active_days", 0))))}</b></div>'
|
|
753
|
+
f'<div class="wrapped-metric"><span>{_escape(_t(lang, "wrapped_longest_streak"))}</span><b>{_escape(_fmt_int(int(wrapped.get("longest_streak", 0))))} {_escape(_t(lang, "contribution_days_unit"))}</b></div>'
|
|
754
|
+
f'<div class="wrapped-metric"><span>{_escape(_t(lang, "wrapped_top_model"))}</span><b>{_escape(top_model)}</b></div>'
|
|
755
|
+
f'<div class="wrapped-metric"><span>{_escape(_t(lang, "wrapped_top_project"))}</span><b data-mask>{_escape(top_project)}</b></div>'
|
|
756
|
+
'</div>'
|
|
757
|
+
'</div>'
|
|
758
|
+
)
|
|
759
|
+
return _section(_t(lang, "wrapped_section"), body, "wrapped-section")
|
|
760
|
+
|
|
761
|
+
|
|
762
|
+
def _render_persona_section(data: Mapping[str, Any], lang: str) -> str:
|
|
763
|
+
persona_body = _persona_body(data.get("persona"), lang)
|
|
764
|
+
return _section(_t(lang, "persona_section"), persona_body, "persona-section")
|
|
765
|
+
|
|
766
|
+
|
|
767
|
+
def _render_session_section(data: Mapping[str, Any], lang: str) -> str:
|
|
768
|
+
session_rows = []
|
|
769
|
+
for idx, session in enumerate(data.get("top_sessions", []), 1):
|
|
770
|
+
session_rows.append(f"""
|
|
771
|
+
<tr>
|
|
772
|
+
<td>#{idx}</td>
|
|
773
|
+
<td>{_escape(session["start_time"])}</td>
|
|
774
|
+
<td class="name">{_escape(_display_name(session["project"], lang))}</td>
|
|
775
|
+
<td>{_escape(_display_name(session["model"], lang))}</td>
|
|
776
|
+
<td>{_fmt_duration(float(session["duration_min"]))}</td>
|
|
777
|
+
<td>{_fmt_tokens(int(session["tokens"]))}</td>
|
|
778
|
+
<td>{_fmt_cost(float(session["cost"]))}</td>
|
|
779
|
+
</tr>""")
|
|
780
|
+
session_body = (
|
|
781
|
+
f"""
|
|
782
|
+
<div class="table-wrap">
|
|
783
|
+
<table>
|
|
784
|
+
<thead><tr><th>{_escape(_t(lang, "rank"))}</th><th>{_escape(_t(lang, "start_time"))}</th><th>{_escape(_t(lang, "project"))}</th><th>{_escape(_t(lang, "model"))}</th><th>{_escape(_t(lang, "duration"))}</th><th>{_escape(_t(lang, "tokens"))}</th><th>{_escape(_t(lang, "cost"))}</th></tr></thead>
|
|
785
|
+
<tbody>{''.join(session_rows)}</tbody>
|
|
786
|
+
</table>
|
|
787
|
+
</div>
|
|
788
|
+
"""
|
|
789
|
+
if session_rows
|
|
790
|
+
else _empty_line(_t(lang, "empty_sessions"))
|
|
791
|
+
)
|
|
792
|
+
return _section(_t(lang, "session_section"), session_body, "session-section")
|
|
793
|
+
|
|
794
|
+
|
|
795
|
+
def _share_config_json(lang: str) -> str:
|
|
796
|
+
share_config = {
|
|
797
|
+
"copied": _t(lang, "share_copied"),
|
|
798
|
+
"pathCopied": _t(lang, "share_path_copied"),
|
|
799
|
+
}
|
|
800
|
+
return json.dumps(share_config, ensure_ascii=False).replace("</", "<\\/")
|
|
801
|
+
|
|
802
|
+
|
|
803
|
+
def _csv_cost(value: float | None) -> str:
|
|
804
|
+
if value is None:
|
|
805
|
+
return "—"
|
|
806
|
+
return f"{value:.4f}" if 0 < value < 1 else f"{value:.2f}"
|
|
807
|
+
|
|
808
|
+
|
|
809
|
+
def _build_csv_data(data: Mapping[str, Any], lang: str, *, mask_projects: bool = False) -> str:
|
|
810
|
+
out = StringIO()
|
|
811
|
+
writer = csv.writer(out, lineterminator="\r\n")
|
|
812
|
+
writer.writerow(["type", "name", "share_pct", "tokens", "cost_usd"])
|
|
813
|
+
for idx, item in enumerate(data.get("by_project", []), start=1):
|
|
814
|
+
writer.writerow(
|
|
815
|
+
[
|
|
816
|
+
"project",
|
|
817
|
+
f"Project {idx}" if mask_projects else _display_name(item.get("project"), lang),
|
|
818
|
+
f"{float(item.get('pct', 0.0)):.1f}",
|
|
819
|
+
str(int(item.get("tokens", 0))),
|
|
820
|
+
_csv_cost(float(item.get("cost", 0.0))),
|
|
821
|
+
]
|
|
822
|
+
)
|
|
823
|
+
for model_item in data.get("by_model", []):
|
|
824
|
+
cost_val = None if not model_item.get("cost_known", True) else float(model_item.get("cost", 0.0))
|
|
825
|
+
writer.writerow(
|
|
826
|
+
[
|
|
827
|
+
"model",
|
|
828
|
+
_display_name(model_item.get("model"), lang),
|
|
829
|
+
f"{float(model_item.get('pct', 0.0)):.1f}",
|
|
830
|
+
str(int(model_item.get("tokens", 0))),
|
|
831
|
+
_csv_cost(cost_val),
|
|
832
|
+
]
|
|
833
|
+
)
|
|
834
|
+
return out.getvalue()
|
|
835
|
+
|
|
836
|
+
|
|
837
|
+
def _render_sponsor_section(lang: str) -> str:
|
|
838
|
+
return f"""<p class="sponsor">
|
|
839
|
+
<a href="https://ko-fi.com/lollapalooza" target="_blank" rel="noopener" aria-label="Buy me a coffee on Ko-fi"><img src="https://img.shields.io/badge/Ko--fi-FF5E5B?logo=ko-fi&logoColor=white" alt="Ko-fi"></a>
|
|
840
|
+
<span class="tagline">{html.escape(_t(lang, "sponsor"))}</span>
|
|
841
|
+
<a href="https://ko-fi.com/lollapalooza" target="_blank" rel="noopener" aria-label="Buy me a coffee on Ko-fi"><img src="https://img.shields.io/badge/Ko--fi-FF5E5B?logo=ko-fi&logoColor=white" alt="Ko-fi"></a>
|
|
842
|
+
</p>
|
|
843
|
+
<p class="sponsor-link"><a href="https://github.com/aqua5230/usage" target="_blank" rel="noopener">github.com/aqua5230/usage</a></p>"""
|
|
844
|
+
|
|
845
|
+
|
|
846
|
+
def _render_styles() -> str:
|
|
847
|
+
return REPORT_CSS
|
|
848
|
+
|
|
849
|
+
|
|
850
|
+
def _render_scripts(share_config_json: str) -> str:
|
|
851
|
+
return f"{HTML_TO_IMAGE_UMD}\n" + REPORT_JS_TEMPLATE.replace(
|
|
852
|
+
"__SHARE_CONFIG_JSON__",
|
|
853
|
+
share_config_json,
|
|
854
|
+
)
|
|
855
|
+
|
|
856
|
+
|
|
857
|
+
def generate_html(data: ReportData | Mapping[str, Any], language: str | None = None) -> str:
|
|
858
|
+
report_data = cast(ReportData, data)
|
|
859
|
+
lang = language or _detect_lang()
|
|
860
|
+
generated_at = datetime.now().astimezone().strftime("%Y-%m-%d %H:%M:%S %Z")
|
|
861
|
+
cards = _summary_cards(report_data["summary"], lang)
|
|
862
|
+
share_config_json = _share_config_json(lang)
|
|
863
|
+
csv_data_json = json.dumps(_build_csv_data(report_data, lang), ensure_ascii=False).replace("</", "<\\/")
|
|
864
|
+
masked_csv_data_json = json.dumps(_build_csv_data(report_data, lang, mask_projects=True), ensure_ascii=False).replace("</", "<\\/")
|
|
865
|
+
title = _t(lang, "title")
|
|
866
|
+
insight_surface = _render_insight_surface(report_data, lang)
|
|
867
|
+
return f"""<!doctype html>
|
|
868
|
+
<html lang="{html.escape(lang)}">
|
|
869
|
+
<head>
|
|
870
|
+
<meta charset="utf-8">
|
|
871
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
872
|
+
<title>{html.escape(title)}</title>
|
|
873
|
+
<style>
|
|
874
|
+
{_render_styles()}
|
|
875
|
+
</style>
|
|
876
|
+
</head>
|
|
877
|
+
<body>
|
|
878
|
+
<main class="wrap">
|
|
879
|
+
{_render_header(report_data, lang, title, generated_at)}
|
|
880
|
+
{_render_share_dialog(lang)}
|
|
881
|
+
{_render_cards_section(cards)}
|
|
882
|
+
{_render_wrapped_section(report_data, lang)}
|
|
883
|
+
{insight_surface} {_render_tools_section(report_data, lang)}
|
|
884
|
+
{_render_project_section(report_data, lang)}
|
|
885
|
+
{_render_model_section(report_data, lang)}
|
|
886
|
+
{_render_trend_section(report_data, lang)}
|
|
887
|
+
{_render_contribution_section(report_data, lang)}
|
|
888
|
+
{_render_recent_titles_section(report_data, lang)}{_render_persona_section(report_data, lang)}
|
|
889
|
+
{_render_session_section(report_data, lang)}
|
|
890
|
+
{_render_sponsor_section(lang)}
|
|
891
|
+
</main>
|
|
892
|
+
<script type="application/json" id="usage-csv-data">{csv_data_json}</script>
|
|
893
|
+
<script type="application/json" id="usage-masked-csv-data">{masked_csv_data_json}</script>
|
|
894
|
+
<script>
|
|
895
|
+
{_render_scripts(share_config_json)}
|
|
896
|
+
</script>
|
|
897
|
+
</body>
|
|
898
|
+
</html>
|
|
899
|
+
"""
|
|
900
|
+
|
|
901
|
+
|
|
902
|
+
def save_and_open(
|
|
903
|
+
data: ReportData | Mapping[str, Any],
|
|
904
|
+
out_path: str | None = None,
|
|
905
|
+
language: str | None = None,
|
|
906
|
+
) -> str:
|
|
907
|
+
if out_path:
|
|
908
|
+
path = Path(os.path.expanduser(out_path))
|
|
909
|
+
display_path = str(path.expanduser())
|
|
910
|
+
else:
|
|
911
|
+
reports_dir = Path.home() / ".usage-reports"
|
|
912
|
+
reports_dir.mkdir(mode=0o700, parents=True, exist_ok=True)
|
|
913
|
+
path = reports_dir / f"usage-report-{datetime.now().strftime('%Y%m%d-%H%M%S')}.html"
|
|
914
|
+
display_path = f"~/.usage-reports/{path.name}"
|
|
915
|
+
path.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
|
|
916
|
+
path.write_text(generate_html(data, language=language), encoding="utf-8")
|
|
917
|
+
path.chmod(0o600)
|
|
918
|
+
if out_path is None:
|
|
919
|
+
if sys.platform == "darwin":
|
|
920
|
+
subprocess.run(["/usr/bin/open", str(path.resolve())], check=False)
|
|
921
|
+
else:
|
|
922
|
+
webbrowser.open(path.resolve().as_uri())
|
|
923
|
+
return display_path
|