codex-work-receipt 0.8.1 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +9 -0
- package/README.en.md +2 -2
- package/README.md +2 -2
- package/docs/data-schema.en.md +17 -1
- package/docs/data-schema.md +17 -1
- package/docs/privacy.en.md +3 -3
- package/docs/privacy.md +3 -3
- package/package.json +1 -1
- package/src/core/metrics.mjs +135 -10
- package/src/core/presentation.mjs +138 -0
- package/src/core/receipt-record.mjs +30 -0
- package/src/lib/tool-category.mjs +36 -0
- package/src/parsers/codex.mjs +6 -0
- package/src/renderers/html.mjs +547 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,14 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.9.0
|
|
4
|
+
|
|
5
|
+
- 新增缓存命中率、每轮 Token / 输出 Token / 工具调用 / 完成耗时等效率指标。
|
|
6
|
+
- 新增首 Token 与整轮耗时的 P50 / P90 分位数,以及按本地时区生成的 24 小时工作热力图。
|
|
7
|
+
- 新增模型使用轮次和脱敏工具类别结构;解析器不保留原始工具名、调用参数、命令或工具输出。
|
|
8
|
+
- HTML 与本地完整 JSON 展示详细效率洞察;微信 `cwr1` / `cwr2` 紧凑载荷保持不变,继续兼容现有小程序。
|
|
9
|
+
- HTML 赞助商下方新增“更多小票功能”,集中展示生成范围、自动 / 手动模式、Skill 与票仔的 12 条常用命令,并支持一键复制。
|
|
10
|
+
- 功能命令区采用独立的浅色纸张样式,记忆展开状态,适配桌面和移动端,并继续排除在长图导出范围之外。
|
|
11
|
+
|
|
3
12
|
## 0.8.0
|
|
4
13
|
|
|
5
14
|
- 首次交互式运行新增“自动保存 / 仅手动”选择;显式范围命令继续保持非交互兼容。
|
package/README.en.md
CHANGED
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
<img src="docs/images/readme-hero.jpg" alt="Three Codex Work Receipt themes" width="920">
|
|
21
21
|
</p>
|
|
22
22
|
|
|
23
|
-
Codex Work Receipt summarizes turns, tool calls, Tokens, duration, and models, then turns
|
|
23
|
+
Codex Work Receipt summarizes turns, tool calls, Tokens, duration, and models. It also reports cache hit rate, per-turn efficiency, P50 / P90 latency, a work-time heatmap, and model/tool structure, then turns the result into playful AI work points, a job title, and a review. Receipts never include prompts, responses, source code, project paths, file names, tool arguments, or tool output.
|
|
24
24
|
|
|
25
25
|
The cwr2 protocol creates stable privacy-safe facts for each session and calendar day, allowing overlapping today, last-seven-days, and this-week receipts to be deduplicated. Every receipt also produces one `.cwr.json` WeChat import file. Scan import appears only when the complete payload fits in one data QR code.
|
|
26
26
|
|
|
@@ -56,7 +56,7 @@ npx codex-work-receipt@latest --hours 3 --lang en
|
|
|
56
56
|
|
|
57
57
|
“Last N hours” is a rolling summary for private history only. It does not participate in AI Work Cooperative deduplicated accounting. Use today, this week, the last seven days, or a specific session when you want accountable canonical facts.
|
|
58
58
|
|
|
59
|
-
HTML, structured data, and the WeChat import file are written to `./codex-work-receipt-output/` by default. The page can download its `.cwr.json` file and save a high-resolution PNG containing only the full receipt and WeChat mini-program code. See the [CLI guide](docs/cli.en.md).
|
|
59
|
+
HTML, structured data, and the WeChat import file are written to `./codex-work-receipt-output/` by default. The page can download its `.cwr.json` file and save a high-resolution PNG containing only the full receipt and WeChat mini-program code. A “More receipt features” section below the sponsor provides 12 copyable commands for receipt ranges, automatic mode, the Codex skill, and Ticket Buddy. The web controls stay out of the exported image. See the [CLI guide](docs/cli.en.md).
|
|
60
60
|
|
|
61
61
|
## Automatic saving or manual only
|
|
62
62
|
|
package/README.md
CHANGED
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
<img src="docs/images/readme-hero.jpg" alt="Codex AI 打工小票三种主题效果" width="920">
|
|
21
21
|
</p>
|
|
22
22
|
|
|
23
|
-
它会统计 Codex 会话中的轮次、工具调用、Token
|
|
23
|
+
它会统计 Codex 会话中的轮次、工具调用、Token、时长和模型,并提供缓存命中率、每轮效率、P50 / P90 延迟、工作时间热力图、模型与工具结构,生成带有 AI 工分、今日工种和点评的小票。不会把 Prompt、回复正文、代码、项目路径、文件名、工具参数或工具输出写入小票。
|
|
24
24
|
|
|
25
25
|
cwr2 协议按“会话 × 自然日”生成稳定的脱敏事实。今日、近 7 日和本周小票即使范围重叠,接收端也能识别相同工作。每张小票还会生成一个 `.cwr.json` 微信导入文件;只有完整数据能放进一个二维码时,网页才额外提供扫码导入。
|
|
26
26
|
|
|
@@ -56,7 +56,7 @@ npx codex-work-receipt@latest --hours 3
|
|
|
56
56
|
|
|
57
57
|
“最近 N 小时”是滚动摘要,只用于查看和保存私人历史,不参与 AI 供销社的去重统计。需要进入供销社时,请生成“今日 / 本周 / 近 7 日 / 指定会话”小票。
|
|
58
58
|
|
|
59
|
-
网页、结构数据和微信导入文件默认保存在 `./codex-work-receipt-output/`。生成的网页支持一键下载 `.cwr.json
|
|
59
|
+
网页、结构数据和微信导入文件默认保存在 `./codex-work-receipt-output/`。生成的网页支持一键下载 `.cwr.json`,也可以保存只包含完整小票和微信小程序码的高清长图。赞助商下方的“更多小票功能”还提供 12 条常用命令及复制按钮,帮助用户发现不同统计范围、自动模式、Skill 和票仔。数据二维码和网页功能区不会进入图片。详见 [CLI 使用文档](docs/cli.md)。
|
|
60
60
|
|
|
61
61
|
## 自动保存或仅手动
|
|
62
62
|
|
package/docs/data-schema.en.md
CHANGED
|
@@ -12,12 +12,26 @@ The full receipt schema is currently version `2`; rolling-hour summaries remain
|
|
|
12
12
|
- `generated_at`: generation time
|
|
13
13
|
- `source`: data source, selected `scope`, and collector version
|
|
14
14
|
- `period`: actual activity times, timezone, and `range_start_date` / `range_end_date` calendar boundaries
|
|
15
|
-
- `stats`: turns, messages, tools, Tokens, duration, and
|
|
15
|
+
- `stats`: turns, messages, tools, Tokens, duration, and detailed local efficiency insights
|
|
16
16
|
- `presentation`: default theme, language-neutral `work_profile`, localized role, review, and AI work points
|
|
17
17
|
- `privacy`: explicit declaration of excluded sensitive content
|
|
18
18
|
|
|
19
19
|
The `id` remains stable for the same session, day, or calendar-boundary pair. Regeneration updates the record rather than creating duplicate history. `source.snapshot_hash` detects metric changes.
|
|
20
20
|
|
|
21
|
+
### Detailed local efficiency insights
|
|
22
|
+
|
|
23
|
+
`stats.insights` in the full JSON contains:
|
|
24
|
+
|
|
25
|
+
- `cache_hit_rate`: `cached_input_tokens / input_tokens`, or `0` when input is zero
|
|
26
|
+
- `per_turn`: total Tokens, output Tokens, tool calls, and average completion duration per completed turn
|
|
27
|
+
- `latency_ms.first_token`: sample count plus P50 and P90 first-token latency
|
|
28
|
+
- `latency_ms.turn`: sample count plus P50 and P90 completed-turn duration
|
|
29
|
+
- `activity_by_hour`: 24 hourly buckets in the receipt timezone; each completed or interrupted turn adds one
|
|
30
|
+
- `model_usage`: completed-turn counts attributed to each model
|
|
31
|
+
- `tool_usage`: privacy-safe category counts for terminal, file editing, browser, research, media, multi-agent, planning, integrations, and other tools
|
|
32
|
+
|
|
33
|
+
Percentiles use linear interpolation between adjacent samples and are rounded to milliseconds. Tool classification retains only stable categories, not raw tool names, arguments, commands, or output.
|
|
34
|
+
|
|
21
35
|
## WeChat import file
|
|
22
36
|
|
|
23
37
|
Each run writes a `*.cwr.json` file beside the HTML:
|
|
@@ -37,6 +51,8 @@ Each run writes a `*.cwr.json` file beside the HTML:
|
|
|
37
51
|
|
|
38
52
|
`payload` is exactly the compact structure obtained after decoding a QR payload. `integrity.digest` is a full SHA-256 over canonical payload JSON. It detects corruption but is not a source signature. The mini program must treat every file as untrusted input, enforce a size limit, validate versions, fields, numeric bounds, unique fact IDs, manifests, and content hashes, then persist atomically only after confirmation.
|
|
39
53
|
|
|
54
|
+
To preserve compatibility with existing mini programs, `stats.insights` currently stays in the local full JSON and HTML and is not added to the compact `cwr1` / `cwr2` payload. The compact payload continues to carry the existing accounting metrics.
|
|
55
|
+
|
|
40
56
|
Canonicalization recursively sorts every object's keys using JavaScript's default string order (UTF-16 code units), preserves array order, uses JSON primitive types, and emits no whitespace or trailing newline. Calculate SHA-256 over the resulting UTF-8 bytes. Use the digest in `docs/fixtures/cwr-file-v1.json` to cross-check another implementation.
|
|
41
57
|
|
|
42
58
|
A cross-repository compatibility fixture is available at `docs/fixtures/cwr-file-v1.json`.
|
package/docs/data-schema.md
CHANGED
|
@@ -12,12 +12,26 @@
|
|
|
12
12
|
- `generated_at`:生成时间
|
|
13
13
|
- `source`:数据来源、`scope` 统计范围和采集器版本
|
|
14
14
|
- `period`:实际活动起止时间、时区,以及 `range_start_date` / `range_end_date` 自然日边界
|
|
15
|
-
- `stats`:轮次、消息、工具、Token
|
|
15
|
+
- `stats`:轮次、消息、工具、Token、时长,以及本地详细效率洞察
|
|
16
16
|
- `presentation`:默认主题、语言无关的 `work_profile`、本地化工种、点评和 AI 工分
|
|
17
17
|
- `privacy`:明确声明不包含的敏感内容
|
|
18
18
|
|
|
19
19
|
同一个会话、同一天或同一组自然日边界的 `id` 保持稳定;重复生成会更新该记录。`source.snapshot_hash` 用于识别统计内容是否发生变化。
|
|
20
20
|
|
|
21
|
+
### 本地详细效率洞察
|
|
22
|
+
|
|
23
|
+
完整 JSON 的 `stats.insights` 包含:
|
|
24
|
+
|
|
25
|
+
- `cache_hit_rate`:`cached_input_tokens / input_tokens`,输入为零时记为 `0`
|
|
26
|
+
- `per_turn`:按已完成轮次计算的总 Token、输出 Token、工具调用和平均完成耗时
|
|
27
|
+
- `latency_ms.first_token`:首 Token 延迟的样本数、P50 和 P90
|
|
28
|
+
- `latency_ms.turn`:已完成整轮耗时的样本数、P50 和 P90
|
|
29
|
+
- `activity_by_hour`:按小票时区统计的 24 个小时槽;每个完成或中断轮次计一次
|
|
30
|
+
- `model_usage`:模型对应的完成轮次数量
|
|
31
|
+
- `tool_usage`:脱敏工具类别及数量,包括终端、文件编辑、浏览器、检索、媒体、多代理、规划、外部集成和其他类别
|
|
32
|
+
|
|
33
|
+
百分位使用相邻样本线性插值后取整。工具分类只保留稳定类别,不保留原始工具名、参数、命令或输出。
|
|
34
|
+
|
|
21
35
|
## 微信导入文件
|
|
22
36
|
|
|
23
37
|
每次生成都会在 HTML 旁边写入一个 `*.cwr.json`:
|
|
@@ -37,6 +51,8 @@
|
|
|
37
51
|
|
|
38
52
|
`payload` 与二维码解压后的精简结构完全相同。`integrity.digest` 是对规范化 payload JSON 计算的完整 SHA-256,用于发现损坏,但不是来源签名。小程序必须把文件视为不可信输入,限制文件大小,校验版本、字段、数值范围、factId 唯一性、manifest 和 content hash,并在用户确认后原子入库。
|
|
39
53
|
|
|
54
|
+
为保持现有小程序兼容,`stats.insights` 当前只存在于本地完整 JSON 和 HTML,不进入 `cwr1` / `cwr2` 精简载荷。精简载荷继续携带原有记账统计。
|
|
55
|
+
|
|
40
56
|
规范化规则是:递归按 JavaScript 默认字符串排序(UTF-16 code unit 顺序)排列每个对象的键,保持数组元素顺序,使用 JSON 原始类型,不添加空白或换行,再对生成的 UTF-8 字节计算 SHA-256。兼容实现可用 `docs/fixtures/cwr-file-v1.json` 的 digest 做交叉验证。
|
|
41
57
|
|
|
42
58
|
兼容测试夹具见 `docs/fixtures/cwr-file-v1.json`。
|
package/docs/privacy.en.md
CHANGED
|
@@ -7,10 +7,10 @@ AI Work Receipt reads Codex sessions, computes metrics, and renders receipts loc
|
|
|
7
7
|
## What it reads
|
|
8
8
|
|
|
9
9
|
- `~/.codex/sessions/**/*.jsonl` by default
|
|
10
|
-
- Event types, timestamps, model names, and numerical metadata
|
|
11
|
-
- Metrics such as turns, tool calls, Tokens, duration, and
|
|
10
|
+
- Event types, timestamps, model names, privacy-safe tool categories, and numerical metadata
|
|
11
|
+
- Metrics such as turns, tool calls, Tokens, duration, interruptions, cache hit rate, latency percentiles, and hourly distribution
|
|
12
12
|
|
|
13
|
-
Session logs are parsed locally in bounded chunks, one JSONL row at a time.
|
|
13
|
+
Session logs are parsed locally in bounded chunks, one JSONL row at a time. Raw tool names are immediately mapped to stable categories. Prompts, responses, images, raw tool names, tool arguments, commands, and tool output are discarded from the in-memory statistics representation.
|
|
14
14
|
|
|
15
15
|
## What receipts exclude
|
|
16
16
|
|
package/docs/privacy.md
CHANGED
|
@@ -7,10 +7,10 @@ AI 打工小票在本机读取 Codex 会话,并在本机完成统计和渲染
|
|
|
7
7
|
## 会读取什么
|
|
8
8
|
|
|
9
9
|
- 默认读取 `~/.codex/sessions/**/*.jsonl`
|
|
10
|
-
-
|
|
11
|
-
- 计算轮次、工具调用、Token
|
|
10
|
+
- 提取事件类型、时间、模型名称、脱敏工具类别和数字统计
|
|
11
|
+
- 计算轮次、工具调用、Token、时长、中断次数、缓存命中率、延迟分位与小时分布等指标
|
|
12
12
|
|
|
13
|
-
|
|
13
|
+
会话日志会在本机分块逐行解析。每行解析后只保留统计所需字段;工具名会立即映射为稳定类别。Prompt、回复、图片、原始工具名、工具参数、命令和工具输出不会保留在内存统计对象中。
|
|
14
14
|
|
|
15
15
|
## 不会写入小票什么
|
|
16
16
|
|
package/package.json
CHANGED
package/src/core/metrics.mjs
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { dateKey, rowDate } from "../lib/time.mjs";
|
|
2
|
+
import { toolCategoryForRow } from "../lib/tool-category.mjs";
|
|
2
3
|
import {
|
|
3
4
|
calendarDayCount,
|
|
4
5
|
isCalendarScope,
|
|
@@ -21,6 +22,83 @@ function addUsage(target, source) {
|
|
|
21
22
|
for (const key of Object.keys(target)) target[key] += Number(source[key] || 0);
|
|
22
23
|
}
|
|
23
24
|
|
|
25
|
+
function increment(map, key, amount = 1) {
|
|
26
|
+
if (!key) return;
|
|
27
|
+
map.set(key, Number(map.get(key) || 0) + amount);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function roundedRatio(numerator, denominator, digits = 2) {
|
|
31
|
+
if (!denominator) return 0;
|
|
32
|
+
const scale = 10 ** digits;
|
|
33
|
+
return Math.round((Number(numerator || 0) / denominator) * scale) / scale;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function percentile(samples, quantile) {
|
|
37
|
+
if (!samples.length) return 0;
|
|
38
|
+
const sorted = [...samples].sort((left, right) => left - right);
|
|
39
|
+
const position = (sorted.length - 1) * quantile;
|
|
40
|
+
const lowerIndex = Math.floor(position);
|
|
41
|
+
const upperIndex = Math.ceil(position);
|
|
42
|
+
const lower = sorted[lowerIndex];
|
|
43
|
+
const upper = sorted[upperIndex];
|
|
44
|
+
return Math.round(lower + (upper - lower) * (position - lowerIndex));
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function usageRows(map, keyName) {
|
|
48
|
+
return [...map.entries()]
|
|
49
|
+
.map(([name, count]) => ({ [keyName]: name, count }))
|
|
50
|
+
.sort((left, right) => right.count - left.count || left[keyName].localeCompare(right[keyName]));
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function localHour(date, timezone) {
|
|
54
|
+
if (!date) return null;
|
|
55
|
+
const hour = Number(new Intl.DateTimeFormat("en-GB", {
|
|
56
|
+
timeZone: timezone,
|
|
57
|
+
hour: "2-digit",
|
|
58
|
+
hourCycle: "h23",
|
|
59
|
+
}).format(date));
|
|
60
|
+
return Number.isInteger(hour) && hour >= 0 && hour <= 23 ? hour : null;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function buildInsights({
|
|
64
|
+
tokens,
|
|
65
|
+
completedTurns,
|
|
66
|
+
toolCalls,
|
|
67
|
+
completedDurationMs,
|
|
68
|
+
firstTokenSamples,
|
|
69
|
+
turnDurationSamples,
|
|
70
|
+
activityByHour,
|
|
71
|
+
modelUsage,
|
|
72
|
+
toolUsage,
|
|
73
|
+
}) {
|
|
74
|
+
const inputTokens = Math.max(0, Number(tokens.input_tokens || 0));
|
|
75
|
+
const cachedInputTokens = Math.max(0, Number(tokens.cached_input_tokens || 0));
|
|
76
|
+
return {
|
|
77
|
+
cache_hit_rate: inputTokens ? Math.min(1, roundedRatio(cachedInputTokens, inputTokens, 4)) : 0,
|
|
78
|
+
per_turn: {
|
|
79
|
+
total_tokens: roundedRatio(tokens.total_tokens, completedTurns),
|
|
80
|
+
output_tokens: roundedRatio(tokens.output_tokens, completedTurns),
|
|
81
|
+
tool_calls: roundedRatio(toolCalls, completedTurns),
|
|
82
|
+
work_duration_ms: Math.round(roundedRatio(completedDurationMs, completedTurns)),
|
|
83
|
+
},
|
|
84
|
+
latency_ms: {
|
|
85
|
+
first_token: {
|
|
86
|
+
sample_count: firstTokenSamples.length,
|
|
87
|
+
p50: percentile(firstTokenSamples, 0.5),
|
|
88
|
+
p90: percentile(firstTokenSamples, 0.9),
|
|
89
|
+
},
|
|
90
|
+
turn: {
|
|
91
|
+
sample_count: turnDurationSamples.length,
|
|
92
|
+
p50: percentile(turnDurationSamples, 0.5),
|
|
93
|
+
p90: percentile(turnDurationSamples, 0.9),
|
|
94
|
+
},
|
|
95
|
+
},
|
|
96
|
+
activity_by_hour: [...activityByHour],
|
|
97
|
+
model_usage: usageRows(modelUsage, "model"),
|
|
98
|
+
tool_usage: usageRows(toolUsage, "category"),
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
|
|
24
102
|
function sessionTokenUsage(rows, range) {
|
|
25
103
|
const events = rows
|
|
26
104
|
.filter((row) => row.type === "event_msg" && row.payload?.type === "token_count")
|
|
@@ -78,11 +156,17 @@ export function collectMetrics(sessions, range) {
|
|
|
78
156
|
let toolCalls = 0;
|
|
79
157
|
let interruptions = 0;
|
|
80
158
|
let workDurationMs = 0;
|
|
159
|
+
let completedDurationMs = 0;
|
|
81
160
|
let totalFirstTokenMs = 0;
|
|
82
161
|
let firstTokenSamples = 0;
|
|
162
|
+
const firstTokenLatencySamples = [];
|
|
163
|
+
const turnDurationSamples = [];
|
|
83
164
|
const timestamps = [];
|
|
84
165
|
const activeDateKeys = new Set();
|
|
85
166
|
const models = new Set();
|
|
167
|
+
const modelUsage = new Map();
|
|
168
|
+
const toolUsage = new Map();
|
|
169
|
+
const activityByHour = Array(24).fill(0);
|
|
86
170
|
|
|
87
171
|
for (const session of sessions) {
|
|
88
172
|
const scopedRows = isCalendarScope(range.scope) || isTimeWindowScope(range.scope)
|
|
@@ -94,40 +178,70 @@ export function collectMetrics(sessions, range) {
|
|
|
94
178
|
sessionIds.push(session.sessionId);
|
|
95
179
|
addUsage(tokens, sessionTokenUsage(session.rows, range));
|
|
96
180
|
|
|
97
|
-
|
|
181
|
+
let activeModel = null;
|
|
182
|
+
let unattributedModelTurns = 0;
|
|
183
|
+
let sessionHasSelectedModel = false;
|
|
184
|
+
for (const row of session.rows) {
|
|
185
|
+
if (row.type === "turn_context" && row.payload?.model) activeModel = row.payload.model;
|
|
186
|
+
const selected = (!isCalendarScope(range.scope) && !isTimeWindowScope(range.scope))
|
|
187
|
+
|| isDateInRange(rowDate(row), range);
|
|
188
|
+
if (!selected) continue;
|
|
98
189
|
const date = rowDate(row);
|
|
99
190
|
if (date) {
|
|
100
191
|
timestamps.push(date);
|
|
101
192
|
activeDateKeys.add(dateKey(date, range.timezone));
|
|
102
193
|
}
|
|
103
194
|
|
|
104
|
-
if (row.type === "turn_context" && row.payload?.model)
|
|
195
|
+
if (row.type === "turn_context" && row.payload?.model) {
|
|
196
|
+
models.add(row.payload.model);
|
|
197
|
+
sessionHasSelectedModel = true;
|
|
198
|
+
}
|
|
105
199
|
if (row.type === "event_msg") {
|
|
106
200
|
const eventType = row.payload?.type;
|
|
107
201
|
if (eventType === "task_complete") {
|
|
202
|
+
const hasDuration = Number.isFinite(row.payload.duration_ms);
|
|
203
|
+
const durationMs = hasDuration ? Math.max(0, Number(row.payload.duration_ms)) : 0;
|
|
108
204
|
completedTurns += 1;
|
|
109
|
-
workDurationMs +=
|
|
205
|
+
workDurationMs += durationMs;
|
|
206
|
+
completedDurationMs += durationMs;
|
|
207
|
+
if (hasDuration) turnDurationSamples.push(durationMs);
|
|
208
|
+
if (activeModel) {
|
|
209
|
+
models.add(activeModel);
|
|
210
|
+
sessionHasSelectedModel = true;
|
|
211
|
+
increment(modelUsage, activeModel);
|
|
212
|
+
}
|
|
213
|
+
else unattributedModelTurns += 1;
|
|
214
|
+
const hour = localHour(date, range.timezone);
|
|
215
|
+
if (hour !== null) activityByHour[hour] += 1;
|
|
110
216
|
if (Number.isFinite(row.payload.time_to_first_token_ms)) {
|
|
111
|
-
|
|
217
|
+
const firstTokenMs = Math.max(0, Number(row.payload.time_to_first_token_ms));
|
|
218
|
+
totalFirstTokenMs += firstTokenMs;
|
|
112
219
|
firstTokenSamples += 1;
|
|
220
|
+
firstTokenLatencySamples.push(firstTokenMs);
|
|
113
221
|
}
|
|
114
222
|
} else if (eventType === "user_message") userMessages += 1;
|
|
115
223
|
else if (eventType === "turn_aborted") {
|
|
116
224
|
interruptions += 1;
|
|
117
|
-
workDurationMs += Number(row.payload.duration_ms || 0);
|
|
225
|
+
workDurationMs += Math.max(0, Number(row.payload.duration_ms || 0));
|
|
226
|
+
const hour = localHour(date, range.timezone);
|
|
227
|
+
if (hour !== null) activityByHour[hour] += 1;
|
|
118
228
|
}
|
|
119
229
|
}
|
|
120
230
|
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
231
|
+
const toolCategory = toolCategoryForRow(row);
|
|
232
|
+
if (toolCategory) {
|
|
233
|
+
toolCalls += 1;
|
|
234
|
+
increment(toolUsage, toolCategory);
|
|
235
|
+
}
|
|
125
236
|
}
|
|
126
237
|
|
|
127
238
|
const fallbackModel = [...session.rows]
|
|
128
239
|
.reverse()
|
|
129
240
|
.find((row) => row.type === "turn_context" && row.payload?.model)?.payload?.model;
|
|
130
|
-
if (fallbackModel
|
|
241
|
+
if (fallbackModel && (!sessionHasSelectedModel || unattributedModelTurns)) {
|
|
242
|
+
models.add(fallbackModel);
|
|
243
|
+
if (unattributedModelTurns) increment(modelUsage, fallbackModel, unattributedModelTurns);
|
|
244
|
+
}
|
|
131
245
|
}
|
|
132
246
|
|
|
133
247
|
if (!scopedSessions.length || !timestamps.length) throw new Error(emptyRangeMessage(range));
|
|
@@ -159,6 +273,17 @@ export function collectMetrics(sessions, range) {
|
|
|
159
273
|
tokens,
|
|
160
274
|
models: [...models],
|
|
161
275
|
};
|
|
276
|
+
metrics.insights = buildInsights({
|
|
277
|
+
tokens,
|
|
278
|
+
completedTurns,
|
|
279
|
+
toolCalls,
|
|
280
|
+
completedDurationMs,
|
|
281
|
+
firstTokenSamples: firstTokenLatencySamples,
|
|
282
|
+
turnDurationSamples,
|
|
283
|
+
activityByHour,
|
|
284
|
+
modelUsage,
|
|
285
|
+
toolUsage,
|
|
286
|
+
});
|
|
162
287
|
metrics.workProfileId = selectWorkProfileId(metrics);
|
|
163
288
|
metrics.workPoints = calculateWorkPoints(metrics);
|
|
164
289
|
return metrics;
|
|
@@ -93,6 +93,45 @@ const RECEIPT_COPY = {
|
|
|
93
93
|
changelogLink: "查看更新日志 →",
|
|
94
94
|
sponsorLabel: "赞助伙伴",
|
|
95
95
|
sponsorAlt: "ModelFlare 标志",
|
|
96
|
+
features: {
|
|
97
|
+
title: "更多小票功能",
|
|
98
|
+
countLabel: "{count} 项",
|
|
99
|
+
description: "复制命令后,在终端中运行即可。",
|
|
100
|
+
copyLabel: "复制",
|
|
101
|
+
copiedLabel: "已复制",
|
|
102
|
+
copyErrorLabel: "复制失败",
|
|
103
|
+
copiedStatus: "命令已复制到剪贴板",
|
|
104
|
+
copyErrorStatus: "无法自动复制,请手动选择命令",
|
|
105
|
+
groups: [
|
|
106
|
+
{
|
|
107
|
+
title: "生成小票",
|
|
108
|
+
commands: [
|
|
109
|
+
{ label: "生成最近一次小票", args: "--latest" },
|
|
110
|
+
{ label: "生成最近 3 小时小票", args: "--hours 3" },
|
|
111
|
+
{ label: "生成今日小票", args: "--today" },
|
|
112
|
+
{ label: "生成最近 7 天小票", args: "--range last-7-days" },
|
|
113
|
+
{ label: "生成本周小票", args: "--range this-week" },
|
|
114
|
+
],
|
|
115
|
+
},
|
|
116
|
+
{
|
|
117
|
+
title: "自动与手动",
|
|
118
|
+
commands: [
|
|
119
|
+
{ label: "重新选择工作模式", args: "--setup" },
|
|
120
|
+
{ label: "开启自动保存", args: "--enable-auto" },
|
|
121
|
+
{ label: "切换为仅手动", args: "--disable-auto" },
|
|
122
|
+
{ label: "查看自动保存状态", args: "--auto-status" },
|
|
123
|
+
],
|
|
124
|
+
},
|
|
125
|
+
{
|
|
126
|
+
title: "票仔与对话开票",
|
|
127
|
+
commands: [
|
|
128
|
+
{ label: "安装票仔和对话开票", args: "--install-companion" },
|
|
129
|
+
{ label: "仅安装票仔", args: "--install-pet" },
|
|
130
|
+
{ label: "仅安装对话开票", args: "--install-skill" },
|
|
131
|
+
],
|
|
132
|
+
},
|
|
133
|
+
],
|
|
134
|
+
},
|
|
96
135
|
},
|
|
97
136
|
themes: {
|
|
98
137
|
classic: "经典白票",
|
|
@@ -135,6 +174,36 @@ const RECEIPT_COPY = {
|
|
|
135
174
|
interruptions: ["次", "次"],
|
|
136
175
|
seconds: ["秒", "秒"],
|
|
137
176
|
},
|
|
177
|
+
insights: {
|
|
178
|
+
title: "效率与工作结构",
|
|
179
|
+
cacheHit: "缓存命中率",
|
|
180
|
+
perTurn: "每轮效率",
|
|
181
|
+
totalTokens: "总 Token",
|
|
182
|
+
outputTokens: "输出 Token",
|
|
183
|
+
toolCalls: "工具调用",
|
|
184
|
+
firstTokenLatency: "首次响应延迟",
|
|
185
|
+
turnLatency: "整轮耗时",
|
|
186
|
+
p50: "P50",
|
|
187
|
+
p90: "P90",
|
|
188
|
+
noSamples: "暂无样本",
|
|
189
|
+
heatmap: "工作时间热力图",
|
|
190
|
+
heatmapCell: "{hour}:00 · {count} 轮",
|
|
191
|
+
models: "模型结构",
|
|
192
|
+
tools: "工具结构",
|
|
193
|
+
turnsUnit: "轮",
|
|
194
|
+
callsUnit: "次",
|
|
195
|
+
toolCategories: {
|
|
196
|
+
terminal: "终端命令",
|
|
197
|
+
"file-edit": "文件编辑",
|
|
198
|
+
browser: "浏览器操作",
|
|
199
|
+
research: "检索研究",
|
|
200
|
+
media: "图像与媒体",
|
|
201
|
+
agents: "多代理协作",
|
|
202
|
+
planning: "规划与确认",
|
|
203
|
+
integrations: "外部集成",
|
|
204
|
+
other: "其他工具",
|
|
205
|
+
},
|
|
206
|
+
},
|
|
138
207
|
footerThanks: "谢谢惠顾,欢迎明天继续改需求",
|
|
139
208
|
transferAria: "微信小程序导入联",
|
|
140
209
|
transferTitle: "导入到微信小程序",
|
|
@@ -186,6 +255,45 @@ const RECEIPT_COPY = {
|
|
|
186
255
|
changelogLink: "View the changelog →",
|
|
187
256
|
sponsorLabel: "Sponsored by",
|
|
188
257
|
sponsorAlt: "ModelFlare logo",
|
|
258
|
+
features: {
|
|
259
|
+
title: "More receipt features",
|
|
260
|
+
countLabel: "{count} commands",
|
|
261
|
+
description: "Copy a command, then run it in your terminal.",
|
|
262
|
+
copyLabel: "Copy",
|
|
263
|
+
copiedLabel: "Copied",
|
|
264
|
+
copyErrorLabel: "Copy failed",
|
|
265
|
+
copiedStatus: "Command copied to the clipboard",
|
|
266
|
+
copyErrorStatus: "Could not copy automatically. Select the command manually.",
|
|
267
|
+
groups: [
|
|
268
|
+
{
|
|
269
|
+
title: "Generate receipts",
|
|
270
|
+
commands: [
|
|
271
|
+
{ label: "Generate the latest receipt", args: "--latest" },
|
|
272
|
+
{ label: "Generate the last 3 hours", args: "--hours 3" },
|
|
273
|
+
{ label: "Generate today's receipt", args: "--today" },
|
|
274
|
+
{ label: "Generate the last 7 days", args: "--range last-7-days" },
|
|
275
|
+
{ label: "Generate this week's receipt", args: "--range this-week" },
|
|
276
|
+
],
|
|
277
|
+
},
|
|
278
|
+
{
|
|
279
|
+
title: "Automatic and manual",
|
|
280
|
+
commands: [
|
|
281
|
+
{ label: "Choose a working mode", args: "--setup" },
|
|
282
|
+
{ label: "Enable automatic saving", args: "--enable-auto" },
|
|
283
|
+
{ label: "Switch to manual only", args: "--disable-auto" },
|
|
284
|
+
{ label: "Check automatic saving", args: "--auto-status" },
|
|
285
|
+
],
|
|
286
|
+
},
|
|
287
|
+
{
|
|
288
|
+
title: "Ticket Buddy and chat commands",
|
|
289
|
+
commands: [
|
|
290
|
+
{ label: "Install Ticket Buddy and chat commands", args: "--install-companion" },
|
|
291
|
+
{ label: "Install Ticket Buddy only", args: "--install-pet" },
|
|
292
|
+
{ label: "Install chat commands only", args: "--install-skill" },
|
|
293
|
+
],
|
|
294
|
+
},
|
|
295
|
+
],
|
|
296
|
+
},
|
|
189
297
|
},
|
|
190
298
|
themes: {
|
|
191
299
|
classic: "Classic White",
|
|
@@ -228,6 +336,36 @@ const RECEIPT_COPY = {
|
|
|
228
336
|
interruptions: ["time", "times"],
|
|
229
337
|
seconds: ["sec", "sec"],
|
|
230
338
|
},
|
|
339
|
+
insights: {
|
|
340
|
+
title: "Efficiency & work structure",
|
|
341
|
+
cacheHit: "Cache hit rate",
|
|
342
|
+
perTurn: "Per-turn efficiency",
|
|
343
|
+
totalTokens: "total Tokens",
|
|
344
|
+
outputTokens: "output Tokens",
|
|
345
|
+
toolCalls: "tool calls",
|
|
346
|
+
firstTokenLatency: "First-token latency",
|
|
347
|
+
turnLatency: "Full-turn duration",
|
|
348
|
+
p50: "P50",
|
|
349
|
+
p90: "P90",
|
|
350
|
+
noSamples: "No samples",
|
|
351
|
+
heatmap: "Work-time heatmap",
|
|
352
|
+
heatmapCell: "{hour}:00 · {count} turns",
|
|
353
|
+
models: "Model structure",
|
|
354
|
+
tools: "Tool structure",
|
|
355
|
+
turnsUnit: "turns",
|
|
356
|
+
callsUnit: "calls",
|
|
357
|
+
toolCategories: {
|
|
358
|
+
terminal: "Terminal",
|
|
359
|
+
"file-edit": "File editing",
|
|
360
|
+
browser: "Browser",
|
|
361
|
+
research: "Research",
|
|
362
|
+
media: "Image & media",
|
|
363
|
+
agents: "Multi-agent",
|
|
364
|
+
planning: "Planning",
|
|
365
|
+
integrations: "Integrations",
|
|
366
|
+
other: "Other tools",
|
|
367
|
+
},
|
|
368
|
+
},
|
|
231
369
|
footerThanks: "Thanks for your business. More revisions welcome tomorrow.",
|
|
232
370
|
transferAria: "WeChat mini-program import stub",
|
|
233
371
|
transferTitle: "IMPORT INTO WECHAT",
|
|
@@ -21,6 +21,30 @@ const SCHEMA_VERSION = 2;
|
|
|
21
21
|
const SOURCE_VERSION = "cwr2";
|
|
22
22
|
const COLLECTOR_VERSION = "0.6.0";
|
|
23
23
|
|
|
24
|
+
function fallbackInsights(metrics) {
|
|
25
|
+
const completedTurns = Math.max(0, Number(metrics.completedTurns || 0));
|
|
26
|
+
const ratio = (value) => completedTurns ? Number(value || 0) / completedTurns : 0;
|
|
27
|
+
const inputTokens = Math.max(0, Number(metrics.tokens?.input_tokens || 0));
|
|
28
|
+
return {
|
|
29
|
+
cache_hit_rate: inputTokens
|
|
30
|
+
? Math.min(1, Math.max(0, Number(metrics.tokens?.cached_input_tokens || 0) / inputTokens))
|
|
31
|
+
: 0,
|
|
32
|
+
per_turn: {
|
|
33
|
+
total_tokens: ratio(metrics.tokens?.total_tokens),
|
|
34
|
+
output_tokens: ratio(metrics.tokens?.output_tokens),
|
|
35
|
+
tool_calls: ratio(metrics.toolCalls),
|
|
36
|
+
work_duration_ms: Math.round(ratio(metrics.workDurationMs)),
|
|
37
|
+
},
|
|
38
|
+
latency_ms: {
|
|
39
|
+
first_token: { sample_count: 0, p50: 0, p90: 0 },
|
|
40
|
+
turn: { sample_count: 0, p50: 0, p90: 0 },
|
|
41
|
+
},
|
|
42
|
+
activity_by_hour: Array(24).fill(0),
|
|
43
|
+
model_usage: (metrics.models || []).map((model) => ({ model, count: 0 })),
|
|
44
|
+
tool_usage: metrics.toolCalls ? [{ category: "other", count: metrics.toolCalls }] : [],
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
24
48
|
function fingerprintSessionIds(sessionIds) {
|
|
25
49
|
return crypto.createHash("sha256").update([...sessionIds].sort().join("|")).digest("hex").slice(0, 16);
|
|
26
50
|
}
|
|
@@ -29,6 +53,7 @@ export function buildReceiptRecord(metrics, defaultTheme = "classic", locale = D
|
|
|
29
53
|
const sessionFingerprint = fingerprintSessionIds(metrics.sessionIds);
|
|
30
54
|
const logicalKey = buildLogicalReceiptKey(metrics);
|
|
31
55
|
const summaryOnly = metrics.mode === "last-hours";
|
|
56
|
+
const insights = metrics.insights || fallbackInsights(metrics);
|
|
32
57
|
const schemaVersion = summaryOnly ? 1 : SCHEMA_VERSION;
|
|
33
58
|
const sourceVersion = summaryOnly ? "cwr1" : SOURCE_VERSION;
|
|
34
59
|
const id = summaryOnly
|
|
@@ -41,6 +66,7 @@ export function buildReceiptRecord(metrics, defaultTheme = "classic", locale = D
|
|
|
41
66
|
tools: metrics.toolCalls,
|
|
42
67
|
turns: metrics.completedTurns,
|
|
43
68
|
interruptions: metrics.interruptions,
|
|
69
|
+
insights,
|
|
44
70
|
scope: metrics.mode,
|
|
45
71
|
rangeStartDate: metrics.rangeStartDate,
|
|
46
72
|
rangeEndDate: metrics.rangeEndDate,
|
|
@@ -104,6 +130,7 @@ export function buildReceiptRecord(metrics, defaultTheme = "classic", locale = D
|
|
|
104
130
|
average_first_token_ms: Math.round(metrics.averageFirstTokenMs),
|
|
105
131
|
tokens: { ...metrics.tokens },
|
|
106
132
|
models: [...metrics.models],
|
|
133
|
+
insights,
|
|
107
134
|
receipt_work_points: metrics.workPoints,
|
|
108
135
|
receipt_formula_version: "receipt_work_points_v1",
|
|
109
136
|
},
|
|
@@ -120,6 +147,9 @@ export function buildReceiptRecord(metrics, defaultTheme = "classic", locale = D
|
|
|
120
147
|
contains_code: false,
|
|
121
148
|
contains_paths: false,
|
|
122
149
|
contains_filenames: false,
|
|
150
|
+
contains_tool_names: false,
|
|
151
|
+
contains_tool_arguments: false,
|
|
152
|
+
contains_tool_output: false,
|
|
123
153
|
},
|
|
124
154
|
};
|
|
125
155
|
if (!summaryOnly) {
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
const TOOL_CATEGORIES = new Set([
|
|
2
|
+
"terminal",
|
|
3
|
+
"file-edit",
|
|
4
|
+
"browser",
|
|
5
|
+
"research",
|
|
6
|
+
"media",
|
|
7
|
+
"agents",
|
|
8
|
+
"planning",
|
|
9
|
+
"integrations",
|
|
10
|
+
"other",
|
|
11
|
+
]);
|
|
12
|
+
|
|
13
|
+
export function normalizeToolCategory(value) {
|
|
14
|
+
return TOOL_CATEGORIES.has(value) ? value : "other";
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function classifyToolName(value) {
|
|
18
|
+
const name = String(value || "").trim().toLowerCase();
|
|
19
|
+
if (!name) return "other";
|
|
20
|
+
if (/(^|[_.-])(apply[_-]?patch|edit(?:[_-]?file)?|write(?:[_-]?file)?)$/.test(name)) return "file-edit";
|
|
21
|
+
if (/browser|playwright|computer|chrome|navigate|screenshot/.test(name)) return "browser";
|
|
22
|
+
if (/web[_-]?search|search[_-]?docs|fetch[_-]?doc|openai.*docs/.test(name)) return "research";
|
|
23
|
+
if (/image|audio|video|canvas|view[_-]?image/.test(name)) return "media";
|
|
24
|
+
if (/spawn[_-]?agent|send[_-]?message|followup[_-]?task|wait[_-]?agent|collaboration/.test(name)) return "agents";
|
|
25
|
+
if (/update[_-]?plan|request[_-]?user[_-]?input|create[_-]?goal|update[_-]?goal/.test(name)) return "planning";
|
|
26
|
+
if (/exec|shell|bash|terminal|write[_-]?stdin|command/.test(name)) return "terminal";
|
|
27
|
+
if (/^mcp__|connector|plugin|slack|github|notion|linear|figma/.test(name)) return "integrations";
|
|
28
|
+
return "other";
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function toolCategoryForRow(row) {
|
|
32
|
+
if (row?.type !== "response_item") return null;
|
|
33
|
+
const payloadType = row.payload?.type;
|
|
34
|
+
if (payloadType !== "custom_tool_call" && payloadType !== "function_call") return null;
|
|
35
|
+
return normalizeToolCategory(row.payload?.tool_category);
|
|
36
|
+
}
|
package/src/parsers/codex.mjs
CHANGED
|
@@ -4,6 +4,7 @@ import os from "node:os";
|
|
|
4
4
|
import path from "node:path";
|
|
5
5
|
|
|
6
6
|
import { rowDate } from "../lib/time.mjs";
|
|
7
|
+
import { classifyToolName } from "../lib/tool-category.mjs";
|
|
7
8
|
|
|
8
9
|
const READ_CHUNK_BYTES = 256 * 1024;
|
|
9
10
|
const MAX_JSONL_ROW_BYTES = 64 * 1024 * 1024;
|
|
@@ -70,6 +71,11 @@ function compactPayload(rowType, value) {
|
|
|
70
71
|
if (firstTokenMs !== null) payload.time_to_first_token_ms = firstTokenMs;
|
|
71
72
|
const totalTokenUsage = compactTokenUsage(value.info?.total_token_usage);
|
|
72
73
|
if (totalTokenUsage) payload.info = { total_token_usage: totalTokenUsage };
|
|
74
|
+
} else if (
|
|
75
|
+
rowType === "response_item" &&
|
|
76
|
+
(value.type === "custom_tool_call" || value.type === "function_call")
|
|
77
|
+
) {
|
|
78
|
+
payload.tool_category = classifyToolName(value.name);
|
|
73
79
|
}
|
|
74
80
|
|
|
75
81
|
return Object.keys(payload).length ? payload : null;
|
package/src/renderers/html.mjs
CHANGED
|
@@ -62,6 +62,151 @@ function formatDateKey(value, locale) {
|
|
|
62
62
|
return locale === "en" ? `${match[2]}/${match[3]}/${match[1]}` : `${match[1]}/${match[2]}/${match[3]}`;
|
|
63
63
|
}
|
|
64
64
|
|
|
65
|
+
function formatDecimal(value, locale, maximumFractionDigits = 1) {
|
|
66
|
+
return new Intl.NumberFormat(locale === "en" ? "en-US" : "zh-CN", {
|
|
67
|
+
maximumFractionDigits,
|
|
68
|
+
}).format(Math.max(0, Number(value || 0)));
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function formatPercent(value, locale) {
|
|
72
|
+
return new Intl.NumberFormat(locale === "en" ? "en-US" : "zh-CN", {
|
|
73
|
+
style: "percent",
|
|
74
|
+
maximumFractionDigits: 1,
|
|
75
|
+
}).format(Math.min(1, Math.max(0, Number(value || 0))));
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function formatSeconds(milliseconds, locale) {
|
|
79
|
+
return `${formatDecimal(Number(milliseconds || 0) / 1000, locale)}s`;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function renderBreakdown(items, { key, labels = {}, unit, emptyLabel, locale }) {
|
|
83
|
+
if (!items.length) return `<p class="structure-empty">${escapeHtml(emptyLabel)}</p>`;
|
|
84
|
+
const maximum = Math.max(1, ...items.map((item) => Number(item.count || 0)));
|
|
85
|
+
return items.slice(0, 6).map((item) => {
|
|
86
|
+
const name = String(item[key] || "");
|
|
87
|
+
const label = labels[name] || name;
|
|
88
|
+
const count = Math.max(0, Number(item.count || 0));
|
|
89
|
+
const width = count ? Math.max(6, Math.round((count / maximum) * 100)) : 0;
|
|
90
|
+
return `
|
|
91
|
+
<div class="structure-row">
|
|
92
|
+
<span class="structure-name" title="${escapeHtml(label)}">${escapeHtml(label)}</span>
|
|
93
|
+
<span class="structure-bar" aria-hidden="true"><i style="width:${width}%"></i></span>
|
|
94
|
+
<strong>${escapeHtml(`${formatDecimal(count, locale, 0)} ${unit}`)}</strong>
|
|
95
|
+
</div>`;
|
|
96
|
+
}).join("");
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function renderInsights(record, copy, locale) {
|
|
100
|
+
const insights = record.stats.insights || {};
|
|
101
|
+
const perTurn = insights.per_turn || {};
|
|
102
|
+
const firstToken = insights.latency_ms?.first_token || {};
|
|
103
|
+
const turnLatency = insights.latency_ms?.turn || {};
|
|
104
|
+
const activity = Array.from({ length: 24 }, (_, hour) => Number(insights.activity_by_hour?.[hour] || 0));
|
|
105
|
+
const activityPeak = Math.max(0, ...activity);
|
|
106
|
+
const heatmapCells = activity.map((count, hour) => {
|
|
107
|
+
const strength = count && activityPeak ? 16 + Math.round((count / activityPeak) * 84) : 4;
|
|
108
|
+
const aria = copy.insights.heatmapCell
|
|
109
|
+
.replaceAll("{hour}", String(hour).padStart(2, "0"))
|
|
110
|
+
.replaceAll("{count}", formatDecimal(count, locale, 0));
|
|
111
|
+
return `<span class="heatmap-cell" style="--heat:${strength}%" role="img" aria-label="${escapeHtml(aria)}" title="${escapeHtml(aria)}"></span>`;
|
|
112
|
+
}).join("");
|
|
113
|
+
const firstTokenValue = firstToken.sample_count
|
|
114
|
+
? `${copy.insights.p50} ${formatSeconds(firstToken.p50, locale)}`
|
|
115
|
+
: copy.insights.noSamples;
|
|
116
|
+
const firstTokenDetail = firstToken.sample_count
|
|
117
|
+
? `${copy.insights.p90} ${formatSeconds(firstToken.p90, locale)}`
|
|
118
|
+
: "—";
|
|
119
|
+
const turnValue = turnLatency.sample_count
|
|
120
|
+
? `${copy.insights.p50} ${formatSeconds(turnLatency.p50, locale)}`
|
|
121
|
+
: copy.insights.noSamples;
|
|
122
|
+
const turnDetail = turnLatency.sample_count
|
|
123
|
+
? `${copy.insights.p90} ${formatSeconds(turnLatency.p90, locale)}`
|
|
124
|
+
: "—";
|
|
125
|
+
const modelItems = insights.model_usage?.length
|
|
126
|
+
? insights.model_usage
|
|
127
|
+
: (record.stats.models || []).map((model) => ({ model, count: 0 }));
|
|
128
|
+
const toolItems = insights.tool_usage || [];
|
|
129
|
+
const modelRows = renderBreakdown(modelItems, {
|
|
130
|
+
key: "model",
|
|
131
|
+
unit: copy.insights.turnsUnit,
|
|
132
|
+
emptyLabel: copy.insights.noSamples,
|
|
133
|
+
locale,
|
|
134
|
+
});
|
|
135
|
+
const toolRows = renderBreakdown(toolItems, {
|
|
136
|
+
key: "category",
|
|
137
|
+
labels: copy.insights.toolCategories,
|
|
138
|
+
unit: copy.insights.callsUnit,
|
|
139
|
+
emptyLabel: copy.insights.noSamples,
|
|
140
|
+
locale,
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
return `
|
|
144
|
+
<section class="insights" aria-label="${escapeHtml(copy.insights.title)}">
|
|
145
|
+
<h2 class="insights-title">${escapeHtml(copy.insights.title)}</h2>
|
|
146
|
+
<div class="insight-metrics">
|
|
147
|
+
<div class="insight-metric">
|
|
148
|
+
<span>${escapeHtml(copy.insights.cacheHit)}</span>
|
|
149
|
+
<strong>${escapeHtml(formatPercent(insights.cache_hit_rate, locale))}</strong>
|
|
150
|
+
<small>${escapeHtml(formatNumber(record.stats.tokens.cached_input_tokens, locale))} / ${escapeHtml(formatNumber(record.stats.tokens.input_tokens, locale))}</small>
|
|
151
|
+
</div>
|
|
152
|
+
<div class="insight-metric">
|
|
153
|
+
<span>${escapeHtml(copy.insights.perTurn)}</span>
|
|
154
|
+
<strong>${escapeHtml(`${formatDecimal(perTurn.total_tokens, locale)} Token`)}</strong>
|
|
155
|
+
<small>${escapeHtml(`${formatDecimal(perTurn.output_tokens, locale)} ${copy.insights.outputTokens} · ${formatDecimal(perTurn.tool_calls, locale)} ${copy.insights.toolCalls}`)}</small>
|
|
156
|
+
</div>
|
|
157
|
+
<div class="insight-metric">
|
|
158
|
+
<span>${escapeHtml(copy.insights.firstTokenLatency)}</span>
|
|
159
|
+
<strong>${escapeHtml(firstTokenValue)}</strong>
|
|
160
|
+
<small>${escapeHtml(firstTokenDetail)}</small>
|
|
161
|
+
</div>
|
|
162
|
+
<div class="insight-metric">
|
|
163
|
+
<span>${escapeHtml(copy.insights.turnLatency)}</span>
|
|
164
|
+
<strong>${escapeHtml(turnValue)}</strong>
|
|
165
|
+
<small>${escapeHtml(turnDetail)}</small>
|
|
166
|
+
</div>
|
|
167
|
+
</div>
|
|
168
|
+
<div class="heatmap-block">
|
|
169
|
+
<h3>${escapeHtml(copy.insights.heatmap)}</h3>
|
|
170
|
+
<div class="heatmap-grid">${heatmapCells}</div>
|
|
171
|
+
<div class="heatmap-axis" aria-hidden="true"><span>00</span><span>06</span><span>12</span><span>18</span><span>23</span></div>
|
|
172
|
+
</div>
|
|
173
|
+
<div class="structure-grid">
|
|
174
|
+
<section class="structure-group">
|
|
175
|
+
<h3>${escapeHtml(copy.insights.models)}</h3>${modelRows}
|
|
176
|
+
</section>
|
|
177
|
+
<section class="structure-group">
|
|
178
|
+
<h3>${escapeHtml(copy.insights.tools)}</h3>${toolRows}
|
|
179
|
+
</section>
|
|
180
|
+
</div>
|
|
181
|
+
</section>`;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function renderFeatureCommands(featureCopy, locale) {
|
|
185
|
+
const languageArgument = locale === "en" ? " --lang en" : "";
|
|
186
|
+
return featureCopy.groups.map((group) => {
|
|
187
|
+
const commands = group.commands.map((item) => {
|
|
188
|
+
const command = `npx codex-work-receipt@latest ${item.args}${languageArgument}`;
|
|
189
|
+
return `
|
|
190
|
+
<li class="feature-command">
|
|
191
|
+
<strong class="feature-command__name">${escapeHtml(item.label)}</strong>
|
|
192
|
+
<div class="feature-command__action">
|
|
193
|
+
<code tabindex="0">${escapeHtml(command)}</code>
|
|
194
|
+
<button class="feature-copy-button" type="button" data-copy-command="${escapeHtml(command)}" aria-label="${escapeHtml(`${featureCopy.copyLabel}: ${item.label}`)}" title="${escapeHtml(featureCopy.copyLabel)}">
|
|
195
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect width="14" height="14" x="8" y="8" rx="2" ry="2"></rect><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"></path></svg>
|
|
196
|
+
<span data-copy-label>${escapeHtml(featureCopy.copyLabel)}</span>
|
|
197
|
+
</button>
|
|
198
|
+
</div>
|
|
199
|
+
</li>`;
|
|
200
|
+
}).join("");
|
|
201
|
+
return `
|
|
202
|
+
<section class="feature-group">
|
|
203
|
+
<h4>${escapeHtml(group.title)}</h4>
|
|
204
|
+
<ul>${commands}
|
|
205
|
+
</ul>
|
|
206
|
+
</section>`;
|
|
207
|
+
}).join("");
|
|
208
|
+
}
|
|
209
|
+
|
|
65
210
|
export function renderHtml({ record, dataQrDataUrl = null, miniProgramCodeDataUrl = null, transferFile = null }) {
|
|
66
211
|
const locale = record.locale || DEFAULT_LOCALE;
|
|
67
212
|
const copy = getReceiptCopy(locale);
|
|
@@ -114,6 +259,19 @@ export function renderHtml({ record, dataQrDataUrl = null, miniProgramCodeDataUr
|
|
|
114
259
|
successLabel: copy.downloadSuccess,
|
|
115
260
|
errorLabel: copy.downloadError,
|
|
116
261
|
}).replaceAll("<", "\\u003c");
|
|
262
|
+
const featureConfig = JSON.stringify({
|
|
263
|
+
copyLabel: copy.sidebar.features.copyLabel,
|
|
264
|
+
copiedLabel: copy.sidebar.features.copiedLabel,
|
|
265
|
+
copyErrorLabel: copy.sidebar.features.copyErrorLabel,
|
|
266
|
+
copiedStatus: copy.sidebar.features.copiedStatus,
|
|
267
|
+
copyErrorStatus: copy.sidebar.features.copyErrorStatus,
|
|
268
|
+
}).replaceAll("<", "\\u003c");
|
|
269
|
+
const featureCommands = renderFeatureCommands(copy.sidebar.features, locale);
|
|
270
|
+
const featureCommandCount = copy.sidebar.features.groups
|
|
271
|
+
.reduce((total, group) => total + group.commands.length, 0);
|
|
272
|
+
const featureCountLabel = copy.sidebar.features.countLabel
|
|
273
|
+
.replace("{count}", formatNumber(featureCommandCount, locale));
|
|
274
|
+
const insights = renderInsights(record, copy, locale);
|
|
117
275
|
const rows = [
|
|
118
276
|
receiptRow(copy.rows.scope, scopeLabel),
|
|
119
277
|
receiptRow(copy.rows.sessions, formatCount(record.stats.session_count, copy.units.sessions, locale)),
|
|
@@ -312,6 +470,199 @@ export function renderHtml({ record, dataQrDataUrl = null, miniProgramCodeDataUr
|
|
|
312
470
|
font-weight: 600;
|
|
313
471
|
line-height: 1.4;
|
|
314
472
|
}
|
|
473
|
+
.sidebar-features {
|
|
474
|
+
--feature-ink: #282620;
|
|
475
|
+
--feature-muted: #756f65;
|
|
476
|
+
--feature-line: #c9c2b6;
|
|
477
|
+
--feature-soft-line: #ded7cc;
|
|
478
|
+
--feature-paper: #f6f2e9;
|
|
479
|
+
--feature-paper-soft: #fbfaf7;
|
|
480
|
+
--feature-command: #ece8df;
|
|
481
|
+
padding: 0;
|
|
482
|
+
overflow: hidden;
|
|
483
|
+
border-color: var(--feature-line);
|
|
484
|
+
background: var(--feature-paper);
|
|
485
|
+
color: var(--feature-ink);
|
|
486
|
+
text-align: left;
|
|
487
|
+
transition: background .15s ease, border-color .15s ease, box-shadow .15s ease;
|
|
488
|
+
}
|
|
489
|
+
.sidebar-features[open] {
|
|
490
|
+
background: var(--feature-paper-soft);
|
|
491
|
+
box-shadow: 0 10px 28px rgba(53, 48, 39, .1);
|
|
492
|
+
}
|
|
493
|
+
.sidebar-features__summary {
|
|
494
|
+
display: flex;
|
|
495
|
+
align-items: center;
|
|
496
|
+
justify-content: space-between;
|
|
497
|
+
gap: 8px;
|
|
498
|
+
padding: 12px;
|
|
499
|
+
cursor: pointer;
|
|
500
|
+
list-style: none;
|
|
501
|
+
user-select: none;
|
|
502
|
+
transition: background .15s ease;
|
|
503
|
+
}
|
|
504
|
+
.sidebar-features__summary::-webkit-details-marker { display: none; }
|
|
505
|
+
.sidebar-features__summary:hover { background: #eee8dc; }
|
|
506
|
+
.sidebar-features__summary .sidebar-card__title {
|
|
507
|
+
margin: 0;
|
|
508
|
+
color: var(--feature-ink);
|
|
509
|
+
}
|
|
510
|
+
.sidebar-features__summary .sidebar-card__title svg { color: var(--feature-muted); }
|
|
511
|
+
.sidebar-features__summary:focus-visible {
|
|
512
|
+
outline: 2px solid var(--feature-ink);
|
|
513
|
+
outline-offset: -3px;
|
|
514
|
+
}
|
|
515
|
+
.sidebar-features__meta {
|
|
516
|
+
display: inline-flex;
|
|
517
|
+
align-items: center;
|
|
518
|
+
gap: 5px;
|
|
519
|
+
flex-shrink: 0;
|
|
520
|
+
}
|
|
521
|
+
.sidebar-features__count {
|
|
522
|
+
color: var(--feature-muted);
|
|
523
|
+
font-family: system-ui, -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif;
|
|
524
|
+
font-size: 9px;
|
|
525
|
+
white-space: nowrap;
|
|
526
|
+
}
|
|
527
|
+
.sidebar-features__chevron {
|
|
528
|
+
width: 14px;
|
|
529
|
+
height: 14px;
|
|
530
|
+
flex-shrink: 0;
|
|
531
|
+
color: var(--feature-muted);
|
|
532
|
+
transition: transform .18s ease;
|
|
533
|
+
}
|
|
534
|
+
.sidebar-features[open] .sidebar-features__chevron { transform: rotate(180deg); }
|
|
535
|
+
.sidebar-features__body {
|
|
536
|
+
padding: 0 12px 12px;
|
|
537
|
+
border-top: 1px solid var(--feature-soft-line);
|
|
538
|
+
background: var(--feature-paper-soft);
|
|
539
|
+
animation: feature-reveal .15s ease-out;
|
|
540
|
+
}
|
|
541
|
+
.sidebar-features .sidebar-features__description {
|
|
542
|
+
margin: 10px 0 12px;
|
|
543
|
+
color: var(--feature-muted);
|
|
544
|
+
}
|
|
545
|
+
.feature-group + .feature-group {
|
|
546
|
+
margin-top: 14px;
|
|
547
|
+
padding-top: 13px;
|
|
548
|
+
border-top: 1px solid var(--feature-soft-line);
|
|
549
|
+
}
|
|
550
|
+
.feature-group h4 {
|
|
551
|
+
margin: 0 0 8px;
|
|
552
|
+
color: var(--feature-muted);
|
|
553
|
+
font-family: system-ui, -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif;
|
|
554
|
+
font-size: 9px;
|
|
555
|
+
font-weight: 700;
|
|
556
|
+
letter-spacing: .08em;
|
|
557
|
+
text-transform: uppercase;
|
|
558
|
+
}
|
|
559
|
+
.feature-group ul {
|
|
560
|
+
margin: 0;
|
|
561
|
+
padding: 0;
|
|
562
|
+
list-style: none;
|
|
563
|
+
}
|
|
564
|
+
.feature-command + .feature-command {
|
|
565
|
+
margin-top: 10px;
|
|
566
|
+
padding-top: 10px;
|
|
567
|
+
border-top: 1px dashed var(--feature-soft-line);
|
|
568
|
+
}
|
|
569
|
+
.feature-command__name {
|
|
570
|
+
display: block;
|
|
571
|
+
margin-bottom: 5px;
|
|
572
|
+
color: var(--feature-ink);
|
|
573
|
+
font-family: system-ui, -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif;
|
|
574
|
+
font-size: 10px;
|
|
575
|
+
font-weight: 600;
|
|
576
|
+
line-height: 1.4;
|
|
577
|
+
}
|
|
578
|
+
.feature-command__action {
|
|
579
|
+
display: grid;
|
|
580
|
+
grid-template-columns: minmax(0, 1fr) auto;
|
|
581
|
+
gap: 5px;
|
|
582
|
+
align-items: stretch;
|
|
583
|
+
}
|
|
584
|
+
.feature-command code {
|
|
585
|
+
display: block;
|
|
586
|
+
min-width: 0;
|
|
587
|
+
padding: 6px;
|
|
588
|
+
overflow-x: auto;
|
|
589
|
+
border: 1px solid #d5cfc4;
|
|
590
|
+
border-radius: 4px;
|
|
591
|
+
background: var(--feature-command);
|
|
592
|
+
color: #34312c;
|
|
593
|
+
font-size: 9px;
|
|
594
|
+
line-height: 1.35;
|
|
595
|
+
white-space: nowrap;
|
|
596
|
+
scrollbar-width: thin;
|
|
597
|
+
scrollbar-color: #b8b0a4 transparent;
|
|
598
|
+
}
|
|
599
|
+
.feature-command code:focus-visible {
|
|
600
|
+
outline: 2px solid #81786c;
|
|
601
|
+
outline-offset: 1px;
|
|
602
|
+
}
|
|
603
|
+
.feature-copy-button {
|
|
604
|
+
display: inline-flex;
|
|
605
|
+
align-items: center;
|
|
606
|
+
justify-content: center;
|
|
607
|
+
gap: 3px;
|
|
608
|
+
min-width: 48px;
|
|
609
|
+
padding: 5px 6px;
|
|
610
|
+
border: 1px solid #bdb5a9;
|
|
611
|
+
border-radius: 4px;
|
|
612
|
+
background: #fff;
|
|
613
|
+
color: #4c4942;
|
|
614
|
+
cursor: pointer;
|
|
615
|
+
font-family: system-ui, -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif;
|
|
616
|
+
font-size: 9px;
|
|
617
|
+
font-weight: 600;
|
|
618
|
+
}
|
|
619
|
+
.feature-copy-button:hover { background: #e5dfd4; }
|
|
620
|
+
.feature-copy-button:focus-visible {
|
|
621
|
+
outline: 2px solid var(--feature-ink);
|
|
622
|
+
outline-offset: 2px;
|
|
623
|
+
}
|
|
624
|
+
.feature-copy-button svg {
|
|
625
|
+
width: 11px;
|
|
626
|
+
height: 11px;
|
|
627
|
+
flex-shrink: 0;
|
|
628
|
+
}
|
|
629
|
+
.feature-copy-button[data-state="success"] {
|
|
630
|
+
border-color: #78a782;
|
|
631
|
+
background: #eef7ef;
|
|
632
|
+
color: #276036;
|
|
633
|
+
}
|
|
634
|
+
.feature-copy-button[data-state="error"] {
|
|
635
|
+
border-color: #c88c84;
|
|
636
|
+
background: #fff1ef;
|
|
637
|
+
color: #8b3028;
|
|
638
|
+
}
|
|
639
|
+
.feature-copy-status {
|
|
640
|
+
position: absolute;
|
|
641
|
+
width: 1px;
|
|
642
|
+
height: 1px;
|
|
643
|
+
padding: 0;
|
|
644
|
+
margin: -1px;
|
|
645
|
+
overflow: hidden;
|
|
646
|
+
clip: rect(0, 0, 0, 0);
|
|
647
|
+
white-space: nowrap;
|
|
648
|
+
border: 0;
|
|
649
|
+
}
|
|
650
|
+
@keyframes feature-reveal {
|
|
651
|
+
from { opacity: 0; transform: translateY(-3px); }
|
|
652
|
+
to { opacity: 1; transform: translateY(0); }
|
|
653
|
+
}
|
|
654
|
+
@media (min-width: 1021px) {
|
|
655
|
+
.sidebar-features[open] {
|
|
656
|
+
width: 300px;
|
|
657
|
+
margin-left: -120px;
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
@media (prefers-reduced-motion: reduce) {
|
|
661
|
+
.sidebar-features,
|
|
662
|
+
.sidebar-features__summary,
|
|
663
|
+
.sidebar-features__chevron { transition: none; }
|
|
664
|
+
.sidebar-features__body { animation: none; }
|
|
665
|
+
}
|
|
315
666
|
.toolbar {
|
|
316
667
|
display: flex;
|
|
317
668
|
width: 100%;
|
|
@@ -452,6 +803,110 @@ export function renderHtml({ record, dataQrDataUrl = null, miniProgramCodeDataUr
|
|
|
452
803
|
padding: 8px 6px;
|
|
453
804
|
background: var(--accent);
|
|
454
805
|
}
|
|
806
|
+
.insights-title {
|
|
807
|
+
margin: 0 0 12px;
|
|
808
|
+
font-size: 14px;
|
|
809
|
+
letter-spacing: 0;
|
|
810
|
+
text-align: center;
|
|
811
|
+
}
|
|
812
|
+
.insight-metrics {
|
|
813
|
+
display: grid;
|
|
814
|
+
grid-template-columns: repeat(2, minmax(0, 1fr));
|
|
815
|
+
border-top: 1px solid var(--line);
|
|
816
|
+
border-left: 1px solid var(--line);
|
|
817
|
+
}
|
|
818
|
+
.insight-metric {
|
|
819
|
+
min-width: 0;
|
|
820
|
+
padding: 10px;
|
|
821
|
+
border-right: 1px solid var(--line);
|
|
822
|
+
border-bottom: 1px solid var(--line);
|
|
823
|
+
}
|
|
824
|
+
.insight-metric span,
|
|
825
|
+
.insight-metric strong,
|
|
826
|
+
.insight-metric small { display: block; }
|
|
827
|
+
.insight-metric span {
|
|
828
|
+
color: var(--muted);
|
|
829
|
+
font-size: 10px;
|
|
830
|
+
line-height: 1.35;
|
|
831
|
+
}
|
|
832
|
+
.insight-metric strong {
|
|
833
|
+
margin-top: 5px;
|
|
834
|
+
font-size: 16px;
|
|
835
|
+
line-height: 1.2;
|
|
836
|
+
}
|
|
837
|
+
.insight-metric small {
|
|
838
|
+
margin-top: 4px;
|
|
839
|
+
overflow: hidden;
|
|
840
|
+
color: var(--muted);
|
|
841
|
+
font-size: 9px;
|
|
842
|
+
line-height: 1.35;
|
|
843
|
+
text-overflow: ellipsis;
|
|
844
|
+
white-space: nowrap;
|
|
845
|
+
}
|
|
846
|
+
.heatmap-block,
|
|
847
|
+
.structure-grid {
|
|
848
|
+
margin-top: 16px;
|
|
849
|
+
padding-top: 14px;
|
|
850
|
+
border-top: 1px dashed var(--line);
|
|
851
|
+
}
|
|
852
|
+
.heatmap-block h3,
|
|
853
|
+
.structure-group h3 {
|
|
854
|
+
margin: 0 0 9px;
|
|
855
|
+
color: var(--muted);
|
|
856
|
+
font-size: 10px;
|
|
857
|
+
letter-spacing: 0;
|
|
858
|
+
text-transform: uppercase;
|
|
859
|
+
}
|
|
860
|
+
.heatmap-grid {
|
|
861
|
+
display: grid;
|
|
862
|
+
grid-template-columns: repeat(24, minmax(0, 1fr));
|
|
863
|
+
gap: 2px;
|
|
864
|
+
}
|
|
865
|
+
.heatmap-cell {
|
|
866
|
+
display: block;
|
|
867
|
+
height: 22px;
|
|
868
|
+
background: color-mix(in srgb, var(--ink) var(--heat), transparent);
|
|
869
|
+
}
|
|
870
|
+
.heatmap-axis {
|
|
871
|
+
display: flex;
|
|
872
|
+
justify-content: space-between;
|
|
873
|
+
margin-top: 5px;
|
|
874
|
+
color: var(--muted);
|
|
875
|
+
font-size: 8px;
|
|
876
|
+
}
|
|
877
|
+
.structure-grid {
|
|
878
|
+
display: grid;
|
|
879
|
+
grid-template-columns: repeat(2, minmax(0, 1fr));
|
|
880
|
+
gap: 18px;
|
|
881
|
+
}
|
|
882
|
+
.structure-group { min-width: 0; }
|
|
883
|
+
.structure-row {
|
|
884
|
+
display: grid;
|
|
885
|
+
grid-template-columns: minmax(0, 1fr) minmax(32px, .65fr) auto;
|
|
886
|
+
gap: 6px;
|
|
887
|
+
align-items: center;
|
|
888
|
+
min-width: 0;
|
|
889
|
+
padding: 4px 0;
|
|
890
|
+
font-size: 9px;
|
|
891
|
+
}
|
|
892
|
+
.structure-name {
|
|
893
|
+
min-width: 0;
|
|
894
|
+
overflow: hidden;
|
|
895
|
+
text-overflow: ellipsis;
|
|
896
|
+
white-space: nowrap;
|
|
897
|
+
}
|
|
898
|
+
.structure-bar {
|
|
899
|
+
display: block;
|
|
900
|
+
height: 4px;
|
|
901
|
+
background: color-mix(in srgb, var(--ink) 10%, transparent);
|
|
902
|
+
}
|
|
903
|
+
.structure-bar i {
|
|
904
|
+
display: block;
|
|
905
|
+
height: 100%;
|
|
906
|
+
background: var(--ink);
|
|
907
|
+
}
|
|
908
|
+
.structure-row strong { font-size: 8px; white-space: nowrap; }
|
|
909
|
+
.structure-empty { margin: 0; color: var(--muted); font-size: 9px; }
|
|
455
910
|
.verdict { text-align: center; }
|
|
456
911
|
.verdict h2 { margin: 0; font-size: 22px; letter-spacing: .04em; }
|
|
457
912
|
.review {
|
|
@@ -663,6 +1118,7 @@ export function renderHtml({ record, dataQrDataUrl = null, miniProgramCodeDataUr
|
|
|
663
1118
|
flex: 1 1 160px;
|
|
664
1119
|
min-width: 0;
|
|
665
1120
|
}
|
|
1121
|
+
.sidebar-features { flex-basis: 100%; }
|
|
666
1122
|
}
|
|
667
1123
|
@media (max-width: 420px) {
|
|
668
1124
|
.layout { padding-inline: 10px; }
|
|
@@ -674,6 +1130,7 @@ export function renderHtml({ record, dataQrDataUrl = null, miniProgramCodeDataUr
|
|
|
674
1130
|
.meta { grid-template-columns: 1fr; }
|
|
675
1131
|
.meta div:nth-child(even) { text-align: left; }
|
|
676
1132
|
.transfer-layout { grid-template-columns: 1fr; }
|
|
1133
|
+
.structure-grid { grid-template-columns: 1fr; }
|
|
677
1134
|
.sidebar { flex-direction: column; }
|
|
678
1135
|
.sidebar-card { flex-basis: auto; }
|
|
679
1136
|
}
|
|
@@ -713,6 +1170,10 @@ export function renderHtml({ record, dataQrDataUrl = null, miniProgramCodeDataUr
|
|
|
713
1170
|
<section>${rows}</section>
|
|
714
1171
|
<div class="divider"></div>
|
|
715
1172
|
|
|
1173
|
+
${insights}
|
|
1174
|
+
|
|
1175
|
+
<div class="divider"></div>
|
|
1176
|
+
|
|
716
1177
|
<section class="verdict">
|
|
717
1178
|
<h2>${escapeHtml(record.presentation.work_title)}</h2>
|
|
718
1179
|
<p class="review">${escapeHtml(record.presentation.review)}</p>
|
|
@@ -768,12 +1229,29 @@ export function renderHtml({ record, dataQrDataUrl = null, miniProgramCodeDataUr
|
|
|
768
1229
|
<span class="sidebar-sponsor__name">ModelFlare<br>modelflare.dev</span>
|
|
769
1230
|
</a>
|
|
770
1231
|
</div>
|
|
1232
|
+
<details class="sidebar-card sidebar-features" data-feature-details>
|
|
1233
|
+
<summary class="sidebar-features__summary">
|
|
1234
|
+
<span class="sidebar-card__title">
|
|
1235
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="4 17 10 11 4 5"></polyline><line x1="12" x2="20" y1="19" y2="19"></line></svg>
|
|
1236
|
+
${escapeHtml(copy.sidebar.features.title)}
|
|
1237
|
+
</span>
|
|
1238
|
+
<span class="sidebar-features__meta">
|
|
1239
|
+
<span class="sidebar-features__count">${escapeHtml(featureCountLabel)}</span>
|
|
1240
|
+
<svg class="sidebar-features__chevron" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m6 9 6 6 6-6"></path></svg>
|
|
1241
|
+
</span>
|
|
1242
|
+
</summary>
|
|
1243
|
+
<div class="sidebar-features__body">
|
|
1244
|
+
<p class="sidebar-features__description">${escapeHtml(copy.sidebar.features.description)}</p>${featureCommands}
|
|
1245
|
+
<span class="feature-copy-status" data-feature-copy-status role="status" aria-live="polite"></span>
|
|
1246
|
+
</div>
|
|
1247
|
+
</details>
|
|
771
1248
|
</aside>
|
|
772
1249
|
</div>
|
|
773
1250
|
<script>${inlineScript(DOM_TO_IMAGE_SOURCE)}</script>
|
|
774
1251
|
<script>
|
|
775
1252
|
const exportConfig = ${exportConfig};
|
|
776
1253
|
const transferConfig = ${transferConfig};
|
|
1254
|
+
const featureConfig = ${featureConfig};
|
|
777
1255
|
const themes = new Set(["classic", "diner", "payroll"]);
|
|
778
1256
|
const buttons = [...document.querySelectorAll("[data-theme-value]")];
|
|
779
1257
|
function applyTheme(theme) {
|
|
@@ -787,6 +1265,75 @@ export function renderHtml({ record, dataQrDataUrl = null, miniProgramCodeDataUr
|
|
|
787
1265
|
try { savedTheme = localStorage.getItem("codex-work-receipt-theme"); } catch {}
|
|
788
1266
|
applyTheme(savedTheme || document.documentElement.dataset.theme);
|
|
789
1267
|
|
|
1268
|
+
const featureDetails = document.querySelector("[data-feature-details]");
|
|
1269
|
+
if (featureDetails) {
|
|
1270
|
+
try {
|
|
1271
|
+
const savedFeatureState = localStorage.getItem("codex-work-receipt-features-open");
|
|
1272
|
+
if (savedFeatureState === "true") featureDetails.open = true;
|
|
1273
|
+
if (savedFeatureState === "false") featureDetails.open = false;
|
|
1274
|
+
} catch {}
|
|
1275
|
+
featureDetails.addEventListener("toggle", () => {
|
|
1276
|
+
try { localStorage.setItem("codex-work-receipt-features-open", String(featureDetails.open)); } catch {}
|
|
1277
|
+
});
|
|
1278
|
+
}
|
|
1279
|
+
|
|
1280
|
+
function fallbackCopyText(value) {
|
|
1281
|
+
const textarea = document.createElement("textarea");
|
|
1282
|
+
textarea.value = value;
|
|
1283
|
+
textarea.setAttribute("readonly", "");
|
|
1284
|
+
Object.assign(textarea.style, {
|
|
1285
|
+
position: "fixed",
|
|
1286
|
+
left: "-10000px",
|
|
1287
|
+
top: "0",
|
|
1288
|
+
opacity: "0",
|
|
1289
|
+
pointerEvents: "none",
|
|
1290
|
+
});
|
|
1291
|
+
document.body.appendChild(textarea);
|
|
1292
|
+
textarea.focus();
|
|
1293
|
+
textarea.select();
|
|
1294
|
+
textarea.setSelectionRange(0, textarea.value.length);
|
|
1295
|
+
const copied = document.execCommand("copy");
|
|
1296
|
+
textarea.remove();
|
|
1297
|
+
if (!copied) throw new Error("Clipboard copy was rejected");
|
|
1298
|
+
}
|
|
1299
|
+
|
|
1300
|
+
async function copyFeatureCommand(value) {
|
|
1301
|
+
if (navigator.clipboard?.writeText) {
|
|
1302
|
+
try {
|
|
1303
|
+
await navigator.clipboard.writeText(value);
|
|
1304
|
+
return;
|
|
1305
|
+
} catch {}
|
|
1306
|
+
}
|
|
1307
|
+
fallbackCopyText(value);
|
|
1308
|
+
}
|
|
1309
|
+
|
|
1310
|
+
const featureCopyStatus = document.querySelector("[data-feature-copy-status]");
|
|
1311
|
+
document.querySelectorAll("[data-copy-command]").forEach((button) => {
|
|
1312
|
+
let resetTimer = null;
|
|
1313
|
+
button.addEventListener("click", async () => {
|
|
1314
|
+
const label = button.querySelector("[data-copy-label]");
|
|
1315
|
+
clearTimeout(resetTimer);
|
|
1316
|
+
button.disabled = true;
|
|
1317
|
+
try {
|
|
1318
|
+
await copyFeatureCommand(button.dataset.copyCommand || "");
|
|
1319
|
+
button.dataset.state = "success";
|
|
1320
|
+
if (label) label.textContent = featureConfig.copiedLabel;
|
|
1321
|
+
if (featureCopyStatus) featureCopyStatus.textContent = featureConfig.copiedStatus;
|
|
1322
|
+
} catch (error) {
|
|
1323
|
+
console.error(error);
|
|
1324
|
+
button.dataset.state = "error";
|
|
1325
|
+
if (label) label.textContent = featureConfig.copyErrorLabel;
|
|
1326
|
+
if (featureCopyStatus) featureCopyStatus.textContent = featureConfig.copyErrorStatus;
|
|
1327
|
+
} finally {
|
|
1328
|
+
button.disabled = false;
|
|
1329
|
+
resetTimer = setTimeout(() => {
|
|
1330
|
+
delete button.dataset.state;
|
|
1331
|
+
if (label) label.textContent = featureConfig.copyLabel;
|
|
1332
|
+
}, 1800);
|
|
1333
|
+
}
|
|
1334
|
+
});
|
|
1335
|
+
});
|
|
1336
|
+
|
|
790
1337
|
const downloadFileButton = document.getElementById("download-import-file");
|
|
791
1338
|
const downloadStatus = document.getElementById("download-status");
|
|
792
1339
|
if (downloadFileButton && downloadStatus) {
|