From b3d3f48c40cc262a8066da726ca0cca14a3cd6d0 Mon Sep 17 00:00:00 2001 From: Sycun <165354365+Sycun@users.noreply.github.com> Date: Sun, 11 Oct 2026 22:30:30 +0800 Subject: [PATCH 1/5] feat: add detailed token usage monitoring and cache write metrics --- docs/zh-CN/token-usage-monitoring.md | 54 +++++++ go.mod | 12 +- go.sum | 12 ++ internal/database/model_token_usage.go | 26 ++-- internal/database/model_token_usage_test.go | 41 ++++- internal/database/schema_reconcile_test.go | 1 + internal/database/schemas/application.sql | 1 + .../multiagent/eino_run_usage_accumulator.go | 14 +- .../eino_run_usage_accumulator_test.go | 22 +++ internal/openai/eino_http.go | 1 + internal/openai/eino_sse_sanitizer.go | 7 +- internal/openai/eino_usage_normalizer.go | 90 +++++++++++ internal/openai/eino_usage_normalizer_test.go | 70 +++++++++ web/static/css/style.css | 22 +++ web/static/i18n/en-US.json | 36 ++++- web/static/i18n/ru-RU.json | 35 ++++- web/static/i18n/zh-CN.json | 36 ++++- web/static/js/chat-scroll-refresh.test.cjs | 2 +- web/static/js/chat.js | 7 + web/static/js/dashboard.js | 141 +++++++++++++++++- web/static/js/hitl-approval-ui.test.cjs | 4 +- web/static/js/monitor.js | 6 +- web/static/js/token-usage-analysis.test.cjs | 43 ++++++ web/static/js/turn-duration-refresh.test.cjs | 2 + web/static/js/webshell.js | 3 + web/templates/index.html | 23 ++- 26 files changed, 666 insertions(+), 45 deletions(-) create mode 100644 docs/zh-CN/token-usage-monitoring.md create mode 100644 internal/openai/eino_usage_normalizer.go create mode 100644 internal/openai/eino_usage_normalizer_test.go create mode 100644 web/static/js/token-usage-analysis.test.cjs diff --git a/docs/zh-CN/token-usage-monitoring.md b/docs/zh-CN/token-usage-monitoring.md new file mode 100644 index 00000000..f8c844b2 --- /dev/null +++ b/docs/zh-CN/token-usage-monitoring.md @@ -0,0 +1,54 @@ +# Token 用量监控:调研与实现 + +调研时间:2026-10-11。 + +## 调研结论 + +| 参考 | 官方能力 | 本项目采用的设计 | +| --- | --- | --- | +| [ZCode 使用统计](https://zcode.z.ai/cn/docs/usage-stats) | 本地会话统计与远端编程套餐额度分别展示;支持时间范围、日趋势和模型占比 | 首页增加可展开的本地用量面板,提供 7/30/90 天、日趋势、模型占比与明细 | +| [DeepSeek 上下文缓存](https://api-docs.deepseek.com/guides/kv_cache/) | 响应 usage 的 `prompt_cache_hit_tokens` / `prompt_cache_miss_tokens` 区分输入缓存命中和未命中 | 将命中字段补入 OpenAI 标准缓存读取字段;不把未命中错误映射为缓存写入 | +| [Eino v0.9.21](https://github.com/cloudwego/eino/releases/tag/v0.9.21)、[schema PR #1309](https://github.com/cloudwego/eino/pull/1309) | 稳定版新增 `PromptTokenDetails.CacheWriteTokens`,原有缓存读取、输入、输出、推理字段继续保留 | 使用稳定版的标准字段贯穿流式归并、运行汇总、数据库和 UI | +| [Eino-ext PR #1015](https://github.com/cloudwego/eino-ext/pull/1015) | 模型适配器传递缓存写入计数 | 升级 agenticclaude 0.1.7、agenticopenai 0.2.4 与对应 OpenAI ACL,而非仅升级框架 | +| [Eino 回调与 Trace](https://www.cloudwego.io/docs/eino/quick_start/chapter_06_callback_and_trace/) | 组件回调可收集模型生命周期、用量与耗时 | 保留现有运行事件采集;未来要覆盖所有内部调用,应另外设计统一模型调用账本 | + +版本已通过 Go module 解析和本地源码确认;未采用 0.10 alpha。DeepSeek 原始字段当前不会自动进入 Eino 的标准缓存读取计数,因此在项目现有兼容 HTTP 层补齐,覆盖普通 JSON 和 SSE 流式响应。已有标准 `cached_tokens` 优先,未知字段使用 RawMessage 保存,避免浮点转换损失数字精度。 + +## 统计口径 + +- `promptTokens`:模型返回的全部输入。 +- `completionTokens`:全部输出,包括供应商计入其中的推理。 +- `totalTokens`:使用供应商的总量;缺省时由输入加输出补齐。 +- `cachedTokens`:输入中的缓存读取部分。 +- `cacheWriteTokens`:输入中的缓存写入部分。 +- `reasoningTokens`:输出中的推理部分。 +- 缓存读取、写入、推理都是细分,不能再加到总量。缓存读取占输入的比例为 `cachedTokens / promptTokens`;没有输入时显示破折号。 +- 一个流式模型输出可能多次返回累计计数,沿用按字段取最大值的归并方式,整条输出结束后只加入一次运行汇总。部分输出发生错误时,仍可保留已返回用量。 + +DeepSeek 的未命中输入不等于 Claude 的缓存写入。不能根据文本长度推导账单级计数,也不能根据缺失字段断言供应商未使用缓存。旧记录的新增字段默认为 0,表示未记录该细分。 + +## 功能入口与数据流 + +点击首页「Token 用量」卡片或「查看用量详情」展开面板。支持当前项目范围、7/30/90 天切换、八项指标、最多 20 项运行模型排行、每日趋势与完整日明细、最多 20 项最近运行。沿用首页自动刷新和请求取消机制。 + +运行事件 → 流式/非流式用量累加 → `eino_usage_summary` → `process_details` 与 `model_token_usage` → 现有 `/api/usage/tokens`、对话用量接口 → 首页与聊天摘要。 + +新增 `model_token_usage.cache_write_tokens INTEGER NOT NULL DEFAULT 0`。启动时现有声明式 SQLite schema reconciliation 自动补列;旧数据库无需手工执行迁移。现有事件 ID 幂等写入、外键删除关联和历史回填继续生效。日分组按服务本地时区显示,与首页「今日」边界一致。 + +面板采用文本节点渲染供应商模型名称,表格在手机上局部横向滚动,提供中文、英文、俄文标签。聊天轮次 tooltip、主对话与 WebShell 时间线均增加缓存写入字段。 + +## 当前边界与后续方向 + +统计基于已记录的 Agent 输出事件,包括已有采集链路中的子 Agent,**不是全站 API 请求账本**。摘要压缩、检索 embedding、隐藏的模型调用、没有返回 usage 的请求不能由当前链路完整覆盖;因请求取消丢失最终 usage 的消耗也无法还原。 + +模型排行按运行配置的模型名称归类,发生 failover 的运行可能混合多个模型,不能据此核算逐模型账单。近期列表每项是运行汇总,不是单次 HTTP 请求;模型调用次数也仅统计返回了非空 usage 的输出。 + +本次不展示费用或套餐剩余额度。自定义中转渠道和订阅套餐的价格、计费方式不同,token 乘固定单价无法可靠代表费用。下一步如需要完整成本管理,应先建设按请求记录的账本:调用来源、真实供应商与模型、成功/错误/取消、耗时、TTFT、usage 是否返回、价格版本和币种;统一覆盖主/子 Agent、压缩、重试、embedding,单独对接供应商额度接口。 + +## 验证 + +- `go test ./...`:全仓通过。 +- `node --test web/static/js/*.test.cjs`:295 项通过。 +- 针对缓存写入的保存/分组/近期记录、旧 schema 补列、用户可见范围、流式去重和总量回退添加回归测试。 +- DeepSeek 普通 JSON/SSE 的缓存字段转换、标准字段优先、错误/终止块保持、超大数值精度验证通过。 +- 浏览器使用合成数据验证桌面、390px 手机、深色模式、模型名称转义、空数据与接口不可用状态;没有读取真实运行数据库,也没有调用付费模型。 diff --git a/go.mod b/go.mod index 894b3864..7d319ebd 100644 --- a/go.mod +++ b/go.mod @@ -7,14 +7,14 @@ go 1.25.0 require ( github.com/bwmarrin/discordgo v0.29.0 github.com/bytedance/sonic v1.15.0 - github.com/cloudwego/eino v0.9.14 + github.com/cloudwego/eino v0.9.21 github.com/cloudwego/eino-ext/adk/backend/local v0.2.6 github.com/cloudwego/eino-ext/components/document/loader/file v0.0.0-20260427010451-749e3706378b github.com/cloudwego/eino-ext/components/document/transformer/splitter/markdown v0.0.0-20260427010451-749e3706378b github.com/cloudwego/eino-ext/components/document/transformer/splitter/recursive v0.0.0-20260427010451-749e3706378b github.com/cloudwego/eino-ext/components/embedding/openai v0.0.0-20260427010451-749e3706378b - github.com/cloudwego/eino-ext/components/model/agenticclaude v0.1.3 - github.com/cloudwego/eino-ext/components/model/agenticopenai v0.2.1 + github.com/cloudwego/eino-ext/components/model/agenticclaude v0.1.7 + github.com/cloudwego/eino-ext/components/model/agenticopenai v0.2.4 github.com/cloudwego/eino-ext/components/model/openai v0.1.13 github.com/creack/pty v1.1.24 github.com/disintegration/imaging v1.6.2 @@ -74,7 +74,7 @@ require ( github.com/cenkalti/backoff/v4 v4.3.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/cloudwego/base64x v0.1.6 // indirect - github.com/cloudwego/eino-ext/libs/acl/openai v0.1.18-0.20260527084435-846f52bd97c6 // indirect + github.com/cloudwego/eino-ext/libs/acl/openai v0.1.18-0.20260924031408-10df7cac3f6d // indirect github.com/dlclark/regexp2 v1.10.0 // indirect github.com/dustin/go-humanize v1.0.1 // indirect github.com/evanphx/json-patch v0.5.2 // indirect @@ -105,11 +105,11 @@ require ( github.com/leodido/go-urn v1.2.4 // indirect github.com/mailru/easyjson v0.9.0 // indirect github.com/mattn/go-isatty v0.0.19 // indirect - github.com/meguminnnnnnnnn/go-openai v0.1.2 // indirect + github.com/meguminnnnnnnnn/go-openai v0.1.6 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.2 // indirect github.com/nikolalohinski/gonja v1.5.3 // indirect - github.com/openai/openai-go/v3 v3.35.0 // indirect + github.com/openai/openai-go/v3 v3.44.0 // indirect github.com/pb33f/ordered-map/v2 v2.3.1 // indirect github.com/pelletier/go-toml/v2 v2.2.3 // indirect github.com/pkg/errors v0.9.1 // indirect diff --git a/go.sum b/go.sum index c10b3fa3..cc4267ea 100644 --- a/go.sum +++ b/go.sum @@ -81,6 +81,8 @@ github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU= github.com/cloudwego/eino v0.9.14 h1:suNVibjtkPMiW7csFBdBqN3FRG0nRlCqoGKE7t0UDwY= github.com/cloudwego/eino v0.9.14/go.mod h1:OBD1mrkfkt/pJa4rkg1P0VnaMeOVl7l8IAdEqY//3IQ= +github.com/cloudwego/eino v0.9.21 h1:kBAyAjsnPq5NDWjt0VdmP9HY/XVUHs05LoeqybudPRQ= +github.com/cloudwego/eino v0.9.21/go.mod h1:OBD1mrkfkt/pJa4rkg1P0VnaMeOVl7l8IAdEqY//3IQ= github.com/cloudwego/eino-ext/adk/backend/local v0.2.6 h1:pxKAWcveIBWrbz28H2bDAMyJZnhysa9bh2Kh5s7iH7s= github.com/cloudwego/eino-ext/adk/backend/local v0.2.6/go.mod h1:uJlpYclMA2ScgUlHWHu2om+DDguV2Lzc+TNulWBXqfo= github.com/cloudwego/eino-ext/components/document/loader/file v0.0.0-20260427010451-749e3706378b h1:GIOC/VnXuSQx79mnQ3HgMvECjtyqvpJipmSUTFFfVsc= @@ -93,12 +95,18 @@ github.com/cloudwego/eino-ext/components/embedding/openai v0.0.0-20260427010451- github.com/cloudwego/eino-ext/components/embedding/openai v0.0.0-20260427010451-749e3706378b/go.mod h1:zyPrZT2bO6LyRJgVksQowR18jVgyLSvqK93hnO53/Lc= github.com/cloudwego/eino-ext/components/model/agenticclaude v0.1.3 h1:nfO9jUPhIKTHDPyL7rso6y7Mx8cAmrIgkeJC6KqTuXI= github.com/cloudwego/eino-ext/components/model/agenticclaude v0.1.3/go.mod h1:/dX1P7HNC7D3/nYLDoXBvdKAFnGS4tBbtJ8NrkaXuUk= +github.com/cloudwego/eino-ext/components/model/agenticclaude v0.1.7 h1:oxvyuAdZLlKSWYrzXwXS63AokgC3TqSljrzCGNRi4JA= +github.com/cloudwego/eino-ext/components/model/agenticclaude v0.1.7/go.mod h1:LsIyLkRviQtfJsGYP6MX9PwqCX4jMtVxR4zR2A7ZB+g= github.com/cloudwego/eino-ext/components/model/agenticopenai v0.2.1 h1:bIMyUxEZdBEofWbXQCozfGxXCsbyNjpZcRMfW+Kt5u0= github.com/cloudwego/eino-ext/components/model/agenticopenai v0.2.1/go.mod h1:84jUcVAcmlGDRVX0ExABVJEfrIaplaAeRbjFYW9mzkI= +github.com/cloudwego/eino-ext/components/model/agenticopenai v0.2.4 h1:M5ODiB1UaYV0I/dL2dmgc/HfZ2WUBV0saM9Tkhk2IDg= +github.com/cloudwego/eino-ext/components/model/agenticopenai v0.2.4/go.mod h1:AfLNKxrTFFkJbCWSvOtqKK1ZXEp0hpe26CBQ8GSf5zo= github.com/cloudwego/eino-ext/components/model/openai v0.1.13 h1:5XHRTiTD5bt9KQrMHcfvuWNklEC3tpm3XHejdozt9vM= github.com/cloudwego/eino-ext/components/model/openai v0.1.13/go.mod h1:mgIoqYYOc0eECCqvLbEYpOJrQNTNxkwXzSJzFU+v5sQ= github.com/cloudwego/eino-ext/libs/acl/openai v0.1.18-0.20260527084435-846f52bd97c6 h1:ES/xufN5eqJ3h+9tw/tq6F8kkgnAxBAHVUB6nqKsIDU= github.com/cloudwego/eino-ext/libs/acl/openai v0.1.18-0.20260527084435-846f52bd97c6/go.mod h1:5Xj74dGrfHo1z7I07Fzp3SlTF7Bt4tss3A2FSt8SqQ4= +github.com/cloudwego/eino-ext/libs/acl/openai v0.1.18-0.20260924031408-10df7cac3f6d h1:FaBHOMj/ZDBmtsI9UMoDMDu9CNZPbjJwK/O26JFuZ8k= +github.com/cloudwego/eino-ext/libs/acl/openai v0.1.18-0.20260924031408-10df7cac3f6d/go.mod h1:6yg0Rv7B3jZCQPc/C5s13PNnVewQdaRxOp2OMru7Yw4= github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc= github.com/cncf/xds/go v0.0.0-20251210132809-ee656c7534f5 h1:6xNmx7iTtyBRev0+D/Tv1FZd4SCg8axKApyNyRsAt/w= github.com/cncf/xds/go v0.0.0-20251210132809-ee656c7534f5/go.mod h1:KdCmV+x/BuvyMxRnYBlmVaq4OLiKW6iRQfvC62cvdkI= @@ -272,6 +280,8 @@ github.com/mattn/go-sqlite3 v1.14.18 h1:JL0eqdCOq6DJVNPSvArO/bIV9/P7fbGrV00LZHc+ github.com/mattn/go-sqlite3 v1.14.18/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg= github.com/meguminnnnnnnnn/go-openai v0.1.2 h1:iXombGGjqjBrmE9WaSidUhhi3YQhf42QTHvHLMkgvCA= github.com/meguminnnnnnnnn/go-openai v0.1.2/go.mod h1:qs96ysDmxhE4BZoU45I43zcyfnaYxU3X+aRzLko/htY= +github.com/meguminnnnnnnnn/go-openai v0.1.6 h1:c74nk9F3+/agOd09eHeQt/Ayty4pw54X2Dj5JHdxcys= +github.com/meguminnnnnnnnn/go-openai v0.1.6/go.mod h1:qs96ysDmxhE4BZoU45I43zcyfnaYxU3X+aRzLko/htY= github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b h1:j7+1HpAFS1zy5+Q4qx1fWh90gTKwiN4QCGoY9TWyyO4= github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b/go.mod h1:01TrycV0kFyexm33Z7vhZRXopbI8J3TDReVlkTgMUxE= github.com/modelcontextprotocol/go-sdk v1.3.1 h1:TfqtNKOIWN4Z1oqmPAiWDC2Jq7K9OdJaooe0teoXASI= @@ -300,6 +310,8 @@ github.com/onsi/gomega v1.40.0 h1:Vtol0e1MghCD2ZVIilPDIg44XSL9l2QAn8ZNaljWcJc= github.com/onsi/gomega v1.40.0/go.mod h1:M/Uqpu/8qTjtzCLUA2zJHX9Iilrau25x1PdoSRbWh5A= github.com/openai/openai-go/v3 v3.35.0 h1:109x3epXMSE423KW2euR506GGFezcEt0s87MoWejpH0= github.com/openai/openai-go/v3 v3.35.0/go.mod h1:cdufnVK14cWcT9qA1rRtrXx4FTRsgbDPW7Ia7SS5cZo= +github.com/openai/openai-go/v3 v3.44.0 h1:kkGh+jb/sKfSh5P74Jk5mCRufaQ0q7oH+lq+pNlWjsk= +github.com/openai/openai-go/v3 v3.44.0/go.mod h1:cdufnVK14cWcT9qA1rRtrXx4FTRsgbDPW7Ia7SS5cZo= github.com/pb33f/ordered-map/v2 v2.3.1 h1:5319HDO0aw4DA4gzi+zv4FXU9UlSs3xGZ40wcP1nBjY= github.com/pb33f/ordered-map/v2 v2.3.1/go.mod h1:qxFQgd0PkVUtOMCkTapqotNgzRhMPL7VvaHKbd1HnmQ= github.com/pelletier/go-toml/v2 v2.2.3 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNHvL12M= diff --git a/internal/database/model_token_usage.go b/internal/database/model_token_usage.go index 426bf5f5..6452b717 100644 --- a/internal/database/model_token_usage.go +++ b/internal/database/model_token_usage.go @@ -30,6 +30,7 @@ type ModelTokenUsage struct { PromptTokens int64 `json:"promptTokens"` CompletionTokens int64 `json:"completionTokens"` TotalTokens int64 `json:"totalTokens"` + CacheWriteTokens int64 `json:"cacheWriteTokens"` CachedTokens int64 `json:"cachedTokens"` ReasoningTokens int64 `json:"reasoningTokens"` CreatedAt time.Time `json:"createdAt"` @@ -43,6 +44,7 @@ type ModelTokenUsageSummary struct { PromptTokens int64 `json:"promptTokens"` CompletionTokens int64 `json:"completionTokens"` TotalTokens int64 `json:"totalTokens"` + CacheWriteTokens int64 `json:"cacheWriteTokens"` CachedTokens int64 `json:"cachedTokens"` ReasoningTokens int64 `json:"reasoningTokens"` } @@ -56,6 +58,7 @@ type ModelTokenUsageBreakdown struct { PromptTokens int64 `json:"promptTokens"` CompletionTokens int64 `json:"completionTokens"` TotalTokens int64 `json:"totalTokens"` + CacheWriteTokens int64 `json:"cacheWriteTokens"` CachedTokens int64 `json:"cachedTokens"` ReasoningTokens int64 `json:"reasoningTokens"` } @@ -100,6 +103,7 @@ func modelTokenUsageFromProcessDetail(messageID, conversationID, processDetailID CompletionTokens: usageInt64(m["completionTokens"]), TotalTokens: usageInt64(m["totalTokens"]), CachedTokens: usageInt64(m["cachedTokens"]), + CacheWriteTokens: usageInt64(m["cacheWriteTokens"]), ReasoningTokens: usageInt64(m["reasoningTokens"]), } if usage.TotalTokens == 0 && (usage.PromptTokens > 0 || usage.CompletionTokens > 0) { @@ -108,7 +112,7 @@ func modelTokenUsageFromProcessDetail(messageID, conversationID, processDetailID if usage.ProcessDetailID == "" || usage.MessageID == "" || usage.ConversationID == "" { return ModelTokenUsage{}, false } - if usage.ModelCalls == 0 && usage.TotalTokens == 0 && usage.PromptTokens == 0 && usage.CompletionTokens == 0 && usage.CachedTokens == 0 && usage.ReasoningTokens == 0 { + if usage.ModelCalls == 0 && usage.TotalTokens == 0 && usage.PromptTokens == 0 && usage.CompletionTokens == 0 && usage.CachedTokens == 0 && usage.CacheWriteTokens == 0 && usage.ReasoningTokens == 0 { return ModelTokenUsage{}, false } return usage, true @@ -224,9 +228,9 @@ func (db *DB) UpsertModelTokenUsage(usage ModelTokenUsage) error { INSERT INTO model_token_usage ( id, process_detail_id, message_id, conversation_id, project_id, source, orchestration, reason, model, model_calls, - prompt_tokens, completion_tokens, total_tokens, cached_tokens, reasoning_tokens, + prompt_tokens, completion_tokens, total_tokens, cached_tokens, cache_write_tokens, reasoning_tokens, created_at, updated_at -) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) +) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(process_detail_id) DO UPDATE SET message_id = excluded.message_id, conversation_id = excluded.conversation_id, @@ -240,12 +244,13 @@ ON CONFLICT(process_detail_id) DO UPDATE SET completion_tokens = excluded.completion_tokens, total_tokens = excluded.total_tokens, cached_tokens = excluded.cached_tokens, + cache_write_tokens = excluded.cache_write_tokens, reasoning_tokens = excluded.reasoning_tokens, created_at = excluded.created_at, updated_at = excluded.updated_at`, usage.ID, usage.ProcessDetailID, usage.MessageID, usage.ConversationID, projectValue, usage.Source, usage.Orchestration, usage.Reason, usage.Model, usage.ModelCalls, - usage.PromptTokens, usage.CompletionTokens, usage.TotalTokens, usage.CachedTokens, usage.ReasoningTokens, + usage.PromptTokens, usage.CompletionTokens, usage.TotalTokens, usage.CachedTokens, usage.CacheWriteTokens, usage.ReasoningTokens, createdAt, now, ) if err != nil { @@ -318,7 +323,7 @@ func (db *DB) GetModelTokenUsageStats(filter ModelTokenUsageFilter) (*ModelToken return nil, err } byDay, err := db.queryModelTokenUsageBreakdown( - "SELECT date(mtu.created_at) AS k, date(mtu.created_at) AS label, "+modelTokenUsageSummarySelect("mtu")+" FROM model_token_usage mtu JOIN conversations c ON c.id = mtu.conversation_id"+where+" GROUP BY date(mtu.created_at) ORDER BY k DESC LIMIT ?", + "SELECT date(mtu.created_at, 'localtime') AS k, date(mtu.created_at, 'localtime') AS label, "+modelTokenUsageSummarySelect("mtu")+" FROM model_token_usage mtu JOIN conversations c ON c.id = mtu.conversation_id"+where+" GROUP BY date(mtu.created_at, 'localtime') ORDER BY k DESC LIMIT ?", append(args, filter.Days)..., ) if err != nil { @@ -363,7 +368,8 @@ COALESCE(SUM(%sprompt_tokens), 0), COALESCE(SUM(%scompletion_tokens), 0), COALESCE(SUM(%stotal_tokens), 0), COALESCE(SUM(%scached_tokens), 0), -COALESCE(SUM(%sreasoning_tokens), 0)`, p, p, p, p, p, p, p) +COALESCE(SUM(%scache_write_tokens), 0), +COALESCE(SUM(%sreasoning_tokens), 0)`, p, p, p, p, p, p, p, p) } func buildModelTokenUsageWhere(filter ModelTokenUsageFilter, usageAlias, convAlias string) (string, []interface{}) { @@ -394,7 +400,7 @@ func (db *DB) queryModelTokenUsageSummary(query string, args ...interface{}) (Mo var s ModelTokenUsageSummary err := db.QueryRow(query, args...).Scan( &s.Events, &s.ModelCalls, &s.PromptTokens, &s.CompletionTokens, - &s.TotalTokens, &s.CachedTokens, &s.ReasoningTokens, + &s.TotalTokens, &s.CachedTokens, &s.CacheWriteTokens, &s.ReasoningTokens, ) if err != nil { return s, fmt.Errorf("查询模型Token用量汇总失败: %w", err) @@ -413,7 +419,7 @@ func (db *DB) queryModelTokenUsageBreakdown(query string, args ...interface{}) ( var row ModelTokenUsageBreakdown if err := rows.Scan( &row.Key, &row.Label, &row.Events, &row.ModelCalls, &row.PromptTokens, - &row.CompletionTokens, &row.TotalTokens, &row.CachedTokens, &row.ReasoningTokens, + &row.CompletionTokens, &row.TotalTokens, &row.CachedTokens, &row.CacheWriteTokens, &row.ReasoningTokens, ); err != nil { return nil, fmt.Errorf("扫描模型Token用量分组失败: %w", err) } @@ -438,7 +444,7 @@ func (db *DB) ListModelTokenUsage(filter ModelTokenUsageFilter) ([]ModelTokenUsa SELECT mtu.id, mtu.process_detail_id, mtu.message_id, mtu.conversation_id, COALESCE(mtu.project_id, ''), mtu.source, mtu.orchestration, mtu.reason, mtu.model, mtu.model_calls, mtu.prompt_tokens, mtu.completion_tokens, mtu.total_tokens, - mtu.cached_tokens, mtu.reasoning_tokens, mtu.created_at, mtu.updated_at + mtu.cached_tokens, mtu.cache_write_tokens, mtu.reasoning_tokens, mtu.created_at, mtu.updated_at FROM model_token_usage mtu JOIN conversations c ON c.id = mtu.conversation_id`+where+` ORDER BY mtu.created_at DESC, mtu.rowid DESC @@ -455,7 +461,7 @@ LIMIT ?`, args...) &u.ID, &u.ProcessDetailID, &u.MessageID, &u.ConversationID, &u.ProjectID, &u.Source, &u.Orchestration, &u.Reason, &u.Model, &u.ModelCalls, &u.PromptTokens, &u.CompletionTokens, &u.TotalTokens, &u.CachedTokens, - &u.ReasoningTokens, &createdAt, &updatedAt, + &u.CacheWriteTokens, &u.ReasoningTokens, &createdAt, &updatedAt, ); err != nil { return nil, fmt.Errorf("扫描模型Token用量明细失败: %w", err) } diff --git a/internal/database/model_token_usage_test.go b/internal/database/model_token_usage_test.go index 5fa1a09f..51b94aae 100644 --- a/internal/database/model_token_usage_test.go +++ b/internal/database/model_token_usage_test.go @@ -28,6 +28,7 @@ func TestModelTokenUsagePersistsFromUsageProcessDetail(t *testing.T) { "totalTokens": 13, "cachedTokens": 4, "reasoningTokens": 1, + "cacheWriteTokens": 2, }); err != nil { t.Fatalf("AddProcessDetail: %v", err) } @@ -36,12 +37,15 @@ func TestModelTokenUsagePersistsFromUsageProcessDetail(t *testing.T) { if err != nil { t.Fatalf("GetModelTokenUsageStats: %v", err) } - if stats.Summary.Events != 1 || stats.Summary.ModelCalls != 2 || stats.Summary.TotalTokens != 13 || stats.Summary.CachedTokens != 4 || stats.Summary.ReasoningTokens != 1 { + if stats.Summary.Events != 1 || stats.Summary.ModelCalls != 2 || stats.Summary.TotalTokens != 13 || stats.Summary.CachedTokens != 4 || stats.Summary.ReasoningTokens != 1 || stats.Summary.CacheWriteTokens != 2 { t.Fatalf("summary = %#v", stats.Summary) } - if len(stats.ByModel) != 1 || stats.ByModel[0].Key != "gpt-test" || stats.ByModel[0].TotalTokens != 13 { + if len(stats.ByModel) != 1 || stats.ByModel[0].Key != "gpt-test" || stats.ByModel[0].TotalTokens != 13 || stats.ByModel[0].CacheWriteTokens != 2 { t.Fatalf("by model = %#v", stats.ByModel) } + if len(stats.Recent) != 1 || stats.Recent[0].CacheWriteTokens != 2 || stats.Today.CacheWriteTokens != 2 || len(stats.ByDay) != 1 || stats.ByDay[0].CacheWriteTokens != 2 { + t.Fatalf("cache write usage lost: %#v", stats) + } } func TestModelTokenUsageBackfillIsIdempotent(t *testing.T) { @@ -83,3 +87,36 @@ func newModelTokenUsageTestDB(t *testing.T) *DB { t.Cleanup(func() { _ = db.Close() }) return db } + +func TestModelTokenUsageStatsRespectConversationAccess(t *testing.T) { + db := newModelTokenUsageTestDB(t) + var ownID string + for _, owner := range []string{"alice", "bob"} { + conv, err := db.CreateConversation(owner, ConversationCreateMeta{}) + if err != nil { + t.Fatal(err) + } + if owner == "alice" { + ownID = conv.ID + } + if _, err := db.Exec(`UPDATE conversations SET owner_user_id = ? WHERE id = ?`, owner, conv.ID); err != nil { + t.Fatal(err) + } + msg, err := db.AddMessage(conv.ID, "assistant", "done", nil) + if err != nil { + t.Fatal(err) + } + if err := db.AddProcessDetail(msg.ID, conv.ID, modelTokenUsageEventType, "usage", map[string]interface{}{ + "model": owner, "modelCalls": 1, "promptTokens": 100, "completionTokens": 20, "cacheWriteTokens": 30, + }); err != nil { + t.Fatal(err) + } + } + stats, err := db.GetModelTokenUsageStats(ModelTokenUsageFilter{Access: RBACListAccess{UserID: "alice", Scope: "own"}}) + if err != nil { + t.Fatal(err) + } + if stats.Summary.Events != 1 || stats.Summary.TotalTokens != 120 || stats.Summary.CacheWriteTokens != 30 || len(stats.Recent) != 1 || stats.Recent[0].ConversationID != ownID || len(stats.ByModel) != 1 || stats.ByModel[0].Key != "alice" { + t.Fatalf("usage leaks across users: %#v", stats) + } +} diff --git a/internal/database/schema_reconcile_test.go b/internal/database/schema_reconcile_test.go index 3ed2cd01..9c346e94 100644 --- a/internal/database/schema_reconcile_test.go +++ b/internal/database/schema_reconcile_test.go @@ -115,6 +115,7 @@ func TestStartupRepairsTablesAndUnlistedColumnsPreservesData(t *testing.T) { ALTER TABLE hitl_interrupts DROP COLUMN payload; ALTER TABLE hitl_conversation_configs DROP COLUMN reviewer; ALTER TABLE model_token_usage DROP COLUMN cached_tokens; + ALTER TABLE model_token_usage DROP COLUMN cache_write_tokens; ALTER TABLE rbac_users DROP COLUMN display_name; ALTER TABLE messages DROP COLUMN reasoning_content;`) db.Close() diff --git a/internal/database/schemas/application.sql b/internal/database/schemas/application.sql index 2bba4c23..33754916 100644 --- a/internal/database/schemas/application.sql +++ b/internal/database/schemas/application.sql @@ -321,6 +321,7 @@ CREATE TABLE model_token_usage ( completion_tokens INTEGER NOT NULL DEFAULT 0, total_tokens INTEGER NOT NULL DEFAULT 0, cached_tokens INTEGER NOT NULL DEFAULT 0, + cache_write_tokens INTEGER NOT NULL DEFAULT 0, reasoning_tokens INTEGER NOT NULL DEFAULT 0, created_at DATETIME NOT NULL, updated_at DATETIME NOT NULL, diff --git a/internal/multiagent/eino_run_usage_accumulator.go b/internal/multiagent/eino_run_usage_accumulator.go index 1cc06cc7..5c468440 100644 --- a/internal/multiagent/eino_run_usage_accumulator.go +++ b/internal/multiagent/eino_run_usage_accumulator.go @@ -13,6 +13,7 @@ type einoRunUsageSummary struct { CompletionTokens int TotalTokens int CachedTokens int + CacheWriteTokens int ReasoningTokens int } @@ -42,8 +43,13 @@ func (a *einoRunUsageAccumulator) AddUsage(usage *schema.TokenUsage) bool { a.summary.ModelCalls++ a.summary.PromptTokens += usage.PromptTokens a.summary.CompletionTokens += usage.CompletionTokens - a.summary.TotalTokens += usage.TotalTokens + total := usage.TotalTokens + if total == 0 { + total = usage.PromptTokens + usage.CompletionTokens + } + a.summary.TotalTokens += total a.summary.CachedTokens += usage.PromptTokenDetails.CachedTokens + a.summary.CacheWriteTokens += usage.PromptTokenDetails.CacheWriteTokens a.summary.ReasoningTokens += usage.CompletionTokensDetails.ReasoningTokens return true } @@ -88,6 +94,7 @@ func (a *einoRunUsageAccumulator) EmitOnce( "completionTokens": s.CompletionTokens, "totalTokens": s.TotalTokens, "cachedTokens": s.CachedTokens, + "cacheWriteTokens": s.CacheWriteTokens, "reasoningTokens": s.ReasoningTokens, } if progress != nil { @@ -104,6 +111,7 @@ func (a *einoRunUsageAccumulator) EmitOnce( zap.Int("completionTokens", s.CompletionTokens), zap.Int("totalTokens", s.TotalTokens), zap.Int("cachedTokens", s.CachedTokens), + zap.Int("cacheWriteTokens", s.CacheWriteTokens), zap.Int("reasoningTokens", s.ReasoningTokens), ) } @@ -129,6 +137,9 @@ func maxEinoTokenUsage(dst *schema.TokenUsage, src *schema.TokenUsage) *schema.T if src.PromptTokenDetails.CachedTokens > dst.PromptTokenDetails.CachedTokens { dst.PromptTokenDetails.CachedTokens = src.PromptTokenDetails.CachedTokens } + if src.PromptTokenDetails.CacheWriteTokens > dst.PromptTokenDetails.CacheWriteTokens { + dst.PromptTokenDetails.CacheWriteTokens = src.PromptTokenDetails.CacheWriteTokens + } if src.CompletionTokensDetails.ReasoningTokens > dst.CompletionTokensDetails.ReasoningTokens { dst.CompletionTokensDetails.ReasoningTokens = src.CompletionTokensDetails.ReasoningTokens } @@ -149,5 +160,6 @@ func tokenUsageEmpty(u *schema.TokenUsage) bool { u.CompletionTokens == 0 && u.TotalTokens == 0 && u.PromptTokenDetails.CachedTokens == 0 && + u.PromptTokenDetails.CacheWriteTokens == 0 && u.CompletionTokensDetails.ReasoningTokens == 0) } diff --git a/internal/multiagent/eino_run_usage_accumulator_test.go b/internal/multiagent/eino_run_usage_accumulator_test.go index a26e3c3b..374c04a2 100644 --- a/internal/multiagent/eino_run_usage_accumulator_test.go +++ b/internal/multiagent/eino_run_usage_accumulator_test.go @@ -79,3 +79,25 @@ func TestMaxEinoTokenUsageUsesLargestStreamChunkValues(t *testing.T) { t.Fatalf("usage = %#v", got) } } + +func TestEinoRunUsageCacheWriteDoesNotInflateTotal(t *testing.T) { + acc := newEinoRunUsageAccumulator() + var stream *schema.TokenUsage + for i := 0; i < 2; i++ { + stream = maxEinoTokenUsage(stream, &schema.TokenUsage{ + PromptTokens: 100, CompletionTokens: 20, + PromptTokenDetails: schema.PromptTokenDetails{CachedTokens: 40, CacheWriteTokens: 30}, + CompletionTokensDetails: schema.CompletionTokensDetails{ReasoningTokens: 10}, + }) + } + acc.AddUsage(stream) + got := acc.Summary() + if got.ModelCalls != 1 || got.TotalTokens != 120 || got.CacheWriteTokens != 30 || got.CachedTokens != 40 || got.ReasoningTokens != 10 { + t.Fatalf("summary = %#v", got) + } + var emitted map[string]interface{} + acc.EmitOnce("conv", "deep", "final", "claude-test", func(_, _ string, data interface{}) { emitted = data.(map[string]interface{}) }, nil) + if emitted["cacheWriteTokens"] != 30 || emitted["totalTokens"] != 120 { + t.Fatalf("event = %#v", emitted) + } +} diff --git a/internal/openai/eino_http.go b/internal/openai/eino_http.go index a4d761e0..625da8ac 100644 --- a/internal/openai/eino_http.go +++ b/internal/openai/eino_http.go @@ -21,6 +21,7 @@ func NewEinoHTTPClient(cfg *config.OpenAIConfig, base *http.Client) *http.Client transport = &reasoningToolChoiceCompatRoundTripper{base: transport, cfg: cfg} transport = &einoSSEErrorRoundTripper{base: transport} transport = &einoSSESanitizingRoundTripper{base: transport} + transport = &einoUsageNormalizingRoundTripper{base: transport} cloned.Transport = transport return &cloned } diff --git a/internal/openai/eino_sse_sanitizer.go b/internal/openai/eino_sse_sanitizer.go index 340b3a04..6f6319c2 100644 --- a/internal/openai/eino_sse_sanitizer.go +++ b/internal/openai/eino_sse_sanitizer.go @@ -19,7 +19,7 @@ package openai // // 该层对调用方完全透明: // - 仅当响应 Content-Type 是 text/event-stream 时介入;普通 JSON 响应原样透传 -// - data: payload (含 [DONE] 与 {"error":...}) 一字节不改 +// - data: payload 仅补齐 DeepSeek 缓存用量字段,其余内容原样透传 // - 上游真断流 (EOF / connection reset / context cancel) 原样透传 import ( @@ -103,6 +103,11 @@ func (b *einoSSESanitizingBody) Read(p []byte) (int, error) { if line[len(line)-1] != '\n' { line = append(line, '\n') } + payload := bytes.TrimSpace(bytes.TrimLeft(line, " \t")[5:]) + normalized := normalizeEinoUsagePayload(payload) + if !bytes.Equal(payload, normalized) { + line = append(append([]byte("data: "), normalized...), '\n') + } b.pending = line if err != nil { b.err = err diff --git a/internal/openai/eino_usage_normalizer.go b/internal/openai/eino_usage_normalizer.go new file mode 100644 index 00000000..0b14bc3b --- /dev/null +++ b/internal/openai/eino_usage_normalizer.go @@ -0,0 +1,90 @@ +package openai + +import ( + "bytes" + "encoding/json" + "io" + "net/http" + "strings" +) + +// normalizeEinoUsagePayload bridges DeepSeek's cache counters to the OpenAI +// details understood by Eino. Standard provider fields always take precedence. +func normalizeEinoUsagePayload(payload []byte) []byte { + if !bytes.Contains(payload, []byte(`"prompt_cache_hit_tokens"`)) { + return payload + } + var root map[string]json.RawMessage + if json.Unmarshal(payload, &root) != nil { + return payload + } + var usage map[string]json.RawMessage + if json.Unmarshal(root["usage"], &usage) != nil { + return payload + } + hit, ok := usage["prompt_cache_hit_tokens"] + if !ok { + return payload + } + var count int64 + if json.Unmarshal(hit, &count) != nil || count < 0 { + return payload + } + details := map[string]json.RawMessage{} + if raw := usage["prompt_tokens_details"]; len(raw) > 0 && string(raw) != "null" { + if json.Unmarshal(raw, &details) != nil { + return payload + } + } + if _, exists := details["cached_tokens"]; exists { + return payload + } + details["cached_tokens"] = hit + usage["prompt_tokens_details"], _ = json.Marshal(details) + root["usage"], _ = json.Marshal(usage) + out, err := json.Marshal(root) + if err != nil { + return payload + } + return out +} + +// JSON responses are normalized lazily so RoundTrip retains its normal timing. +// Streaming responses are normalized one data line at a time by the SSE reader. +type einoUsageNormalizingRoundTripper struct{ base http.RoundTripper } + +func (rt *einoUsageNormalizingRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) { + resp, err := rt.base.RoundTrip(req) + if err == nil && resp != nil && resp.Body != nil && resp.StatusCode == http.StatusOK && strings.HasPrefix(strings.ToLower(resp.Header.Get("Content-Type")), "application/json") { + resp.Body = &einoUsageJSONBody{upstream: resp.Body} + resp.ContentLength = -1 + resp.Header.Del("Content-Length") + } + return resp, err +} + +type einoUsageJSONBody struct { + upstream io.ReadCloser + reader *bytes.Reader + err error +} + +func (b *einoUsageJSONBody) Read(p []byte) (int, error) { + if len(p) == 0 { + return 0, nil + } + if b.reader == nil { + raw, err := io.ReadAll(b.upstream) + b.err = err + if err == nil { + raw = normalizeEinoUsagePayload(raw) + } + b.reader = bytes.NewReader(raw) + } + n, err := b.reader.Read(p) + if err == io.EOF && b.err != nil { + return n, b.err + } + return n, err +} +func (b *einoUsageJSONBody) Close() error { return b.upstream.Close() } diff --git a/internal/openai/eino_usage_normalizer_test.go b/internal/openai/eino_usage_normalizer_test.go new file mode 100644 index 00000000..4bd319f9 --- /dev/null +++ b/internal/openai/eino_usage_normalizer_test.go @@ -0,0 +1,70 @@ +package openai + +import ( + "encoding/json" + "io" + "net/http" + "strings" + "testing" +) + +func TestNormalizeEinoDeepSeekCacheUsage(t *testing.T) { + for _, raw := range []string{ + `{"id":9007199254740993,"usage":{"prompt_tokens":100,"prompt_cache_hit_tokens":80,"prompt_cache_miss_tokens":20}}`, + `{"usage":{"prompt_tokens_details":null,"prompt_cache_hit_tokens":80}}`, + } { + out := normalizeEinoUsagePayload([]byte(raw)) + var root map[string]json.RawMessage + if err := json.Unmarshal(out, &root); err != nil { + t.Fatal(err) + } + var usage struct { + Details struct { + Cached int `json:"cached_tokens"` + } `json:"prompt_tokens_details"` + } + if err := json.Unmarshal(root["usage"], &usage); err != nil || usage.Details.Cached != 80 { + t.Fatalf("normalized = %s, err = %v", out, err) + } + if id := root["id"]; id != nil && string(id) != "9007199254740993" { + t.Fatalf("ID precision lost: %s", id) + } + } + for _, raw := range []string{ + `[DONE]`, `{"error":{"message":"failed"}}`, `{"usage":{"prompt_cache_hit_tokens":-1}}`, + `{"usage":{"prompt_cache_hit_tokens":80,"prompt_tokens_details":{"cached_tokens":0}}}`, + } { + if out := string(normalizeEinoUsagePayload([]byte(raw))); out != raw { + t.Fatalf("unexpected change: %s", out) + } + } +} + +func TestEinoDeepSeekCacheUsageJSONAndStream(t *testing.T) { + payload := `{"usage":{"prompt_tokens":100,"completion_tokens":5,"total_tokens":105,"prompt_cache_hit_tokens":80}}` + for _, stream := range []bool{false, true} { + contentType, body := "application/json", payload + if stream { + contentType, body = "text/event-stream", ": ping\ndata: "+payload+"\n\ndata: [DONE]\n" + } + client := NewEinoHTTPClient(nil, &http.Client{Transport: usageTestTransport(func(req *http.Request) (*http.Response, error) { + return &http.Response{StatusCode: 200, Header: http.Header{"Content-Type": []string{contentType}}, Body: io.NopCloser(strings.NewReader(body))}, nil + })}) + resp, err := client.Get("http://example.invalid/v1/chat/completions") + if err != nil { + t.Fatal(err) + } + out, err := io.ReadAll(resp.Body) + resp.Body.Close() + if err != nil || !strings.Contains(string(out), `"cached_tokens":80`) { + t.Fatalf("stream=%v body=%s err=%v", stream, out, err) + } + if stream && !strings.Contains(string(out), "data: [DONE]") { + t.Fatal("lost stream terminator") + } + } +} + +type usageTestTransport func(*http.Request) (*http.Response, error) + +func (f usageTestTransport) RoundTrip(req *http.Request) (*http.Response, error) { return f(req) } diff --git a/web/static/css/style.css b/web/static/css/style.css index 5e80d17f..100b2dd9 100644 --- a/web/static/css/style.css +++ b/web/static/css/style.css @@ -47219,3 +47219,25 @@ html[data-theme="dark"] { --tool-blocked-color: #f4bc62; } .update-adopt-confirm-btn { align-self: flex-start; } + +/* Provider-reported token analysis, scoped by the dashboard project selector. */ +.token-usage-panel { margin-bottom: 20px; padding: 18px 22px; } +.token-usage-panel > summary { cursor: pointer; } +.token-usage-toolbar { display: flex; align-items: center; gap: 12px; margin-top: 18px; } +.token-usage-toolbar select { padding: 6px 10px; border: 1px solid var(--border-color, #dce2ea); border-radius: 6px; background: var(--bg-secondary, #fff); color: inherit; } +.token-usage-note { opacity: .72; font-size: 12px; line-height: 1.7; margin: 14px 0; } +.token-usage-metrics { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 10px; } +.token-usage-metrics > div { padding: 12px; border: 1px solid var(--border-color, #dce2ea); border-radius: 8px; min-width: 0; } +.token-usage-metrics span { display: block; font-size: 12px; opacity: .7; } +.token-usage-metrics strong { display: block; margin-top: 6px; font-size: 20px; overflow-wrap: anywhere; font-variant-numeric: tabular-nums; } +.token-usage-table-wrap { overflow-x: auto; } +.token-usage-table-wrap table { width: 100%; border-collapse: collapse; font-size: 12px; font-variant-numeric: tabular-nums; } +.token-usage-table-wrap th, .token-usage-table-wrap td { padding: 10px; text-align: right; border-bottom: 1px solid var(--border-color, #dce2ea); white-space: nowrap; } +.token-usage-table-wrap th:first-child, .token-usage-table-wrap td:first-child { text-align: left; } +.token-usage-table-wrap td:first-child { max-width: 280px; overflow: hidden; text-overflow: ellipsis; } +@media (max-width: 640px) { .token-usage-metrics { grid-template-columns: repeat(2, minmax(0, 1fr)); } .token-usage-panel { padding: 14px; } } +.token-usage-trend { display: flex; align-items: flex-end; gap: 8px; overflow-x: auto; padding: 12px 0; } +.token-usage-day { display: flex; flex-direction: column; align-items: center; justify-content: flex-end; flex: 1 0 38px; height: 128px; gap: 6px; font-size: 11px; } +.token-usage-day-bar { width: 70%; max-width: 48px; background: var(--accent-color, #3b82f6); border-radius: 4px 4px 0 0; } + +.token-usage-panel h4 { margin: 20px 0 8px; } diff --git a/web/static/i18n/en-US.json b/web/static/i18n/en-US.json index 14e72eb0..25c5a3c2 100644 --- a/web/static/i18n/en-US.json +++ b/web/static/i18n/en-US.json @@ -287,7 +287,7 @@ "toolCalls": "Tool invocations", "successRate": "Tool success rate", "tokenUsage": "Token usage", - "tokenUsageSub": "Last 7 days {{calls}} calls · Today {{today}}", + "tokenUsageSub": "Last {{days}} days: {{calls}} calls · Today {{today}}", "noTokenUsageYet": "No usage yet", "clickToViewTasks": "Click to view tasks", "clickToViewChat": "Click to view conversations", @@ -433,7 +433,33 @@ "mcpPartialDown_other": "{{count}} stopped", "mcpAllDown": "All stopped", "noVulnDesc": "This list shows recent records; new results appear here when detection completes in chat", - "startScanBtn": "Go to chat to scan" + "startScanBtn": "Go to chat to scan", + "usageView": "View token usage", + "usagePeriod": "Period", + "usage7Days": "Last 7 days", + "usage30Days": "Last 30 days", + "usage90Days": "Last 90 days", + "usageTotal": "Total tokens", + "usageInput": "Input tokens", + "usageOutput": "Output tokens", + "usageCacheRead": "Cache read", + "usageCacheWrite": "Cache write", + "usageReasoning": "Reasoning tokens", + "usageCalls": "Model calls", + "usageCacheRate": "Cache read / input", + "usageByModel": "Usage by model (up to 20)", + "usageByDay": "Daily usage", + "usageRecent": "Recent runs (up to 20)", + "usageDate": "Date", + "usageTime": "Time", + "usageModel": "Model", + "usageStatus": "End reason", + "usageUnavailable": "Token usage is unavailable", + "usageScope": "Provider-reported usage from recorded Agent runs, including sub-agents. Cache reads and writes are part of input; reasoning is part of output. These details are not added to totals again. Unreported or uncollected usage is excluded. This is not a provider bill. Models are grouped by run configuration; runs with model failover may contain mixed usage.", + "usageShare": "Token share", + "usageTrend": "Daily token trend", + "usageFinal": "Completed", + "usagePartial": "Partial usage" }, "projects": { "title": "Projects", @@ -795,7 +821,7 @@ "turnDurationHours": "{{hours}} hr {{minutes}} min", "turnProcessAria": "{{state}}; expand or collapse execution details", "turnTokenUsageLabel": "{{tokens}} tokens", - "turnTokenUsageTitle": "Token usage: {{total}} (input {{prompt}}, output {{completion}}, cached {{cached}}, reasoning {{reasoning}}, {{calls}} calls)", + "turnTokenUsageTitle": "Token usage: {{total}} (input {{prompt}}, output {{completion}}, cached {{cached}}, reasoning {{reasoning}}, {{calls}} calls) · Cache write {{cacheWrite}}", "turnNumber": "Turn {{number}}", "turnPending": "Processing…", "expandDetailLazyHint": "Expand details (loads iteration details on click)", @@ -979,7 +1005,8 @@ "hitlTimeoutUnlimited": "No limit", "hitlTimeoutHint": "Unanswered requests are rejected automatically when time expires; approval cards show the countdown.", "hitlStatusOff": "Human-in-the-loop: Off", - "rolePanelTitle": "Select role" + "rolePanelTitle": "Select role", + "einoUsageCacheWriteTokens": "Cache write tokens" }, "toolGuard": { "invalidTestResponse": "The server returned an invalid test result", @@ -3722,7 +3749,6 @@ "hitlPolicy": "Tool approval", "hitlHint": "Applies to all tasks in this queue, including tasks added later. Uses the global whitelist, audit strategy and timeout.", "hitlSubtaskHint": "New tasks use the queue’s tool approval setting. Change it in queue details.", - "title": "New task", "queueTitle": "Queue title", "queueTitlePlaceholder": "Enter queue title (optional, for identification and filtering)", diff --git a/web/static/i18n/ru-RU.json b/web/static/i18n/ru-RU.json index 02370429..25c0928d 100644 --- a/web/static/i18n/ru-RU.json +++ b/web/static/i18n/ru-RU.json @@ -287,7 +287,7 @@ "toolCalls": "Вызовы инструментов", "successRate": "Успешность инструментов", "tokenUsage": "Расход токенов", - "tokenUsageSub": "За 7 дней {{calls}} вызовов · сегодня {{today}}", + "tokenUsageSub": "За {{days}} дней {{calls}} вызовов · сегодня {{today}}", "noTokenUsageYet": "Расхода пока нет", "clickToViewTasks": "Открыть задачи", "clickToViewChat": "Открыть диалоги", @@ -445,7 +445,33 @@ "recoApproveHitl": "Одобрить запросы HITL: {{count}}", "recoRestartMcp": "Проверить не запущенные внешние MCP: {{count}}", "recoCheckMonitor": "Разобрать неудачные вызовы инструментов: {{count}}", - "mcpPartialDown": "Не запущено: {{count}}" + "mcpPartialDown": "Не запущено: {{count}}", + "usageView": "Расход токенов", + "usagePeriod": "Период", + "usage7Days": "За 7 дней", + "usage30Days": "За 30 дней", + "usage90Days": "За 90 дней", + "usageTotal": "Всего токенов", + "usageInput": "Входные токены", + "usageOutput": "Выходные токены", + "usageCacheRead": "Чтение кэша", + "usageCacheWrite": "Запись кэша", + "usageReasoning": "Токены рассуждения", + "usageCalls": "Вызовы модели", + "usageCacheRate": "Чтение кэша / ввод", + "usageByModel": "По моделям (до 20)", + "usageByDay": "По дням", + "usageRecent": "Последние запуски (до 20)", + "usageDate": "Дата", + "usageTime": "Время", + "usageModel": "Модель", + "usageStatus": "Причина завершения", + "usageUnavailable": "Статистика недоступна", + "usageShare": "Доля токенов", + "usageTrend": "Расход по дням", + "usageScope": "Данные провайдера из сохранённых запусков Agent, включая субагентов. Чтение и запись кэша входят во ввод, рассуждение — в вывод; они не прибавляются к сумме повторно. Неполученные данные исключены. Это не счёт провайдера. Группировка по модели в конфигурации запуска; при переключении модели расход может быть смешан.", + "usageFinal": "Завершено", + "usagePartial": "Частичный расход" }, "projects": { "title": "Проекты", @@ -807,7 +833,7 @@ "turnDurationHours": "{{hours}} ч {{minutes}} мин", "turnProcessAria": "{{state}}; развернуть или свернуть подробности выполнения", "turnTokenUsageLabel": "{{tokens}} токенов", - "turnTokenUsageTitle": "Расход токенов: {{total}} (ввод {{prompt}}, вывод {{completion}}, кэш {{cached}}, рассуждение {{reasoning}}, вызовов {{calls}})", + "turnTokenUsageTitle": "Расход токенов: {{total}} (ввод {{prompt}}, вывод {{completion}}, кэш {{cached}}, рассуждение {{reasoning}}, вызовов {{calls}}) · Запись кэша {{cacheWrite}}", "turnNumber": "Ход {{number}}", "turnPending": "Обработка…", "expandDetailLazyHint": "Развернуть подробности (итерации загружаются по щелчку)", @@ -991,7 +1017,8 @@ "hitlTimeoutUnlimited": "Без ограничения", "hitlTimeoutHint": "Запросы без ответа автоматически отклоняются по истечении времени; на карточках одобрения виден отсчёт.", "hitlStatusOff": "Контроль человеком: выкл.", - "rolePanelTitle": "Выбор роли" + "rolePanelTitle": "Выбор роли", + "einoUsageCacheWriteTokens": "Токены записи кэша" }, "toolGuard": { "invalidTestResponse": "Сервер вернул некорректный результат проверки", diff --git a/web/static/i18n/zh-CN.json b/web/static/i18n/zh-CN.json index 6cf0dd9a..00f8d90e 100644 --- a/web/static/i18n/zh-CN.json +++ b/web/static/i18n/zh-CN.json @@ -287,7 +287,7 @@ "toolCalls": "工具调用次数", "successRate": "工具执行成功率", "tokenUsage": "Token 用量", - "tokenUsageSub": "近 7 天 {{calls}} 次调用 · 今日 {{today}}", + "tokenUsageSub": "近 {{days}} 天 {{calls}} 次调用 · 今日 {{today}}", "noTokenUsageYet": "暂无用量", "clickToViewTasks": "点击查看任务管理", "clickToViewChat": "点击查看对话", @@ -421,7 +421,33 @@ "mcpPartialDown": "{{count}} 个未运行", "mcpAllDown": "全部未运行", "noVulnDesc": "此处展示近期漏洞记录;在对话中完成检测后,新结果会出现在这里", - "startScanBtn": "前往对话发起扫描" + "startScanBtn": "前往对话发起扫描", + "usageView": "查看用量详情", + "usagePeriod": "统计周期", + "usage7Days": "近 7 天", + "usage30Days": "近 30 天", + "usage90Days": "近 90 天", + "usageTotal": "总 Token", + "usageInput": "输入 Token", + "usageOutput": "输出 Token", + "usageCacheRead": "缓存读取", + "usageCacheWrite": "缓存写入", + "usageReasoning": "推理 Token", + "usageCalls": "模型调用", + "usageCacheRate": "缓存读取占输入", + "usageByModel": "模型用量(最多 20 项)", + "usageByDay": "每日用量", + "usageRecent": "最近运行(最多 20 项)", + "usageDate": "日期", + "usageTime": "时间", + "usageModel": "模型", + "usageStatus": "结束原因", + "usageUnavailable": "用量统计暂不可用", + "usageScope": "统计已记录的 Agent 运行中模型返回的用量,包括子 Agent。缓存读取和写入属于输入,推理属于输出,不重复加入总量。未返回或未采集的用量不在统计内;这不是供应商账单。 模型按运行配置归类,切换模型的运行可能混合用量。", + "usageShare": "用量占比", + "usageTrend": "每日用量趋势", + "usageFinal": "已完成", + "usagePartial": "部分用量" }, "projects": { "title": "项目管理", @@ -783,7 +809,7 @@ "turnDurationHours": "{{hours}} 小时 {{minutes}} 分钟", "turnProcessAria": "{{state}},展开或收起执行过程", "turnTokenUsageLabel": "{{tokens}} tokens", - "turnTokenUsageTitle": "Token 用量:{{total}}(输入 {{prompt}},输出 {{completion}},缓存 {{cached}},推理 {{reasoning}},调用 {{calls}} 次)", + "turnTokenUsageTitle": "Token 用量:{{total}}(输入 {{prompt}},输出 {{completion}},缓存 {{cached}},推理 {{reasoning}},调用 {{calls}} 次) · 缓存写入 {{cacheWrite}}", "turnNumber": "第 {{number}} 轮", "turnPending": "正在处理…", "expandDetailLazyHint": "展开详情(点击后加载迭代详情)", @@ -967,7 +993,8 @@ "hitlTimeoutUnlimited": "不限制", "hitlTimeoutHint": "到期未处理将自动拒绝;审批卡片会显示倒计时。", "hitlStatusOff": "人机协同:关闭", - "rolePanelTitle": "选择角色" + "rolePanelTitle": "选择角色", + "einoUsageCacheWriteTokens": "缓存写入 tokens" }, "toolGuard": { "invalidTestResponse": "服务端返回的测试结果格式不正确", @@ -3710,7 +3737,6 @@ "hitlPolicy": "工具审批", "hitlHint": "队列内所有任务(含后续添加的任务)使用此设置;白名单、审计策略和等待时限沿用全局配置。", "hitlSubtaskHint": "新增任务沿用队列的工具审批设置,可在队列详情中修改。", - "title": "新建任务", "queueTitle": "任务标题", "queueTitlePlaceholder": "请输入任务标题(可选,用于标识和筛选)", diff --git a/web/static/js/chat-scroll-refresh.test.cjs b/web/static/js/chat-scroll-refresh.test.cjs index 53cc6a75..24dfa94b 100644 --- a/web/static/js/chat-scroll-refresh.test.cjs +++ b/web/static/js/chat-scroll-refresh.test.cjs @@ -341,7 +341,7 @@ test('消息气泡内部流式增高时仅在跟随模式继续粘底', () => { test('页面在任务补流脚本之前加载智能滚动控制器', () => { const scrollIndex = html.indexOf('/static/js/chat-scroll.js?v=20260815-1'); - const monitorIndex = html.indexOf('/static/js/monitor.js?v=20260907-blocked-1'); + const monitorIndex = html.indexOf('/static/js/monitor.js?v=20261011-token-usage'); assert.notEqual(scrollIndex, -1); assert.notEqual(monitorIndex, -1); diff --git a/web/static/js/chat.js b/web/static/js/chat.js index 70637194..8d26bcdb 100644 --- a/web/static/js/chat.js +++ b/web/static/js/chat.js @@ -4945,6 +4945,7 @@ function normalizeAssistantTurnTokenUsage(data) { completionTokens: assistantTurnUsageNumber(source.completionTokens), totalTokens: assistantTurnUsageNumber(source.totalTokens), cachedTokens: assistantTurnUsageNumber(source.cachedTokens), + cacheWriteTokens: assistantTurnUsageNumber(source.cacheWriteTokens), reasoningTokens: assistantTurnUsageNumber(source.reasoningTokens), model: source.model != null ? String(source.model).trim() : '' }; @@ -4962,6 +4963,7 @@ function mergeAssistantTurnTokenUsage(target, usage) { completionTokens: 0, totalTokens: 0, cachedTokens: 0, + cacheWriteTokens: 0, reasoningTokens: 0, model: '' }; @@ -4970,6 +4972,7 @@ function mergeAssistantTurnTokenUsage(target, usage) { out.completionTokens += usage.completionTokens || 0; out.totalTokens += usage.totalTokens || 0; out.cachedTokens += usage.cachedTokens || 0; + out.cacheWriteTokens += usage.cacheWriteTokens || 0; out.reasoningTokens += usage.reasoningTokens || 0; if (!out.model && usage.model) out.model = usage.model; return out.totalTokens > 0 ? out : null; @@ -4998,6 +5001,7 @@ function setAssistantTurnTokenUsage(messageElementOrId, usage) { delete messageElement.dataset.turnCompletionTokens; delete messageElement.dataset.turnTotalTokens; delete messageElement.dataset.turnCachedTokens; + delete messageElement.dataset.turnCacheWriteTokens; delete messageElement.dataset.turnReasoningTokens; delete messageElement.dataset.turnModel; syncAssistantTurnSummary(messageElement); @@ -5008,6 +5012,7 @@ function setAssistantTurnTokenUsage(messageElementOrId, usage) { messageElement.dataset.turnCompletionTokens = String(normalized.completionTokens || 0); messageElement.dataset.turnTotalTokens = String(normalized.totalTokens || 0); messageElement.dataset.turnCachedTokens = String(normalized.cachedTokens || 0); + messageElement.dataset.turnCacheWriteTokens = String(normalized.cacheWriteTokens || 0); messageElement.dataset.turnReasoningTokens = String(normalized.reasoningTokens || 0); if (normalized.model) { messageElement.dataset.turnModel = normalized.model; @@ -5025,6 +5030,7 @@ function getAssistantTurnTokenUsage(messageElement) { completionTokens: messageElement.dataset.turnCompletionTokens, totalTokens: messageElement.dataset.turnTotalTokens, cachedTokens: messageElement.dataset.turnCachedTokens, + cacheWriteTokens: messageElement.dataset.turnCacheWriteTokens, reasoningTokens: messageElement.dataset.turnReasoningTokens, model: messageElement.dataset.turnModel }); @@ -5051,6 +5057,7 @@ function formatAssistantTurnTokenUsageTitle(usage) { prompt: formatAssistantTurnTokenCount(safeUsage.promptTokens), completion: formatAssistantTurnTokenCount(safeUsage.completionTokens), cached: formatAssistantTurnTokenCount(safeUsage.cachedTokens), + cacheWrite: formatAssistantTurnTokenCount(safeUsage.cacheWriteTokens), reasoning: formatAssistantTurnTokenCount(safeUsage.reasoningTokens), calls: formatAssistantTurnTokenCount(safeUsage.modelCalls), model: safeUsage.model || '' diff --git a/web/static/js/dashboard.js b/web/static/js/dashboard.js index 23243bd5..e45a774a 100644 --- a/web/static/js/dashboard.js +++ b/web/static/js/dashboard.js @@ -17,6 +17,7 @@ var DASHBOARD_SEVERITY_STATUS_FILTER_STORAGE_KEY = 'cyberstrike.dashboard.severi var DASHBOARD_SEVERITY_STATUS_FILTER_VALUES = ['', 'open', 'confirmed', 'fixed', 'ignored', 'false_positive']; var dashboardState = { + tokenDays: 7, currentController: null, // 当前正在进行的 fetch 的 AbortController pollTimer: null, // 自动轮询的 setInterval id staleTimer: null, // 过期检查的 setInterval id @@ -162,7 +163,7 @@ async function refreshDashboard() { fetchJson(dashboardProjectScopedUrl('/api/c2/tasks?page=1&page_size=1')), fetchJson('/api/projects/dashboard-summary?fact_limit=10'), selectedSeverityStatus ? fetchJson('/api/vulnerabilities/stats?status=' + encodeURIComponent(selectedSeverityStatus)) : Promise.resolve(null), - fetchJson(dashboardProjectScopedUrl('/api/usage/tokens?days=7&limit=5')) + fetchJson(dashboardProjectScopedUrl('/api/usage/tokens?days=' + dashboardState.tokenDays + '&limit=20')) ]); // 如果在 await 期间 controller 已被 abort,说明又有新刷新启动了,丢弃本次结果 @@ -440,6 +441,7 @@ async function refreshDashboard() { setDashboardOverviewPlaceholder('-'); setEl('dashboard-kpi-success-rate', '-'); setEl('dashboard-kpi-tools-calls', '-'); + renderDashboardTokenAnalysis(null); setEl('dashboard-kpi-token-usage', '-'); setKpiSubText('dashboard-kpi-tasks-sub-text', '-'); setKpiSubText('dashboard-kpi-vuln-sub-text', '-'); @@ -708,6 +710,7 @@ function setKpiRateBadge(id, rate, failedCount) { } function renderDashboardTokenUsage(res) { + renderDashboardTokenAnalysis(res); const summary = res && res.summary ? res.summary : null; if (!summary) { setEl('dashboard-kpi-token-usage', '-'); @@ -725,9 +728,10 @@ function renderDashboardTokenUsage(res) { setEl('dashboard-kpi-token-usage', formatTokenUsageCompact(total)); setKpiSubText('dashboard-kpi-token-sub-text', dt('dashboard.tokenUsageSub', { + days: dashboardState.tokenDays, today: formatTokenUsageCompact(today), calls: Number.isFinite(calls) ? calls : 0 - }, '近 7 天 ' + (Number.isFinite(calls) ? calls : 0) + ' 次调用 · 今日 ' + formatTokenUsageCompact(today))); + }, '近 ' + dashboardState.tokenDays + ' 天 ' + (Number.isFinite(calls) ? calls : 0) + ' 次调用 · 今日 ' + formatTokenUsageCompact(today))); } function formatTokenUsageCompact(num) { @@ -2524,3 +2528,136 @@ document.addEventListener('click', function (ev) { var banner = document.getElementById('dashboard-alert-banner'); if (banner) banner.hidden = true; }); + +function openDashboardTokenUsage() { + const panel = document.getElementById('dashboard-token-details'); + if (!panel) return; + panel.open = true; + panel.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); +} + +function changeDashboardTokenPeriod(value) { + const days = Number(value); + if (![7, 30, 90].includes(days)) return; + dashboardState.tokenDays = days; + const host = document.getElementById('dashboard-token-analysis'); + if (host) host.textContent = '…'; + refreshDashboard(); +} + +function dashboardUsageCount(value) { + const n = Number(value); + return Number.isFinite(n) && n > 0 ? Math.trunc(n) : 0; +} + +function dashboardUsageMetrics(summary) { + const input = dashboardUsageCount(summary.promptTokens); + const cached = dashboardUsageCount(summary.cachedTokens); + return [ + ['usageTotal', summary.totalTokens], + ['usageInput', input], + ['usageOutput', summary.completionTokens], + ['usageCacheRead', cached], + ['usageCacheWrite', summary.cacheWriteTokens], + ['usageReasoning', summary.reasoningTokens], + ['usageCalls', summary.modelCalls], + ['usageCacheRate', input > 0 ? (Math.min(cached, input) / input * 100).toFixed(1) + '%' : '—'] + ]; +} + +function renderDashboardTokenAnalysis(res) { + const host = document.getElementById('dashboard-token-analysis'); + if (!host) return; + host.replaceChildren(); + if (!res || !res.summary) { + host.textContent = dt('dashboard.usageUnavailable', null, '用量统计暂不可用'); + return; + } + const exact = value => dashboardUsageCount(value).toLocaleString(window.__locale || 'zh-CN'); + const label = key => dt('dashboard.' + key, null, key); + const metrics = document.createElement('div'); + metrics.className = 'token-usage-metrics'; + dashboardUsageMetrics(res.summary).forEach(([key, value]) => { + const card = document.createElement('div'); + const title = document.createElement('span'); + title.textContent = label(key); + const count = document.createElement('strong'); + count.textContent = typeof value === 'string' ? value : exact(value); + card.append(title, count); + metrics.append(card); + }); + host.append(metrics); + if (!dashboardUsageCount(res.summary.events)) { + const empty = document.createElement('p'); + empty.textContent = dt('dashboard.noTokenUsageYet', null, '暂无用量'); + host.append(empty); + return; + } + // Tables use text nodes: provider model names and conversation IDs are untrusted. + const table = (titleKey, headings, rows) => { + const section = document.createElement('section'); + const title = document.createElement('h4'); + title.textContent = label(titleKey); + const wrap = document.createElement('div'); + wrap.className = 'token-usage-table-wrap'; + const grid = document.createElement('table'); + const caption = document.createElement('caption'); + caption.className = 'sr-only'; + caption.textContent = title.textContent; + const head = document.createElement('thead'); + const hr = document.createElement('tr'); + headings.forEach(key => { + const th = document.createElement('th'); + th.scope = 'col'; + th.textContent = label(key); + hr.append(th); + }); + head.append(hr); + const body = document.createElement('tbody'); + rows.forEach(values => { + const tr = document.createElement('tr'); + values.forEach(value => { + const td = document.createElement('td'); + td.textContent = String(value == null ? '' : value); + tr.append(td); + }); + body.append(tr); + }); + grid.append(caption, head, body); + wrap.append(grid); + section.append(title, wrap); + host.append(section); + }; + const counts = row => [exact(row.promptTokens), exact(row.completionTokens), exact(row.cachedTokens), + exact(row.cacheWriteTokens), exact(row.reasoningTokens), exact(row.totalTokens), exact(row.modelCalls)]; + const fields = ['usageInput', 'usageOutput', 'usageCacheRead', 'usageCacheWrite', 'usageReasoning', 'usageTotal', 'usageCalls']; + table('usageByModel', ['usageModel', 'usageShare', ...fields], (res.byModel || []).map(row => [row.label || row.key, + dashboardUsageCount(res.summary.totalTokens) > 0 ? (dashboardUsageCount(row.totalTokens) / dashboardUsageCount(res.summary.totalTokens) * 100).toFixed(1) + '%' : '—', ...counts(row)])); + const trendTitle = document.createElement('h4'); + trendTitle.textContent = label('usageTrend'); + const trend = document.createElement('div'); + trend.className = 'token-usage-trend'; + const days = (res.byDay || []).slice().reverse(); + const peak = Math.max(1, ...days.map(row => dashboardUsageCount(row.totalTokens))); + days.forEach(row => { + const bar = document.createElement('div'); + bar.className = 'token-usage-day'; + const count = document.createElement('span'); + count.textContent = formatTokenUsageCompact(row.totalTokens); + const fill = document.createElement('div'); + fill.className = 'token-usage-day-bar'; + fill.style.height = Math.max(1, dashboardUsageCount(row.totalTokens) / peak * 90) + 'px'; + const date = document.createElement('span'); + date.textContent = String(row.key || '').slice(5); + bar.title = row.key + ': ' + exact(row.totalTokens); + bar.append(count, fill, date); + trend.append(bar); + }); + host.append(trendTitle, trend); + table('usageByDay', ['usageDate', ...fields], (res.byDay || []).map(row => [row.key, ...counts(row)])); + table('usageRecent', ['usageTime', 'usageModel', 'usageStatus', 'usageTotal', 'usageCalls'], (res.recent || []).map(row => { + const time = new Date(row.createdAt); + return [Number.isFinite(time.getTime()) ? time.toLocaleString(window.__locale || 'zh-CN') : '—', + row.model || '—', row.reason === 'final' ? label('usageFinal') : row.reason === 'partial' ? label('usagePartial') : (row.reason || '—'), exact(row.totalTokens), exact(row.modelCalls)]; + })); +} diff --git a/web/static/js/hitl-approval-ui.test.cjs b/web/static/js/hitl-approval-ui.test.cjs index 598bdf12..cf15377e 100644 --- a/web/static/js/hitl-approval-ui.test.cjs +++ b/web/static/js/hitl-approval-ui.test.cjs @@ -107,7 +107,7 @@ test('Agent 审查不进入人工审批弹窗、倒计时和项目计数', () => const hitlPage = fs.readFileSync('web/static/js/hitl.js', 'utf8'); assert.match(handler, /CreatePendingInterrupt\([\s\S]{0,260}reviewer string/); assert.match(handler, /reviewer != "audit_agent"[\s\S]{0,120}m\.pending\[id\] = p/); - assert.match(logsHandler, /ADD COLUMN reviewer TEXT NOT NULL DEFAULT 'human'/); + assert.match(fs.readFileSync('internal/database/schemas/application.sql', 'utf8'), /reviewer TEXT NOT NULL DEFAULT 'human'/); assert.match(logsHandler, /status = 'pending' AND COALESCE\(reviewer,'human'\) = 'human'/); assert.match(monitor, /function isAgentReviewedHitl\(data\)/); assert.match(monitor, /if \(!data\.resolved && !isAgentReviewedHitl\(data\)\)/); @@ -293,7 +293,7 @@ test('多对话并发时释放隐藏主流且旧请求不能覆盖新对话状 assert.match(chat, /let loadConversationAbortController = null/); assert.match(chat, /cancelPendingConversationLoad\(\);[\s\S]{0,900}const conversationLoadController = new AbortController\(\)/); assert.match(chat, /signal: conversationLoadController\.signal/); - assert.match(template, /monitor\.js\?v=20260907-blocked-1/); + assert.match(template, /monitor\.js\?v=20261011-token-usage/); assert.match(template, /chat-scroll\.js\?v=20260815-1/); assert.match(template, /chat\.js\?v=[A-Za-z0-9._-]+/); assert.match(template, /style\.css\?v=[A-Za-z0-9._-]+/); diff --git a/web/static/js/monitor.js b/web/static/js/monitor.js index 8009d489..aafad05c 100644 --- a/web/static/js/monitor.js +++ b/web/static/js/monitor.js @@ -2889,6 +2889,9 @@ function formatEinoUsageSummaryMessage(data) { if (Number(d.cachedTokens || 0) > 0) { rows.push([label('chat.einoUsageCachedTokens', '缓存 tokens'), d.cachedTokens]); } + if (Number(d.cacheWriteTokens || 0) > 0) { + rows.push([label('chat.einoUsageCacheWriteTokens', '缓存写入 tokens'), d.cacheWriteTokens]); + } if (Number(d.reasoningTokens || 0) > 0) { rows.push([label('chat.einoUsageReasoningTokens', '推理 tokens'), d.reasoningTokens]); } @@ -6734,7 +6737,7 @@ function addTimelineItem(timeline, type, options) { } if (type === 'eino_usage_summary' && options.data) { const d = options.data; - ['modelCalls', 'promptTokens', 'completionTokens', 'totalTokens', 'cachedTokens', 'reasoningTokens'].forEach(function (key) { + ['modelCalls', 'promptTokens', 'completionTokens', 'totalTokens', 'cachedTokens', 'cacheWriteTokens', 'reasoningTokens'].forEach(function (key) { if (d[key] != null) { item.dataset[key] = String(d[key]); } @@ -9651,6 +9654,7 @@ function refreshProgressAndTimelineI18n() { completionTokens: item.dataset.completionTokens, totalTokens: item.dataset.totalTokens, cachedTokens: item.dataset.cachedTokens, + cacheWriteTokens: item.dataset.cacheWriteTokens, reasoningTokens: item.dataset.reasoningTokens }; titleSpan.textContent = formatEinoUsageSummaryTitle(usageData); diff --git a/web/static/js/token-usage-analysis.test.cjs b/web/static/js/token-usage-analysis.test.cjs new file mode 100644 index 00000000..a6c61896 --- /dev/null +++ b/web/static/js/token-usage-analysis.test.cjs @@ -0,0 +1,43 @@ +const fs = require('node:fs'); +const vm = require('node:vm'); +const test = require('node:test'); +const assert = require('node:assert/strict'); +const source = fs.readFileSync('web/static/js/dashboard.js', 'utf8'); +const harness = () => { + const context = { document: {}, dashboardState: { tokenDays: 7 }, refreshDashboard() {} }; + vm.createContext(context); + vm.runInContext(source.slice(source.indexOf('function openDashboardTokenUsage()')), context); + return context; +}; + +test('cache and reasoning details do not inflate total or double-count input', () => { + const ctx = harness(); + const metrics = ctx.dashboardUsageMetrics({ totalTokens: 120, promptTokens: 100, completionTokens: 20, cachedTokens: 40, cacheWriteTokens: 30, reasoningTokens: 10, modelCalls: 1 }); + const values = Object.fromEntries(metrics); + assert.equal(values.usageTotal, 120); + assert.equal(values.usageCacheWrite, 30); + assert.equal(values.usageCacheRate, '40.0%'); + assert.equal(Object.fromEntries(ctx.dashboardUsageMetrics({})).usageCacheRate, '—'); + assert.equal(ctx.dashboardUsageCount(Infinity), 0); + assert.equal(ctx.dashboardUsageCount(-12), 0); +}); + +test('period selection refreshes once and rejects arbitrary periods', () => { + const ctx = harness(); + let calls = 0; + ctx.refreshDashboard = () => { calls++; }; + ctx.document.getElementById = () => ({ textContent: '' }); + ctx.changeDashboardTokenPeriod('30'); + ctx.changeDashboardTokenPeriod('999'); + assert.equal(ctx.dashboardState.tokenDays, 30); + assert.equal(calls, 1); +}); + +test('all supported languages expose usage labels and interpolation placeholders', () => { + for (const locale of ['zh-CN', 'en-US', 'ru-RU']) { + const data = JSON.parse(fs.readFileSync(`web/static/i18n/${locale}.json`, 'utf8')); + for (const key of ['usageView', 'usageScope', 'usageCacheRead', 'usageCacheWrite', 'usageTrend']) assert.ok(data.dashboard[key]); + assert.match(data.dashboard.tokenUsageSub, /\{\{days\}\}/); + assert.match(data.chat.turnTokenUsageTitle, /\{\{cacheWrite\}\}/); + } +}); diff --git a/web/static/js/turn-duration-refresh.test.cjs b/web/static/js/turn-duration-refresh.test.cjs index 2f0d94b6..030128e2 100644 --- a/web/static/js/turn-duration-refresh.test.cjs +++ b/web/static/js/turn-duration-refresh.test.cjs @@ -117,12 +117,14 @@ test('助手轮次摘要会显示持久化 token 用量', () => { promptTokens: 1200, completionTokens: 34, cachedTokens: 200, + cacheWriteTokens: 100, reasoningTokens: 12, modelCalls: 1, model: 'deepseek-v3', }); assert.equal(message.dataset.turnTotalTokens, '1234'); + assert.equal(message.dataset.turnCacheWriteTokens, '100'); assert.match(message.label.innerHTML, /1\.2K tokens/); assert.match(message.label.innerHTML, /turn-process-token-chip/); }); diff --git a/web/static/js/webshell.js b/web/static/js/webshell.js index 5999b317..b71e3e7c 100644 --- a/web/static/js/webshell.js +++ b/web/static/js/webshell.js @@ -2203,6 +2203,9 @@ function formatWebshellEinoUsageSummaryMessage(data) { if (Number(d.cachedTokens || 0) > 0) { rows.push([wsTOr('chat.einoUsageCachedTokens', '缓存 tokens'), d.cachedTokens]); } + if (Number(d.cacheWriteTokens || 0) > 0) { + rows.push([wsTOr('chat.einoUsageCacheWriteTokens', '缓存写入 tokens'), d.cacheWriteTokens]); + } if (Number(d.reasoningTokens || 0) > 0) { rows.push([wsTOr('chat.einoUsageReasoningTokens', '推理 tokens'), d.reasoningTokens]); } diff --git a/web/templates/index.html b/web/templates/index.html index 85ed32fb..3ced8a4a 100644 --- a/web/templates/index.html +++ b/web/templates/index.html @@ -556,7 +556,7 @@ 运行平稳 -
+
Token 用量
@@ -567,6 +567,19 @@
+
+ 查看用量详情 +
+ + +
+

统计已记录的 Agent 运行中模型返回的用量,包括子 Agent。缓存读取和写入属于输入,推理属于输出,不重复加入总量。未返回或未采集的用量不在统计内;这不是供应商账单。模型按运行配置归类,切换模型的运行可能混合用量。

+
+
@@ -6996,10 +7009,10 @@ - + - - + + @@ -7016,7 +7029,7 @@ - + From 4eda42dc107511e70c54e7f6aeefbfba71fe3c67 Mon Sep 17 00:00:00 2001 From: Sycun <165354365+Sycun@users.noreply.github.com> Date: Sun, 11 Oct 2026 23:51:01 +0800 Subject: [PATCH 2/5] feat: move token details to settings and show dashboard cache hit rate --- docs/zh-CN/token-usage-monitoring.md | 2 +- web/static/css/style.css | 5 +- web/static/i18n/en-US.json | 2 + web/static/i18n/ru-RU.json | 2 + web/static/i18n/zh-CN.json | 2 + web/static/js/dashboard.js | 148 +-------------- web/static/js/router.js | 17 +- web/static/js/settings.js | 12 ++ web/static/js/token-usage-analysis.test.cjs | 62 ++++-- web/static/js/token-usage.js | 197 ++++++++++++++++++++ web/static/js/update-ui.test.cjs | 2 +- web/templates/index.html | 45 +++-- 12 files changed, 320 insertions(+), 176 deletions(-) create mode 100644 web/static/js/token-usage.js diff --git a/docs/zh-CN/token-usage-monitoring.md b/docs/zh-CN/token-usage-monitoring.md index f8c844b2..98247821 100644 --- a/docs/zh-CN/token-usage-monitoring.md +++ b/docs/zh-CN/token-usage-monitoring.md @@ -29,7 +29,7 @@ DeepSeek 的未命中输入不等于 Claude 的缓存写入。不能根据文本 ## 功能入口与数据流 -点击首页「Token 用量」卡片或「查看用量详情」展开面板。支持当前项目范围、7/30/90 天切换、八项指标、最多 20 项运行模型排行、每日趋势与完整日明细、最多 20 项最近运行。沿用首页自动刷新和请求取消机制。 +点击首页「Token 用量」卡片进入「系统设置 → Token 用量」,也可直接从设置二级菜单打开。首页卡片显示近 7 天调用次数、今日用量与缓存命中率(缓存读取 Token / 输入 Token)。支持当前项目范围、7/30/90 天切换、八项指标、最多 20 项运行模型排行、每日趋势与完整日明细、最多 20 项最近运行。详情页独立加载,支持手动刷新与请求取消,切换周期不会刷新整个仪表盘;刷新页面可保持该设置分区。 运行事件 → 流式/非流式用量累加 → `eino_usage_summary` → `process_details` 与 `model_token_usage` → 现有 `/api/usage/tokens`、对话用量接口 → 首页与聊天摘要。 diff --git a/web/static/css/style.css b/web/static/css/style.css index 100b2dd9..7b1576ac 100644 --- a/web/static/css/style.css +++ b/web/static/css/style.css @@ -47221,9 +47221,12 @@ html[data-theme="dark"] { --tool-blocked-color: #f4bc62; } } /* Provider-reported token analysis, scoped by the dashboard project selector. */ +.dashboard-kpi-sub--tokens { row-gap: 2px; } +#dashboard-kpi-token-cache-text { flex-basis: 100%; } .token-usage-panel { margin-bottom: 20px; padding: 18px 22px; } -.token-usage-panel > summary { cursor: pointer; } +.token-usage-header { display: flex; align-items: center; justify-content: space-between; gap: 12px; } .token-usage-toolbar { display: flex; align-items: center; gap: 12px; margin-top: 18px; } +.token-usage-toolbar label { white-space: nowrap; } .token-usage-toolbar select { padding: 6px 10px; border: 1px solid var(--border-color, #dce2ea); border-radius: 6px; background: var(--bg-secondary, #fff); color: inherit; } .token-usage-note { opacity: .72; font-size: 12px; line-height: 1.7; margin: 14px 0; } .token-usage-metrics { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 10px; } diff --git a/web/static/i18n/en-US.json b/web/static/i18n/en-US.json index 25c5a3c2..9f3f913c 100644 --- a/web/static/i18n/en-US.json +++ b/web/static/i18n/en-US.json @@ -287,6 +287,7 @@ "toolCalls": "Tool invocations", "successRate": "Tool success rate", "tokenUsage": "Token usage", + "usageCacheHitRate": "Cache hit {{rate}}", "tokenUsageSub": "Last {{days}} days: {{calls}} calls · Today {{today}}", "noTokenUsageYet": "No usage yet", "clickToViewTasks": "Click to view tasks", @@ -1969,6 +1970,7 @@ "settings": { "title": "System settings", "nav": { + "tokenUsage": "Token usage", "basic": "Basic", "hitl": "Human-in-the-loop", "knowledge": "Knowledge base", diff --git a/web/static/i18n/ru-RU.json b/web/static/i18n/ru-RU.json index 25c0928d..667c550e 100644 --- a/web/static/i18n/ru-RU.json +++ b/web/static/i18n/ru-RU.json @@ -287,6 +287,7 @@ "toolCalls": "Вызовы инструментов", "successRate": "Успешность инструментов", "tokenUsage": "Расход токенов", + "usageCacheHitRate": "Попадания в кэш {{rate}}", "tokenUsageSub": "За {{days}} дней {{calls}} вызовов · сегодня {{today}}", "noTokenUsageYet": "Расхода пока нет", "clickToViewTasks": "Открыть задачи", @@ -1982,6 +1983,7 @@ "settings": { "title": "Системные настройки", "nav": { + "tokenUsage": "Расход токенов", "basic": "Основные", "hitl": "Человек в контуре", "knowledge": "База знаний", diff --git a/web/static/i18n/zh-CN.json b/web/static/i18n/zh-CN.json index 00f8d90e..a758dbd3 100644 --- a/web/static/i18n/zh-CN.json +++ b/web/static/i18n/zh-CN.json @@ -287,6 +287,7 @@ "toolCalls": "工具调用次数", "successRate": "工具执行成功率", "tokenUsage": "Token 用量", + "usageCacheHitRate": "缓存命中 {{rate}}", "tokenUsageSub": "近 {{days}} 天 {{calls}} 次调用 · 今日 {{today}}", "noTokenUsageYet": "暂无用量", "clickToViewTasks": "点击查看任务管理", @@ -1957,6 +1958,7 @@ "settings": { "title": "系统设置", "nav": { + "tokenUsage": "Token 用量", "basic": "基本设置", "hitl": "人机协同", "knowledge": "知识库", diff --git a/web/static/js/dashboard.js b/web/static/js/dashboard.js index e45a774a..eec890d3 100644 --- a/web/static/js/dashboard.js +++ b/web/static/js/dashboard.js @@ -17,7 +17,6 @@ var DASHBOARD_SEVERITY_STATUS_FILTER_STORAGE_KEY = 'cyberstrike.dashboard.severi var DASHBOARD_SEVERITY_STATUS_FILTER_VALUES = ['', 'open', 'confirmed', 'fixed', 'ignored', 'false_positive']; var dashboardState = { - tokenDays: 7, currentController: null, // 当前正在进行的 fetch 的 AbortController pollTimer: null, // 自动轮询的 setInterval id staleTimer: null, // 过期检查的 setInterval id @@ -73,6 +72,7 @@ async function refreshDashboard() { setKpiSubText('dashboard-kpi-tools-sub-text', '…'); setKpiSubText('dashboard-kpi-rate-sub-text', '…'); setKpiSubText('dashboard-kpi-token-sub-text', '…'); + setKpiSubText('dashboard-kpi-token-cache-text', '…'); hideEl('dashboard-kpi-vuln-critical-badge'); hideEl('dashboard-alert-banner'); setRecentVulnsLoading(); @@ -163,7 +163,7 @@ async function refreshDashboard() { fetchJson(dashboardProjectScopedUrl('/api/c2/tasks?page=1&page_size=1')), fetchJson('/api/projects/dashboard-summary?fact_limit=10'), selectedSeverityStatus ? fetchJson('/api/vulnerabilities/stats?status=' + encodeURIComponent(selectedSeverityStatus)) : Promise.resolve(null), - fetchJson(dashboardProjectScopedUrl('/api/usage/tokens?days=' + dashboardState.tokenDays + '&limit=20')) + fetchJson(dashboardProjectScopedUrl('/api/usage/tokens?days=7&limit=20')) ]); // 如果在 await 期间 controller 已被 abort,说明又有新刷新启动了,丢弃本次结果 @@ -441,8 +441,9 @@ async function refreshDashboard() { setDashboardOverviewPlaceholder('-'); setEl('dashboard-kpi-success-rate', '-'); setEl('dashboard-kpi-tools-calls', '-'); - renderDashboardTokenAnalysis(null); setEl('dashboard-kpi-token-usage', '-'); + setKpiSubText('dashboard-kpi-token-sub-text', '-'); + setKpiSubText('dashboard-kpi-token-cache-text', '-'); setKpiSubText('dashboard-kpi-tasks-sub-text', '-'); setKpiSubText('dashboard-kpi-vuln-sub-text', '-'); setKpiSubText('dashboard-kpi-tools-sub-text', '-'); @@ -710,13 +711,15 @@ function setKpiRateBadge(id, rate, failedCount) { } function renderDashboardTokenUsage(res) { - renderDashboardTokenAnalysis(res); const summary = res && res.summary ? res.summary : null; if (!summary) { setEl('dashboard-kpi-token-usage', '-'); setKpiSubText('dashboard-kpi-token-sub-text', '-'); + setKpiSubText('dashboard-kpi-token-cache-text', '-'); return; } + const cacheRate = tokenUsageCacheRate(summary); + setKpiSubText('dashboard-kpi-token-cache-text', dt('dashboard.usageCacheHitRate', { rate: cacheRate }, '缓存命中 ' + cacheRate)); const total = Number(summary.totalTokens || 0); const calls = Number(summary.modelCalls || 0); const today = res && res.today ? Number(res.today.totalTokens || 0) : 0; @@ -728,10 +731,10 @@ function renderDashboardTokenUsage(res) { setEl('dashboard-kpi-token-usage', formatTokenUsageCompact(total)); setKpiSubText('dashboard-kpi-token-sub-text', dt('dashboard.tokenUsageSub', { - days: dashboardState.tokenDays, + days: 7, today: formatTokenUsageCompact(today), calls: Number.isFinite(calls) ? calls : 0 - }, '近 ' + dashboardState.tokenDays + ' 天 ' + (Number.isFinite(calls) ? calls : 0) + ' 次调用 · 今日 ' + formatTokenUsageCompact(today))); + }, '近 7 天 ' + (Number.isFinite(calls) ? calls : 0) + ' 次调用 · 今日 ' + formatTokenUsageCompact(today))); } function formatTokenUsageCompact(num) { @@ -2528,136 +2531,3 @@ document.addEventListener('click', function (ev) { var banner = document.getElementById('dashboard-alert-banner'); if (banner) banner.hidden = true; }); - -function openDashboardTokenUsage() { - const panel = document.getElementById('dashboard-token-details'); - if (!panel) return; - panel.open = true; - panel.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); -} - -function changeDashboardTokenPeriod(value) { - const days = Number(value); - if (![7, 30, 90].includes(days)) return; - dashboardState.tokenDays = days; - const host = document.getElementById('dashboard-token-analysis'); - if (host) host.textContent = '…'; - refreshDashboard(); -} - -function dashboardUsageCount(value) { - const n = Number(value); - return Number.isFinite(n) && n > 0 ? Math.trunc(n) : 0; -} - -function dashboardUsageMetrics(summary) { - const input = dashboardUsageCount(summary.promptTokens); - const cached = dashboardUsageCount(summary.cachedTokens); - return [ - ['usageTotal', summary.totalTokens], - ['usageInput', input], - ['usageOutput', summary.completionTokens], - ['usageCacheRead', cached], - ['usageCacheWrite', summary.cacheWriteTokens], - ['usageReasoning', summary.reasoningTokens], - ['usageCalls', summary.modelCalls], - ['usageCacheRate', input > 0 ? (Math.min(cached, input) / input * 100).toFixed(1) + '%' : '—'] - ]; -} - -function renderDashboardTokenAnalysis(res) { - const host = document.getElementById('dashboard-token-analysis'); - if (!host) return; - host.replaceChildren(); - if (!res || !res.summary) { - host.textContent = dt('dashboard.usageUnavailable', null, '用量统计暂不可用'); - return; - } - const exact = value => dashboardUsageCount(value).toLocaleString(window.__locale || 'zh-CN'); - const label = key => dt('dashboard.' + key, null, key); - const metrics = document.createElement('div'); - metrics.className = 'token-usage-metrics'; - dashboardUsageMetrics(res.summary).forEach(([key, value]) => { - const card = document.createElement('div'); - const title = document.createElement('span'); - title.textContent = label(key); - const count = document.createElement('strong'); - count.textContent = typeof value === 'string' ? value : exact(value); - card.append(title, count); - metrics.append(card); - }); - host.append(metrics); - if (!dashboardUsageCount(res.summary.events)) { - const empty = document.createElement('p'); - empty.textContent = dt('dashboard.noTokenUsageYet', null, '暂无用量'); - host.append(empty); - return; - } - // Tables use text nodes: provider model names and conversation IDs are untrusted. - const table = (titleKey, headings, rows) => { - const section = document.createElement('section'); - const title = document.createElement('h4'); - title.textContent = label(titleKey); - const wrap = document.createElement('div'); - wrap.className = 'token-usage-table-wrap'; - const grid = document.createElement('table'); - const caption = document.createElement('caption'); - caption.className = 'sr-only'; - caption.textContent = title.textContent; - const head = document.createElement('thead'); - const hr = document.createElement('tr'); - headings.forEach(key => { - const th = document.createElement('th'); - th.scope = 'col'; - th.textContent = label(key); - hr.append(th); - }); - head.append(hr); - const body = document.createElement('tbody'); - rows.forEach(values => { - const tr = document.createElement('tr'); - values.forEach(value => { - const td = document.createElement('td'); - td.textContent = String(value == null ? '' : value); - tr.append(td); - }); - body.append(tr); - }); - grid.append(caption, head, body); - wrap.append(grid); - section.append(title, wrap); - host.append(section); - }; - const counts = row => [exact(row.promptTokens), exact(row.completionTokens), exact(row.cachedTokens), - exact(row.cacheWriteTokens), exact(row.reasoningTokens), exact(row.totalTokens), exact(row.modelCalls)]; - const fields = ['usageInput', 'usageOutput', 'usageCacheRead', 'usageCacheWrite', 'usageReasoning', 'usageTotal', 'usageCalls']; - table('usageByModel', ['usageModel', 'usageShare', ...fields], (res.byModel || []).map(row => [row.label || row.key, - dashboardUsageCount(res.summary.totalTokens) > 0 ? (dashboardUsageCount(row.totalTokens) / dashboardUsageCount(res.summary.totalTokens) * 100).toFixed(1) + '%' : '—', ...counts(row)])); - const trendTitle = document.createElement('h4'); - trendTitle.textContent = label('usageTrend'); - const trend = document.createElement('div'); - trend.className = 'token-usage-trend'; - const days = (res.byDay || []).slice().reverse(); - const peak = Math.max(1, ...days.map(row => dashboardUsageCount(row.totalTokens))); - days.forEach(row => { - const bar = document.createElement('div'); - bar.className = 'token-usage-day'; - const count = document.createElement('span'); - count.textContent = formatTokenUsageCompact(row.totalTokens); - const fill = document.createElement('div'); - fill.className = 'token-usage-day-bar'; - fill.style.height = Math.max(1, dashboardUsageCount(row.totalTokens) / peak * 90) + 'px'; - const date = document.createElement('span'); - date.textContent = String(row.key || '').slice(5); - bar.title = row.key + ': ' + exact(row.totalTokens); - bar.append(count, fill, date); - trend.append(bar); - }); - host.append(trendTitle, trend); - table('usageByDay', ['usageDate', ...fields], (res.byDay || []).map(row => [row.key, ...counts(row)])); - table('usageRecent', ['usageTime', 'usageModel', 'usageStatus', 'usageTotal', 'usageCalls'], (res.recent || []).map(row => { - const time = new Date(row.createdAt); - return [Number.isFinite(time.getTime()) ? time.toLocaleString(window.__locale || 'zh-CN') : '—', - row.model || '—', row.reason === 'final' ? label('usageFinal') : row.reason === 'partial' ? label('usagePartial') : (row.reason || '—'), exact(row.totalTokens), exact(row.modelCalls)]; - })); -} diff --git a/web/static/js/router.js b/web/static/js/router.js index ddb3955d..b98c613c 100644 --- a/web/static/js/router.js +++ b/web/static/js/router.js @@ -1,9 +1,9 @@ // 页面路由管理 let currentPage = null; -/** chat、漏洞管理页在切换时保留当前 hash 上的查询串(如 ?conversation= / ?conversation_id=) */ +/** 对话、漏洞管理和设置页切换时保留查询串(会话 ID 或设置分区)。 */ function buildHashForPage(pageId) { - if (pageId !== 'chat' && pageId !== 'vulnerabilities') { + if (pageId !== 'chat' && pageId !== 'vulnerabilities' && pageId !== 'settings') { return pageId; } const full = window.location.hash.slice(1); @@ -111,7 +111,7 @@ function initRouter() { let pageId = hashParts[0].replace(/^#?\//, ''); if (pageId === 'c2') pageId = 'c2-listeners'; // 一键更新已并进系统设置页的分区菜单:#system-update 这条老深链落到设置页的那一区。 - const settingsSection = pageId === 'system-update' ? 'update' : ''; + const settingsSection = pageId === 'system-update' ? 'update' : pageId === 'settings' && new URLSearchParams(hashParts.slice(1).join('?')).get('section') === 'token-usage' ? 'token-usage' : ''; if (settingsSection) pageId = 'settings'; if (pageId && ['dashboard', 'chat', 'hitl', 'tool-guard', 'asset-overview', 'asset-library', 'info-collect', 'projects', 'vulnerabilities', 'webshell', 'chat-files', 'mcp-monitor', 'mcp-management', 'knowledge-management', 'knowledge-retrieval-logs', 'roles-management', 'platform-rbac', 'workflows', 'skills-monitor', 'skills-management', 'agents-management', 'settings', 'tasks', 'c2-listeners', 'c2-sessions', 'c2-tasks', 'c2-payloads', 'c2-events', 'c2-profiles'].includes(pageId)) { switchPage(pageId); @@ -541,6 +541,12 @@ async function initPage(pageId) { if (updateSection && updateSection.classList.contains('active') && typeof loadUpdateConsole === 'function') { loadUpdateConsole(); } + // Returning through the regular settings nav reloads a retained usage panel. + // Deep links are loaded by switchSettingsSection after switchPage returns. + const tokenSection = document.getElementById('settings-section-token-usage'); + if (tokenSection && tokenSection.classList.contains('active') && window.location.hash !== '#settings?section=token-usage' && typeof refreshTokenUsage === 'function') { + refreshTokenUsage(); + } break; case 'roles-management': // 初始化角色管理页面 @@ -622,6 +628,9 @@ async function initPage(pageId) { if (pageId !== 'settings' && typeof stopUpdatePolling === 'function') { stopUpdatePolling(); } + if (pageId !== 'settings' && typeof stopTokenUsageRequests === 'function') { + stopTokenUsageRequests(); + } } // 页面加载完成后初始化路由 @@ -639,7 +648,7 @@ document.addEventListener('DOMContentLoaded', function() { if (pageId === 'c2') pageId = 'c2-listeners'; // 与首屏解析同一套别名:#system-update 落到系统设置页的「一键更新」分区。 - const settingsSection = pageId === 'system-update' ? 'update' : ''; + const settingsSection = pageId === 'system-update' ? 'update' : pageId === 'settings' && new URLSearchParams(hashParts.slice(1).join('?')).get('section') === 'token-usage' ? 'token-usage' : ''; if (settingsSection) pageId = 'settings'; if (pageId && ['dashboard', 'chat', 'hitl', 'tool-guard', 'asset-overview', 'asset-library', 'info-collect', 'projects', 'tasks', 'workflows', 'vulnerabilities', 'webshell', 'chat-files', 'mcp-monitor', 'mcp-management', 'knowledge-management', 'knowledge-retrieval-logs', 'roles-management', 'platform-rbac', 'skills-monitor', 'skills-management', 'agents-management', 'settings', 'c2-listeners', 'c2-sessions', 'c2-tasks', 'c2-payloads', 'c2-events', 'c2-profiles'].includes(pageId)) { switchPage(pageId); diff --git a/web/static/js/settings.js b/web/static/js/settings.js index 9e371efd..0eb2eab9 100644 --- a/web/static/js/settings.js +++ b/web/static/js/settings.js @@ -661,6 +661,13 @@ function switchSettingsSection(section) { return; } + // Unknown hash sections must not hide every settings panel. + const requestedContent = document.getElementById(`settings-section-${section}`); + if (!requestedContent) return; + if (window.location.hash.split('?')[0] === '#settings') { + history.replaceState(null, '', section === 'token-usage' ? '#settings?section=token-usage' : '#settings'); + } + // 更新导航项状态 document.querySelectorAll('.settings-nav-item').forEach(item => { item.classList.remove('active'); @@ -678,6 +685,11 @@ function switchSettingsSection(section) { if (activeContent) { activeContent.classList.add('active'); initSettingsCustomSelects(activeContent); + } + if (section === 'token-usage' && typeof refreshTokenUsage === 'function') { + refreshTokenUsage(); + } else if (typeof stopTokenUsageRequests === 'function') { + stopTokenUsageRequests(); } if (section === 'robots' && typeof window.loadVulnerabilityAlertSubscription === 'function') { window.loadVulnerabilityAlertSubscription(); diff --git a/web/static/js/token-usage-analysis.test.cjs b/web/static/js/token-usage-analysis.test.cjs index a6c61896..de24409d 100644 --- a/web/static/js/token-usage-analysis.test.cjs +++ b/web/static/js/token-usage-analysis.test.cjs @@ -2,41 +2,79 @@ const fs = require('node:fs'); const vm = require('node:vm'); const test = require('node:test'); const assert = require('node:assert/strict'); -const source = fs.readFileSync('web/static/js/dashboard.js', 'utf8'); +const source = fs.readFileSync('web/static/js/token-usage.js', 'utf8'); const harness = () => { - const context = { document: {}, dashboardState: { tokenDays: 7 }, refreshDashboard() {} }; + const context = { document: { addEventListener() {} }, window: {}, AbortController }; vm.createContext(context); - vm.runInContext(source.slice(source.indexOf('function openDashboardTokenUsage()')), context); + vm.runInContext(source, context); return context; }; test('cache and reasoning details do not inflate total or double-count input', () => { const ctx = harness(); - const metrics = ctx.dashboardUsageMetrics({ totalTokens: 120, promptTokens: 100, completionTokens: 20, cachedTokens: 40, cacheWriteTokens: 30, reasoningTokens: 10, modelCalls: 1 }); + const metrics = ctx.tokenUsageMetrics({ totalTokens: 120, promptTokens: 100, completionTokens: 20, cachedTokens: 40, cacheWriteTokens: 30, reasoningTokens: 10, modelCalls: 1 }); const values = Object.fromEntries(metrics); assert.equal(values.usageTotal, 120); assert.equal(values.usageCacheWrite, 30); assert.equal(values.usageCacheRate, '40.0%'); - assert.equal(Object.fromEntries(ctx.dashboardUsageMetrics({})).usageCacheRate, '—'); - assert.equal(ctx.dashboardUsageCount(Infinity), 0); - assert.equal(ctx.dashboardUsageCount(-12), 0); + assert.equal(Object.fromEntries(ctx.tokenUsageMetrics({})).usageCacheRate, '—'); + assert.equal(ctx.tokenUsageCount(Infinity), 0); + assert.equal(ctx.tokenUsageCount(-12), 0); }); test('period selection refreshes once and rejects arbitrary periods', () => { const ctx = harness(); let calls = 0; - ctx.refreshDashboard = () => { calls++; }; + ctx.refreshTokenUsage = () => { calls++; }; ctx.document.getElementById = () => ({ textContent: '' }); - ctx.changeDashboardTokenPeriod('30'); - ctx.changeDashboardTokenPeriod('999'); - assert.equal(ctx.dashboardState.tokenDays, 30); + ctx.changeTokenUsagePeriod('30'); + ctx.changeTokenUsagePeriod('999'); + assert.equal(ctx.tokenUsageState.days, 30); assert.equal(calls, 1); }); +test('cache hit ratio uses input tokens, clamps invalid detail and handles missing input', () => { + const ctx = harness(); + assert.equal(ctx.tokenUsageCacheRate({ promptTokens: 1000, cachedTokens: 930, totalTokens: 1100 }), '93.0%'); + assert.equal(ctx.tokenUsageCacheRate({ promptTokens: 1000, cachedTokens: 0 }), '0.0%'); + assert.equal(ctx.tokenUsageCacheRate({ promptTokens: 10, cachedTokens: 30 }), '100.0%'); + assert.equal(ctx.tokenUsageCacheRate({ promptTokens: 0, cachedTokens: 30 }), '—'); + assert.equal(ctx.tokenUsageCacheRate(null), '—'); +}); + +test('detail requests stay project scoped and discard late results after a period change', async () => { + const ctx = harness(); + const host = { setAttribute() {}, removeAttribute() {}, textContent: '' }; + ctx.document.getElementById = id => id === 'settings-token-analysis' ? host : null; + ctx.getActiveProjectId = () => 'project / sample'; + const requests = []; + ctx.apiFetch = (url, options) => new Promise(resolve => requests.push({ url, options, resolve })); + const rendered = []; + ctx.renderTokenUsageAnalysis = value => rendered.push(value); + const first = ctx.refreshTokenUsage(); + ctx.tokenUsageState.days = 30; + const second = ctx.refreshTokenUsage(); + assert.ok(requests[0].options.signal.aborted); + assert.match(requests[1].url, /days=30.*project_id=project%20%2F%20sample/); + requests[1].resolve({ ok: true, json: async () => ({ summary: { totalTokens: 30 } }) }); + await second; + requests[0].resolve({ ok: true, json: async () => ({ summary: { totalTokens: 7 } }) }); + await first; + assert.equal(rendered.length, 1); + assert.equal(rendered[0].summary.totalTokens, 30); + const pending = ctx.refreshTokenUsage(); + ctx.stopTokenUsageRequests(); + requests[2].resolve({ ok: true, json: async () => ({ summary: { totalTokens: 90 } }) }); + await pending; + assert.equal(rendered.length, 1, 'leaving the section must invalidate outstanding results'); +}); + test('all supported languages expose usage labels and interpolation placeholders', () => { for (const locale of ['zh-CN', 'en-US', 'ru-RU']) { const data = JSON.parse(fs.readFileSync(`web/static/i18n/${locale}.json`, 'utf8')); - for (const key of ['usageView', 'usageScope', 'usageCacheRead', 'usageCacheWrite', 'usageTrend']) assert.ok(data.dashboard[key]); + for (const key of ['usageView', 'usageScope', 'usageCacheRead', 'usageCacheWrite', 'usageTrend', 'usageCacheHitRate']) assert.ok(data.dashboard[key]); + assert.ok(data.settings.nav.tokenUsage); + assert.match(data.dashboard.usageCacheHitRate, /\{\{rate\}\}/); assert.match(data.dashboard.tokenUsageSub, /\{\{days\}\}/); assert.match(data.chat.turnTokenUsageTitle, /\{\{cacheWrite\}\}/); } diff --git a/web/static/js/token-usage.js b/web/static/js/token-usage.js new file mode 100644 index 00000000..34a2da73 --- /dev/null +++ b/web/static/js/token-usage.js @@ -0,0 +1,197 @@ +// Token detail data belongs to the settings section; the dashboard keeps a +// separate fixed seven-day summary. +var tokenUsageState = { days: 7, controller: null, requestSequence: 0, result: null }; + +function tokenUsageText(key, opts, fallback) { + if (typeof window.t === 'function') { + const text = window.t(key, opts); + if (text && text !== key) return text; + } + return fallback == null ? key : fallback; +} + +function openDashboardTokenUsage() { + if (window.location.hash === '#settings?section=token-usage') { + switchPage('settings'); + switchSettingsSection('token-usage'); + } else { + window.location.hash = 'settings?section=token-usage'; + } +} + +function stopTokenUsageRequests() { + tokenUsageState.requestSequence++; + if (tokenUsageState.controller) tokenUsageState.controller.abort(); + tokenUsageState.controller = null; +} + +async function refreshTokenUsage() { + const host = document.getElementById('settings-token-analysis'); + if (!host) return; + stopTokenUsageRequests(); + const sequence = tokenUsageState.requestSequence; + const controller = new AbortController(); + tokenUsageState.controller = controller; + host.setAttribute('aria-busy', 'true'); + host.textContent = tokenUsageText('common.loading', null, '加载中…'); + const select = document.getElementById('settings-token-days'); + if (select) select.value = String(tokenUsageState.days); + let url = '/api/usage/tokens?days=' + tokenUsageState.days + '&limit=20'; + const projectId = typeof getActiveProjectId === 'function' ? getActiveProjectId() : ''; + if (projectId) url += '&project_id=' + encodeURIComponent(projectId); + try { + const response = await apiFetch(url, { signal: controller.signal }); + const result = response && response.ok ? await response.json() : null; + if (sequence !== tokenUsageState.requestSequence) return; + tokenUsageState.result = result; + renderTokenUsageAnalysis(result); + } catch (_) { + if (sequence !== tokenUsageState.requestSequence) return; + tokenUsageState.result = null; + renderTokenUsageAnalysis(null); + } finally { + if (sequence === tokenUsageState.requestSequence) { + tokenUsageState.controller = null; + host.removeAttribute('aria-busy'); + } + } +} + +function changeTokenUsagePeriod(value) { + const days = Number(value); + if (![7, 30, 90].includes(days)) return; + tokenUsageState.days = days; + refreshTokenUsage(); +} + +function tokenUsageCacheRate(summary) { + const input = tokenUsageCount(summary && summary.promptTokens); + const cached = tokenUsageCount(summary && summary.cachedTokens); + return input > 0 ? (Math.min(cached, input) / input * 100).toFixed(1) + '%' : '—'; +} + +function tokenUsageCount(value) { + const n = Number(value); + return Number.isFinite(n) && n > 0 ? Math.trunc(n) : 0; +} + +function tokenUsageMetrics(summary) { + const input = tokenUsageCount(summary.promptTokens); + const cached = tokenUsageCount(summary.cachedTokens); + return [ + ['usageTotal', summary.totalTokens], + ['usageInput', input], + ['usageOutput', summary.completionTokens], + ['usageCacheRead', cached], + ['usageCacheWrite', summary.cacheWriteTokens], + ['usageReasoning', summary.reasoningTokens], + ['usageCalls', summary.modelCalls], + ['usageCacheRate', tokenUsageCacheRate(summary)] + ]; +} + +document.addEventListener('languagechange', function () { + if (tokenUsageState.controller) return; + const page = document.getElementById('page-settings'); + const section = document.getElementById('settings-section-token-usage'); + if (page && page.classList.contains('active') && section && section.classList.contains('active')) { + renderTokenUsageAnalysis(tokenUsageState.result); + } +}); + +function renderTokenUsageAnalysis(res) { + const host = document.getElementById('settings-token-analysis'); + if (!host) return; + host.replaceChildren(); + if (!res || !res.summary) { + host.textContent = tokenUsageText('dashboard.usageUnavailable', null, '用量统计暂不可用'); + return; + } + const exact = value => tokenUsageCount(value).toLocaleString(window.__locale || 'zh-CN'); + const label = key => tokenUsageText('dashboard.' + key, null, key); + const metrics = document.createElement('div'); + metrics.className = 'token-usage-metrics'; + tokenUsageMetrics(res.summary).forEach(([key, value]) => { + const card = document.createElement('div'); + const title = document.createElement('span'); + title.textContent = label(key); + const count = document.createElement('strong'); + count.textContent = typeof value === 'string' ? value : exact(value); + card.append(title, count); + metrics.append(card); + }); + host.append(metrics); + if (!tokenUsageCount(res.summary.events)) { + const empty = document.createElement('p'); + empty.textContent = tokenUsageText('dashboard.noTokenUsageYet', null, '暂无用量'); + host.append(empty); + return; + } + // Tables use text nodes: provider model names and conversation IDs are untrusted. + const table = (titleKey, headings, rows) => { + const section = document.createElement('section'); + const title = document.createElement('h4'); + title.textContent = label(titleKey); + const wrap = document.createElement('div'); + wrap.className = 'token-usage-table-wrap'; + const grid = document.createElement('table'); + const caption = document.createElement('caption'); + caption.className = 'sr-only'; + caption.textContent = title.textContent; + const head = document.createElement('thead'); + const hr = document.createElement('tr'); + headings.forEach(key => { + const th = document.createElement('th'); + th.scope = 'col'; + th.textContent = label(key); + hr.append(th); + }); + head.append(hr); + const body = document.createElement('tbody'); + rows.forEach(values => { + const tr = document.createElement('tr'); + values.forEach(value => { + const td = document.createElement('td'); + td.textContent = String(value == null ? '' : value); + tr.append(td); + }); + body.append(tr); + }); + grid.append(caption, head, body); + wrap.append(grid); + section.append(title, wrap); + host.append(section); + }; + const counts = row => [exact(row.promptTokens), exact(row.completionTokens), exact(row.cachedTokens), + exact(row.cacheWriteTokens), exact(row.reasoningTokens), exact(row.totalTokens), exact(row.modelCalls)]; + const fields = ['usageInput', 'usageOutput', 'usageCacheRead', 'usageCacheWrite', 'usageReasoning', 'usageTotal', 'usageCalls']; + table('usageByModel', ['usageModel', 'usageShare', ...fields], (res.byModel || []).map(row => [row.label || row.key, + tokenUsageCount(res.summary.totalTokens) > 0 ? (tokenUsageCount(row.totalTokens) / tokenUsageCount(res.summary.totalTokens) * 100).toFixed(1) + '%' : '—', ...counts(row)])); + const trendTitle = document.createElement('h4'); + trendTitle.textContent = label('usageTrend'); + const trend = document.createElement('div'); + trend.className = 'token-usage-trend'; + const days = (res.byDay || []).slice().reverse(); + const peak = Math.max(1, ...days.map(row => tokenUsageCount(row.totalTokens))); + days.forEach(row => { + const bar = document.createElement('div'); + bar.className = 'token-usage-day'; + const count = document.createElement('span'); + count.textContent = formatTokenUsageCompact(row.totalTokens); + const fill = document.createElement('div'); + fill.className = 'token-usage-day-bar'; + fill.style.height = Math.max(1, tokenUsageCount(row.totalTokens) / peak * 90) + 'px'; + const date = document.createElement('span'); + date.textContent = String(row.key || '').slice(5); + bar.title = row.key + ': ' + exact(row.totalTokens); + bar.append(count, fill, date); + trend.append(bar); + }); + host.append(trendTitle, trend); + table('usageByDay', ['usageDate', ...fields], (res.byDay || []).map(row => [row.key, ...counts(row)])); + table('usageRecent', ['usageTime', 'usageModel', 'usageStatus', 'usageTotal', 'usageCalls'], (res.recent || []).map(row => { + const time = new Date(row.createdAt); + return [Number.isFinite(time.getTime()) ? time.toLocaleString(window.__locale || 'zh-CN') : '—', + row.model || '—', row.reason === 'final' ? label('usageFinal') : row.reason === 'partial' ? label('usagePartial') : (row.reason || '—'), exact(row.totalTokens), exact(row.modelCalls)]; + })); +} diff --git a/web/static/js/update-ui.test.cjs b/web/static/js/update-ui.test.cjs index a3bf65b4..1e42d24b 100644 --- a/web/static/js/update-ui.test.cjs +++ b/web/static/js/update-ui.test.cjs @@ -435,7 +435,7 @@ test('the console lives inside the settings page as its own section', () => { // Both hash entry points carry the alias, and the retired page id is gone from the whitelists: // #system-update has to land on 系统设置 with its update section selected. - const aliases = [...router.matchAll(/const settingsSection = pageId === 'system-update' \? 'update' : '';/g)]; + const aliases = [...router.matchAll(/const settingsSection = pageId === 'system-update' \? 'update' : /g)]; assert.equal(aliases.length, 2, 'both hash parsers must normalize #system-update into the settings section'); const selects = [...router.matchAll(/if \(settingsSection && typeof switchSettingsSection === 'function'\) \{[\s\S]{0,80}?switchSettingsSection\(settingsSection\);/g)]; assert.equal(selects.length, 2, 'both hash paths must open the update section after switching the page'); diff --git a/web/templates/index.html b/web/templates/index.html index 3ced8a4a..ad0c85d9 100644 --- a/web/templates/index.html +++ b/web/templates/index.html @@ -39,14 +39,14 @@ } })(); - + - +
-
-
+
- + -
-
- 查看用量详情 -
- - -
-

统计已记录的 Agent 运行中模型返回的用量,包括子 Agent。缓存读取和写入属于输入,推理属于输出,不重复加入总量。未返回或未采集的用量不在统计内;这不是供应商账单。模型按运行配置归类,切换模型的运行可能混合用量。

-
-
@@ -3615,6 +3603,9 @@
基本设置
+
+ Token 用量 +
人机协同
@@ -3650,6 +3641,23 @@
+
+
+

Token 用量

+ +
+
+ + +
+

统计已记录的 Agent 运行中模型返回的用量,包括子 Agent。缓存读取和写入属于输入,推理属于输出,不重复加入总量。未返回或未采集的用量不在统计内;这不是供应商账单。模型按运行配置归类,切换模型的运行可能混合用量。

+
+
+
@@ -7009,14 +7017,15 @@ - + - + + From 5d14daf486e277dfcb2075864cd56b73a1837f8e Mon Sep 17 00:00:00 2001 From: Sycun <165354365+Sycun@users.noreply.github.com> Date: Sun, 11 Oct 2026 23:58:41 +0800 Subject: [PATCH 3/5] style: compact dashboard token usage subtitle --- docs/zh-CN/token-usage-monitoring.md | 2 +- web/static/css/style.css | 2 -- web/static/i18n/en-US.json | 2 +- web/static/i18n/ru-RU.json | 2 +- web/static/i18n/zh-CN.json | 2 +- web/static/js/dashboard.js | 10 ++-------- web/static/js/token-usage-analysis.test.cjs | 3 ++- web/templates/index.html | 7 +++---- 8 files changed, 11 insertions(+), 19 deletions(-) diff --git a/docs/zh-CN/token-usage-monitoring.md b/docs/zh-CN/token-usage-monitoring.md index 98247821..555bd7b8 100644 --- a/docs/zh-CN/token-usage-monitoring.md +++ b/docs/zh-CN/token-usage-monitoring.md @@ -29,7 +29,7 @@ DeepSeek 的未命中输入不等于 Claude 的缓存写入。不能根据文本 ## 功能入口与数据流 -点击首页「Token 用量」卡片进入「系统设置 → Token 用量」,也可直接从设置二级菜单打开。首页卡片显示近 7 天调用次数、今日用量与缓存命中率(缓存读取 Token / 输入 Token)。支持当前项目范围、7/30/90 天切换、八项指标、最多 20 项运行模型排行、每日趋势与完整日明细、最多 20 项最近运行。详情页独立加载,支持手动刷新与请求取消,切换周期不会刷新整个仪表盘;刷新页面可保持该设置分区。 +点击首页「Token 用量」卡片进入「系统设置 → Token 用量」,也可直接从设置二级菜单打开。首页卡片显示近 7 天总用量,副标仅用一行显示今日用量与缓存命中率(缓存读取 Token / 输入 Token),调用次数保留在详情页。支持当前项目范围、7/30/90 天切换、八项指标、最多 20 项运行模型排行、每日趋势与完整日明细、最多 20 项最近运行。详情页独立加载,支持手动刷新与请求取消,切换周期不会刷新整个仪表盘;刷新页面可保持该设置分区。 运行事件 → 流式/非流式用量累加 → `eino_usage_summary` → `process_details` 与 `model_token_usage` → 现有 `/api/usage/tokens`、对话用量接口 → 首页与聊天摘要。 diff --git a/web/static/css/style.css b/web/static/css/style.css index 7b1576ac..3258149f 100644 --- a/web/static/css/style.css +++ b/web/static/css/style.css @@ -47221,8 +47221,6 @@ html[data-theme="dark"] { --tool-blocked-color: #f4bc62; } } /* Provider-reported token analysis, scoped by the dashboard project selector. */ -.dashboard-kpi-sub--tokens { row-gap: 2px; } -#dashboard-kpi-token-cache-text { flex-basis: 100%; } .token-usage-panel { margin-bottom: 20px; padding: 18px 22px; } .token-usage-header { display: flex; align-items: center; justify-content: space-between; gap: 12px; } .token-usage-toolbar { display: flex; align-items: center; gap: 12px; margin-top: 18px; } diff --git a/web/static/i18n/en-US.json b/web/static/i18n/en-US.json index 9f3f913c..4a3ad926 100644 --- a/web/static/i18n/en-US.json +++ b/web/static/i18n/en-US.json @@ -288,7 +288,7 @@ "successRate": "Tool success rate", "tokenUsage": "Token usage", "usageCacheHitRate": "Cache hit {{rate}}", - "tokenUsageSub": "Last {{days}} days: {{calls}} calls · Today {{today}}", + "tokenUsageSub": "Today {{today}} · Cache hit {{rate}}", "noTokenUsageYet": "No usage yet", "clickToViewTasks": "Click to view tasks", "clickToViewChat": "Click to view conversations", diff --git a/web/static/i18n/ru-RU.json b/web/static/i18n/ru-RU.json index 667c550e..d060b292 100644 --- a/web/static/i18n/ru-RU.json +++ b/web/static/i18n/ru-RU.json @@ -288,7 +288,7 @@ "successRate": "Успешность инструментов", "tokenUsage": "Расход токенов", "usageCacheHitRate": "Попадания в кэш {{rate}}", - "tokenUsageSub": "За {{days}} дней {{calls}} вызовов · сегодня {{today}}", + "tokenUsageSub": "Сегодня {{today}} · Кэш {{rate}}", "noTokenUsageYet": "Расхода пока нет", "clickToViewTasks": "Открыть задачи", "clickToViewChat": "Открыть диалоги", diff --git a/web/static/i18n/zh-CN.json b/web/static/i18n/zh-CN.json index a758dbd3..77ed40c6 100644 --- a/web/static/i18n/zh-CN.json +++ b/web/static/i18n/zh-CN.json @@ -288,7 +288,7 @@ "successRate": "工具执行成功率", "tokenUsage": "Token 用量", "usageCacheHitRate": "缓存命中 {{rate}}", - "tokenUsageSub": "近 {{days}} 天 {{calls}} 次调用 · 今日 {{today}}", + "tokenUsageSub": "今日 {{today}} · 缓存命中 {{rate}}", "noTokenUsageYet": "暂无用量", "clickToViewTasks": "点击查看任务管理", "clickToViewChat": "点击查看对话", diff --git a/web/static/js/dashboard.js b/web/static/js/dashboard.js index eec890d3..5ad07c76 100644 --- a/web/static/js/dashboard.js +++ b/web/static/js/dashboard.js @@ -72,7 +72,6 @@ async function refreshDashboard() { setKpiSubText('dashboard-kpi-tools-sub-text', '…'); setKpiSubText('dashboard-kpi-rate-sub-text', '…'); setKpiSubText('dashboard-kpi-token-sub-text', '…'); - setKpiSubText('dashboard-kpi-token-cache-text', '…'); hideEl('dashboard-kpi-vuln-critical-badge'); hideEl('dashboard-alert-banner'); setRecentVulnsLoading(); @@ -443,7 +442,6 @@ async function refreshDashboard() { setEl('dashboard-kpi-tools-calls', '-'); setEl('dashboard-kpi-token-usage', '-'); setKpiSubText('dashboard-kpi-token-sub-text', '-'); - setKpiSubText('dashboard-kpi-token-cache-text', '-'); setKpiSubText('dashboard-kpi-tasks-sub-text', '-'); setKpiSubText('dashboard-kpi-vuln-sub-text', '-'); setKpiSubText('dashboard-kpi-tools-sub-text', '-'); @@ -715,13 +713,10 @@ function renderDashboardTokenUsage(res) { if (!summary) { setEl('dashboard-kpi-token-usage', '-'); setKpiSubText('dashboard-kpi-token-sub-text', '-'); - setKpiSubText('dashboard-kpi-token-cache-text', '-'); return; } const cacheRate = tokenUsageCacheRate(summary); - setKpiSubText('dashboard-kpi-token-cache-text', dt('dashboard.usageCacheHitRate', { rate: cacheRate }, '缓存命中 ' + cacheRate)); const total = Number(summary.totalTokens || 0); - const calls = Number(summary.modelCalls || 0); const today = res && res.today ? Number(res.today.totalTokens || 0) : 0; if (!Number.isFinite(total) || total <= 0) { setEl('dashboard-kpi-token-usage', '0'); @@ -731,10 +726,9 @@ function renderDashboardTokenUsage(res) { setEl('dashboard-kpi-token-usage', formatTokenUsageCompact(total)); setKpiSubText('dashboard-kpi-token-sub-text', dt('dashboard.tokenUsageSub', { - days: 7, today: formatTokenUsageCompact(today), - calls: Number.isFinite(calls) ? calls : 0 - }, '近 7 天 ' + (Number.isFinite(calls) ? calls : 0) + ' 次调用 · 今日 ' + formatTokenUsageCompact(today))); + rate: cacheRate + }, '今日 ' + formatTokenUsageCompact(today) + ' · 缓存命中 ' + cacheRate)); } function formatTokenUsageCompact(num) { diff --git a/web/static/js/token-usage-analysis.test.cjs b/web/static/js/token-usage-analysis.test.cjs index de24409d..9a39a482 100644 --- a/web/static/js/token-usage-analysis.test.cjs +++ b/web/static/js/token-usage-analysis.test.cjs @@ -75,7 +75,8 @@ test('all supported languages expose usage labels and interpolation placeholders for (const key of ['usageView', 'usageScope', 'usageCacheRead', 'usageCacheWrite', 'usageTrend', 'usageCacheHitRate']) assert.ok(data.dashboard[key]); assert.ok(data.settings.nav.tokenUsage); assert.match(data.dashboard.usageCacheHitRate, /\{\{rate\}\}/); - assert.match(data.dashboard.tokenUsageSub, /\{\{days\}\}/); + assert.match(data.dashboard.tokenUsageSub, /\{\{today\}\}/); + assert.match(data.dashboard.tokenUsageSub, /\{\{rate\}\}/); assert.match(data.chat.turnTokenUsageTitle, /\{\{cacheWrite\}\}/); } }); diff --git a/web/templates/index.html b/web/templates/index.html index ad0c85d9..949bc0f6 100644 --- a/web/templates/index.html +++ b/web/templates/index.html @@ -39,7 +39,7 @@ } })(); - + @@ -562,9 +562,8 @@
-
-
+
- - -
@@ -7017,7 +7016,7 @@ - + From 4badef67c12cc91b3285a72401e54aadafd8eef8 Mon Sep 17 00:00:00 2001 From: Sycun <165354365+Sycun@users.noreply.github.com> Date: Mon, 12 Oct 2026 00:00:26 +0800 Subject: [PATCH 4/5] style: show only cache hit rate below dashboard token total --- docs/zh-CN/token-usage-monitoring.md | 2 +- web/static/i18n/en-US.json | 2 +- web/static/i18n/ru-RU.json | 2 +- web/static/i18n/zh-CN.json | 2 +- web/static/js/dashboard.js | 4 +--- web/static/js/token-usage-analysis.test.cjs | 1 - web/templates/index.html | 2 +- 7 files changed, 6 insertions(+), 9 deletions(-) diff --git a/docs/zh-CN/token-usage-monitoring.md b/docs/zh-CN/token-usage-monitoring.md index 555bd7b8..80458a69 100644 --- a/docs/zh-CN/token-usage-monitoring.md +++ b/docs/zh-CN/token-usage-monitoring.md @@ -29,7 +29,7 @@ DeepSeek 的未命中输入不等于 Claude 的缓存写入。不能根据文本 ## 功能入口与数据流 -点击首页「Token 用量」卡片进入「系统设置 → Token 用量」,也可直接从设置二级菜单打开。首页卡片显示近 7 天总用量,副标仅用一行显示今日用量与缓存命中率(缓存读取 Token / 输入 Token),调用次数保留在详情页。支持当前项目范围、7/30/90 天切换、八项指标、最多 20 项运行模型排行、每日趋势与完整日明细、最多 20 项最近运行。详情页独立加载,支持手动刷新与请求取消,切换周期不会刷新整个仪表盘;刷新页面可保持该设置分区。 +点击首页「Token 用量」卡片进入「系统设置 → Token 用量」,也可直接从设置二级菜单打开。首页卡片显示近 7 天总用量,副标仅显示缓存命中率(缓存读取 Token / 输入 Token),调用次数和每日用量保留在详情页。支持当前项目范围、7/30/90 天切换、八项指标、最多 20 项运行模型排行、每日趋势与完整日明细、最多 20 项最近运行。详情页独立加载,支持手动刷新与请求取消,切换周期不会刷新整个仪表盘;刷新页面可保持该设置分区。 运行事件 → 流式/非流式用量累加 → `eino_usage_summary` → `process_details` 与 `model_token_usage` → 现有 `/api/usage/tokens`、对话用量接口 → 首页与聊天摘要。 diff --git a/web/static/i18n/en-US.json b/web/static/i18n/en-US.json index 4a3ad926..932ccf16 100644 --- a/web/static/i18n/en-US.json +++ b/web/static/i18n/en-US.json @@ -288,7 +288,7 @@ "successRate": "Tool success rate", "tokenUsage": "Token usage", "usageCacheHitRate": "Cache hit {{rate}}", - "tokenUsageSub": "Today {{today}} · Cache hit {{rate}}", + "tokenUsageSub": "Cache hit {{rate}}", "noTokenUsageYet": "No usage yet", "clickToViewTasks": "Click to view tasks", "clickToViewChat": "Click to view conversations", diff --git a/web/static/i18n/ru-RU.json b/web/static/i18n/ru-RU.json index d060b292..9e833896 100644 --- a/web/static/i18n/ru-RU.json +++ b/web/static/i18n/ru-RU.json @@ -288,7 +288,7 @@ "successRate": "Успешность инструментов", "tokenUsage": "Расход токенов", "usageCacheHitRate": "Попадания в кэш {{rate}}", - "tokenUsageSub": "Сегодня {{today}} · Кэш {{rate}}", + "tokenUsageSub": "Кэш {{rate}}", "noTokenUsageYet": "Расхода пока нет", "clickToViewTasks": "Открыть задачи", "clickToViewChat": "Открыть диалоги", diff --git a/web/static/i18n/zh-CN.json b/web/static/i18n/zh-CN.json index 77ed40c6..e53ee222 100644 --- a/web/static/i18n/zh-CN.json +++ b/web/static/i18n/zh-CN.json @@ -288,7 +288,7 @@ "successRate": "工具执行成功率", "tokenUsage": "Token 用量", "usageCacheHitRate": "缓存命中 {{rate}}", - "tokenUsageSub": "今日 {{today}} · 缓存命中 {{rate}}", + "tokenUsageSub": "缓存命中 {{rate}}", "noTokenUsageYet": "暂无用量", "clickToViewTasks": "点击查看任务管理", "clickToViewChat": "点击查看对话", diff --git a/web/static/js/dashboard.js b/web/static/js/dashboard.js index 5ad07c76..50f8d207 100644 --- a/web/static/js/dashboard.js +++ b/web/static/js/dashboard.js @@ -717,7 +717,6 @@ function renderDashboardTokenUsage(res) { } const cacheRate = tokenUsageCacheRate(summary); const total = Number(summary.totalTokens || 0); - const today = res && res.today ? Number(res.today.totalTokens || 0) : 0; if (!Number.isFinite(total) || total <= 0) { setEl('dashboard-kpi-token-usage', '0'); setKpiSubText('dashboard-kpi-token-sub-text', dt('dashboard.noTokenUsageYet', null, '暂无用量')); @@ -726,9 +725,8 @@ function renderDashboardTokenUsage(res) { setEl('dashboard-kpi-token-usage', formatTokenUsageCompact(total)); setKpiSubText('dashboard-kpi-token-sub-text', dt('dashboard.tokenUsageSub', { - today: formatTokenUsageCompact(today), rate: cacheRate - }, '今日 ' + formatTokenUsageCompact(today) + ' · 缓存命中 ' + cacheRate)); + }, '缓存命中 ' + cacheRate)); } function formatTokenUsageCompact(num) { diff --git a/web/static/js/token-usage-analysis.test.cjs b/web/static/js/token-usage-analysis.test.cjs index 9a39a482..86b98ddb 100644 --- a/web/static/js/token-usage-analysis.test.cjs +++ b/web/static/js/token-usage-analysis.test.cjs @@ -75,7 +75,6 @@ test('all supported languages expose usage labels and interpolation placeholders for (const key of ['usageView', 'usageScope', 'usageCacheRead', 'usageCacheWrite', 'usageTrend', 'usageCacheHitRate']) assert.ok(data.dashboard[key]); assert.ok(data.settings.nav.tokenUsage); assert.match(data.dashboard.usageCacheHitRate, /\{\{rate\}\}/); - assert.match(data.dashboard.tokenUsageSub, /\{\{today\}\}/); assert.match(data.dashboard.tokenUsageSub, /\{\{rate\}\}/); assert.match(data.chat.turnTokenUsageTitle, /\{\{cacheWrite\}\}/); } diff --git a/web/templates/index.html b/web/templates/index.html index 949bc0f6..147d3cee 100644 --- a/web/templates/index.html +++ b/web/templates/index.html @@ -7016,7 +7016,7 @@ - + From 56d29b7054dc2f6ff36cedc91723ce3ce458a438 Mon Sep 17 00:00:00 2001 From: Sycun <165354365+Sycun@users.noreply.github.com> Date: Mon, 12 Oct 2026 00:10:37 +0800 Subject: [PATCH 5/5] feat: refine token analytics with interactive trends and cache breakdown --- docs/zh-CN/token-usage-monitoring.md | 14 ++ web/static/css/style.css | 92 ++++++-- web/static/i18n/en-US.json | 13 ++ web/static/i18n/ru-RU.json | 13 ++ web/static/i18n/zh-CN.json | 13 ++ web/static/js/token-usage-analysis.test.cjs | 12 + web/static/js/token-usage.js | 238 ++++++++++++++------ web/templates/index.html | 11 +- 8 files changed, 310 insertions(+), 96 deletions(-) diff --git a/docs/zh-CN/token-usage-monitoring.md b/docs/zh-CN/token-usage-monitoring.md index 80458a69..9a60fd48 100644 --- a/docs/zh-CN/token-usage-monitoring.md +++ b/docs/zh-CN/token-usage-monitoring.md @@ -31,6 +31,20 @@ DeepSeek 的未命中输入不等于 Claude 的缓存写入。不能根据文本 点击首页「Token 用量」卡片进入「系统设置 → Token 用量」,也可直接从设置二级菜单打开。首页卡片显示近 7 天总用量,副标仅显示缓存命中率(缓存读取 Token / 输入 Token),调用次数和每日用量保留在详情页。支持当前项目范围、7/30/90 天切换、八项指标、最多 20 项运行模型排行、每日趋势与完整日明细、最多 20 项最近运行。详情页独立加载,支持手动刷新与请求取消,切换周期不会刷新整个仪表盘;刷新页面可保持该设置分区。 +详情页采用四项核心指标(总量、输入、输出、缓存读取占输入)与四项次级指标(调用、缓存读取、缓存写入、推理)的层级布局。趋势可切换总 Token、模型调用与缓存命中率,鼠标悬停或键盘聚焦柱形可查看日期与精确数字;切换图表指标不再请求 API。缓存环形图拆分缓存读取与其余输入,不把缓存或推理再次叠加到总量。模型明细展示占比条,每日数字明细默认折叠,最近运行区分完整与部分记录。统计口径可展开查看。 + +每日图表沿用 API 返回的服务端日期,只补齐已返回日期之间无记录的空档,不根据浏览器时区猜测查询终点。小屏图表和宽表在自己的区域内横向滚动。没有记录和无法获取统计分别展示空状态与不可用状态。 + +### UI 调研依据(2026-10-12) + +| 参考 | 采用的设计 | +| --- | --- | +| [DeepSeek Context Caching](https://api-docs.deepseek.com/guides/kv_cache/) | 使用其明确的缓存命中/未命中输入定义,设计缓存构成视图;比例按 Token 而非请求次数计算 | +| [LiteLLM Usage](https://docs.litellm.ai/docs/proxy/ui/chat_ui) | 周期选择、核心统计与使用日志分层;本项目保留已有数据范围,不新增无法准确计量的费用或成功率 | +| [Langfuse Custom Dashboards](https://langfuse.com/docs/metrics/features/custom-dashboards) | 将汇总、时间趋势、模型分布拆为独立可读区域,并让趋势指标可切换 | + +DeepSeek 和 GLM 控制台的完整页面本轮未能公开读取,因此本轮视觉设计以能核实的开源面板为依据。没有声称复刻它们当前登录后的界面。项目也没有新增供应商账单、预算或节省金额推算。 + 运行事件 → 流式/非流式用量累加 → `eino_usage_summary` → `process_details` 与 `model_token_usage` → 现有 `/api/usage/tokens`、对话用量接口 → 首页与聊天摘要。 新增 `model_token_usage.cache_write_tokens INTEGER NOT NULL DEFAULT 0`。启动时现有声明式 SQLite schema reconciliation 自动补列;旧数据库无需手工执行迁移。现有事件 ID 幂等写入、外键删除关联和历史回填继续生效。日分组按服务本地时区显示,与首页「今日」边界一致。 diff --git a/web/static/css/style.css b/web/static/css/style.css index 3258149f..a316b5d1 100644 --- a/web/static/css/style.css +++ b/web/static/css/style.css @@ -47220,25 +47220,79 @@ html[data-theme="dark"] { --tool-blocked-color: #f4bc62; } align-self: flex-start; } -/* Provider-reported token analysis, scoped by the dashboard project selector. */ -.token-usage-panel { margin-bottom: 20px; padding: 18px 22px; } -.token-usage-header { display: flex; align-items: center; justify-content: space-between; gap: 12px; } -.token-usage-toolbar { display: flex; align-items: center; gap: 12px; margin-top: 18px; } -.token-usage-toolbar label { white-space: nowrap; } -.token-usage-toolbar select { padding: 6px 10px; border: 1px solid var(--border-color, #dce2ea); border-radius: 6px; background: var(--bg-secondary, #fff); color: inherit; } -.token-usage-note { opacity: .72; font-size: 12px; line-height: 1.7; margin: 14px 0; } -.token-usage-metrics { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 10px; } -.token-usage-metrics > div { padding: 12px; border: 1px solid var(--border-color, #dce2ea); border-radius: 8px; min-width: 0; } -.token-usage-metrics span { display: block; font-size: 12px; opacity: .7; } -.token-usage-metrics strong { display: block; margin-top: 6px; font-size: 20px; overflow-wrap: anywhere; font-variant-numeric: tabular-nums; } +/* Provider-reported token analytics in System Settings. */ +.token-usage-panel { --token-cache-color: #0d9488; max-width: 1200px; margin: 0 auto 24px; } +html[data-theme="dark"] .token-usage-panel { --token-cache-color: #2dd4bf; } +.token-usage-header { display: flex; align-items: center; justify-content: space-between; gap: 16px; margin-bottom: 20px; } +.token-usage-subtitle { font-size: 13px; color: var(--text-secondary); margin: 8px 0 0; } +.token-usage-toolbar { display: flex; align-items: center; flex-wrap: wrap; gap: 12px; } +.token-usage-scope { font-size: 11px; padding: 4px 8px; border-radius: 6px; color: var(--text-secondary); background: var(--bg-secondary); } +.token-usage-toolbar label { white-space: nowrap; font-size: 13px; } +.token-usage-toolbar select { padding: 7px 12px; border: 1px solid var(--border-color); border-radius: 8px; background: var(--bg-primary); color: var(--text-primary); } +.token-usage-note { color: var(--text-secondary); font-size: 12px; line-height: 1.7; margin: 16px 0 20px; } +.token-usage-note summary { cursor: pointer; width: fit-content; } +.token-usage-note p { margin: 8px 0 0; max-width: 960px; } +.token-usage-metrics { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 12px; } +.token-usage-metric { min-width: 0; padding: 16px; border: 1px solid var(--border-color); border-radius: 12px; } +.token-usage-metric span { display: block; color: var(--text-secondary); font-size: 12px; } +.token-usage-metric strong { display: block; margin-top: 10px; font-size: clamp(20px, 1.65vw, 28px); font-variant-numeric: tabular-nums; overflow-wrap: anywhere; letter-spacing: -.025em; } +.token-usage-primary [data-metric="usageTotal"] { background: rgba(0, 102, 255, .05); border-color: rgba(0, 102, 255, .18); } +.token-usage-primary [data-metric="usageTotal"] strong { color: var(--accent-color); } +.token-usage-primary [data-metric="usageCacheRate"] strong { color: var(--token-cache-color); } +.token-usage-secondary { margin: 12px 0 24px; gap: 0; padding: 14px 0; border: 1px solid var(--border-color); border-radius: 12px; } +.token-usage-secondary .token-usage-metric { border: 0; border-radius: 0; padding: 0 16px; } +.token-usage-secondary .token-usage-metric + .token-usage-metric { border-left: 1px solid var(--border-color); } +.token-usage-secondary strong { margin-top: 6px; font-size: 18px; } +.token-usage-charts { display: grid; grid-template-columns: minmax(0, 2fr) minmax(235px, 1fr); gap: 16px; margin-bottom: 24px; } +.token-usage-chart-card, .token-usage-data-card { min-width: 0; border: 1px solid var(--border-color); border-radius: 12px; padding: 18px; } +.token-usage-panel h4 { margin: 0 0 16px; font-size: 14px; } +.token-usage-chart-head { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 12px; margin-bottom: 16px; } +.token-usage-chart-head h4 { margin: 0; } +.token-usage-chart-tabs { display: flex; flex-wrap: wrap; gap: 4px; } +.token-usage-chart-tabs button { cursor: pointer; border: 0; border-radius: 6px; padding: 6px 8px; font: inherit; font-size: 11px; color: var(--text-secondary); background: transparent; } +.token-usage-chart-tabs button[aria-pressed="true"] { color: var(--accent-color); background: rgba(0, 102, 255, .08); } +.token-usage-chart-tabs button:focus-visible { outline: 2px solid var(--accent-color); outline-offset: 2px; } +.token-usage-trend-svg { display: block; width: 100%; height: auto; overflow: visible; } +.token-usage-svg-wrap { overflow-x: auto; } +.token-usage-chart-grid { stroke: var(--border-color); stroke-dasharray: 3 4; } +.token-usage-chart-label { fill: var(--text-secondary); font-size: 11px; } +.token-usage-chart-tooltip { font-size: 11px; color: var(--text-secondary); line-height: 1.6; min-height: 35px; margin: 8px 0 0; } +.token-usage-chart-bar { fill: var(--accent-color); opacity: .8; } +.token-usage-chart-bar:hover, .token-usage-chart-bar:focus { opacity: 1; outline: none; stroke: var(--text-primary); stroke-width: 1; } +.token-usage-cache-ring { --cache-ratio: 0%; position: relative; width: 148px; height: 148px; margin: 8px auto 18px; border-radius: 50%; background: conic-gradient(var(--token-cache-color) var(--cache-ratio), var(--border-color) 0); display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 4px; } +.token-usage-cache-ring::before { content: ''; position: absolute; inset: 12px; border-radius: 50%; background: var(--bg-primary); } +.token-usage-cache-ring strong, .token-usage-cache-ring span { position: relative; } +.token-usage-cache-ring strong { font-size: 28px; font-variant-numeric: tabular-nums; } +.token-usage-cache-ring span { color: var(--text-secondary); font-size: 12px; } +.token-usage-cache-legend { display: flex; justify-content: space-between; gap: 10px; font-size: 12px; margin: 10px 0; } +.token-usage-cache-legend > span::before { content: ''; display: inline-block; width: 7px; height: 7px; border-radius: 2px; background: var(--border-color); margin-right: 7px; } +.token-usage-cache-legend.is-cached > span::before { background: var(--token-cache-color); } +.token-usage-cache-legend strong { font-variant-numeric: tabular-nums; } +.token-usage-chart-note { font-size: 11px; line-height: 1.6; color: var(--text-secondary); margin: 14px 0 0; } +.token-usage-data-card { margin-bottom: 16px; } +.token-usage-data-card > summary { cursor: pointer; font-size: 14px; font-weight: 600; } +.token-usage-data-card[open] > summary { margin-bottom: 16px; } .token-usage-table-wrap { overflow-x: auto; } .token-usage-table-wrap table { width: 100%; border-collapse: collapse; font-size: 12px; font-variant-numeric: tabular-nums; } -.token-usage-table-wrap th, .token-usage-table-wrap td { padding: 10px; text-align: right; border-bottom: 1px solid var(--border-color, #dce2ea); white-space: nowrap; } +.token-usage-table-wrap th, .token-usage-table-wrap td { padding: 12px 10px; text-align: right; border-bottom: 1px solid var(--border-color); white-space: nowrap; } +.token-usage-table-wrap th { font-weight: 500; color: var(--text-secondary); background: var(--bg-secondary); } .token-usage-table-wrap th:first-child, .token-usage-table-wrap td:first-child { text-align: left; } -.token-usage-table-wrap td:first-child { max-width: 280px; overflow: hidden; text-overflow: ellipsis; } -@media (max-width: 640px) { .token-usage-metrics { grid-template-columns: repeat(2, minmax(0, 1fr)); } .token-usage-panel { padding: 14px; } } -.token-usage-trend { display: flex; align-items: flex-end; gap: 8px; overflow-x: auto; padding: 12px 0; } -.token-usage-day { display: flex; flex-direction: column; align-items: center; justify-content: flex-end; flex: 1 0 38px; height: 128px; gap: 6px; font-size: 11px; } -.token-usage-day-bar { width: 70%; max-width: 48px; background: var(--accent-color, #3b82f6); border-radius: 4px 4px 0 0; } - -.token-usage-panel h4 { margin: 20px 0 8px; } +.token-usage-table-wrap td:first-child { max-width: 220px; overflow: hidden; text-overflow: ellipsis; } +.token-usage-table-wrap tbody tr:last-child td { border-bottom: 0; } +.token-usage-share { display: flex; align-items: center; justify-content: flex-end; gap: 8px; } +.token-usage-share-track { width: 48px; height: 4px; border-radius: 4px; background: var(--border-color); overflow: hidden; } +.token-usage-share-track > span { display: block; height: 100%; background: var(--accent-color); } +.token-usage-status { display: inline-block; padding: 3px 7px; border-radius: 5px; background: var(--bg-secondary); color: var(--text-secondary); } +.token-usage-status.is-final { background: rgba(13, 148, 136, .1); color: var(--token-cache-color); } +.token-usage-status.is-partial { background: rgba(217, 119, 6, .1); color: #d97706; } +.token-usage-empty { text-align: center; padding: 36px 16px; color: var(--text-secondary); font-size: 13px; } +@media (max-width: 1000px) { .token-usage-charts { grid-template-columns: 1fr; } } +@media (max-width: 640px) { + .token-usage-metrics { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; } + .token-usage-metric { padding: 14px; } + .token-usage-secondary { gap: 14px 0; } + .token-usage-secondary .token-usage-metric:nth-child(3) { border-left: 0; } + .token-usage-chart-card, .token-usage-data-card { padding: 14px; } + .token-usage-subtitle { max-width: 220px; line-height: 1.6; } + .token-usage-trend-svg { min-width: 480px; } +} diff --git a/web/static/i18n/en-US.json b/web/static/i18n/en-US.json index 932ccf16..e6817386 100644 --- a/web/static/i18n/en-US.json +++ b/web/static/i18n/en-US.json @@ -277,6 +277,19 @@ "capabilities": "Capabilities", "administration": "Administration" }, + "tokenUsageUI": { + "chartHint": "Hover or focus a bar for exact usage", + "scopeProject": "Current project", + "scopeAccessible": "Accessible conversations", + "subtitle": "Explore model consumption, caching and usage trends", + "scopeNote": "Scope and counting rules", + "trendMetric": "Trend metric", + "noDailyData": "No daily usage records", + "cacheComposition": "Input cache breakdown", + "cacheHit": "Cache hit rate", + "uncachedInput": "Input without cache reads", + "cacheNote": "Cache reads are part of input, not added to the total again. Cache writes are listed separately." + }, "dashboard": { "title": "Dashboard", "refresh": "Refresh", diff --git a/web/static/i18n/ru-RU.json b/web/static/i18n/ru-RU.json index 9e833896..23463753 100644 --- a/web/static/i18n/ru-RU.json +++ b/web/static/i18n/ru-RU.json @@ -277,6 +277,19 @@ "capabilities": "Возможности", "administration": "Администрирование" }, + "tokenUsageUI": { + "chartHint": "Наведите или выберите столбец для точных значений", + "scopeProject": "Текущий проект", + "scopeAccessible": "Доступные диалоги", + "subtitle": "Расход моделей, использование кэша и динамика", + "scopeNote": "Область и правила подсчёта", + "trendMetric": "Показатель графика", + "noDailyData": "Нет ежедневных данных", + "cacheComposition": "Кэш входных токенов", + "cacheHit": "Доля попаданий в кэш", + "uncachedInput": "Вход без чтения кэша", + "cacheNote": "Чтение кэша входит во входные токены и не прибавляется к итогу. Запись кэша указана отдельно." + }, "dashboard": { "title": "Обзор", "refresh": "Обновить", diff --git a/web/static/i18n/zh-CN.json b/web/static/i18n/zh-CN.json index e53ee222..b1b6d7f4 100644 --- a/web/static/i18n/zh-CN.json +++ b/web/static/i18n/zh-CN.json @@ -277,6 +277,19 @@ "capabilities": "能力中心", "administration": "平台管理" }, + "tokenUsageUI": { + "chartHint": "悬停或聚焦柱形查看精确用量", + "scopeProject": "当前项目", + "scopeAccessible": "可访问会话", + "subtitle": "查看模型消耗、缓存利用与使用趋势", + "scopeNote": "统计口径与说明", + "trendMetric": "趋势指标", + "noDailyData": "暂无每日用量记录", + "cacheComposition": "输入缓存构成", + "cacheHit": "缓存命中率", + "uncachedInput": "未命中缓存的输入", + "cacheNote": "缓存读取是输入的一部分,不重复计入总量。缓存写入单独列示。" + }, "dashboard": { "title": "仪表盘", "refresh": "刷新", diff --git a/web/static/js/token-usage-analysis.test.cjs b/web/static/js/token-usage-analysis.test.cjs index 86b98ddb..9c7e5f8d 100644 --- a/web/static/js/token-usage-analysis.test.cjs +++ b/web/static/js/token-usage-analysis.test.cjs @@ -42,6 +42,17 @@ test('cache hit ratio uses input tokens, clamps invalid detail and handles missi assert.equal(ctx.tokenUsageCacheRate(null), '—'); }); +test('daily trends retain server day keys and fill only gaps within returned history', () => { + const ctx = harness(); + const rows = ctx.tokenUsageTimeline([{ key: '2026-10-12', totalTokens: 12 }, { key: '2026-10-10', totalTokens: 10 }, { key: 'invalid', totalTokens: 20 }]); + assert.deepEqual(Array.from(rows, row => row.key), ['2026-10-10', '2026-10-11', '2026-10-12']); + assert.equal(rows[1].totalTokens, 0); + assert.equal(rows[1].events, 0); + assert.equal(rows[2].totalTokens, 12); + assert.equal(ctx.tokenUsageTimeline([]).length, 0); + assert.equal(ctx.tokenUsageTimeline([{ key: '2026-10-10', totalTokens: 10 }]).length, 1); +}); + test('detail requests stay project scoped and discard late results after a period change', async () => { const ctx = harness(); const host = { setAttribute() {}, removeAttribute() {}, textContent: '' }; @@ -74,6 +85,7 @@ test('all supported languages expose usage labels and interpolation placeholders const data = JSON.parse(fs.readFileSync(`web/static/i18n/${locale}.json`, 'utf8')); for (const key of ['usageView', 'usageScope', 'usageCacheRead', 'usageCacheWrite', 'usageTrend', 'usageCacheHitRate']) assert.ok(data.dashboard[key]); assert.ok(data.settings.nav.tokenUsage); + for (const key of ['subtitle', 'scopeNote', 'trendMetric', 'cacheComposition', 'cacheHit', 'uncachedInput', 'cacheNote', 'scopeProject', 'scopeAccessible', 'noDailyData']) assert.ok(data.tokenUsageUI[key]); assert.match(data.dashboard.usageCacheHitRate, /\{\{rate\}\}/); assert.match(data.dashboard.tokenUsageSub, /\{\{rate\}\}/); assert.match(data.chat.turnTokenUsageTitle, /\{\{cacheWrite\}\}/); diff --git a/web/static/js/token-usage.js b/web/static/js/token-usage.js index 34a2da73..ae55fcd9 100644 --- a/web/static/js/token-usage.js +++ b/web/static/js/token-usage.js @@ -1,6 +1,6 @@ // Token detail data belongs to the settings section; the dashboard keeps a // separate fixed seven-day summary. -var tokenUsageState = { days: 7, controller: null, requestSequence: 0, result: null }; +var tokenUsageState = { days: 7, controller: null, requestSequence: 0, result: null, trendMetric: 'totalTokens' }; function tokenUsageText(key, opts, fallback) { if (typeof window.t === 'function') { @@ -38,6 +38,8 @@ async function refreshTokenUsage() { if (select) select.value = String(tokenUsageState.days); let url = '/api/usage/tokens?days=' + tokenUsageState.days + '&limit=20'; const projectId = typeof getActiveProjectId === 'function' ? getActiveProjectId() : ''; + const scope = document.getElementById('settings-token-scope'); + if (scope) scope.textContent = tokenUsageText('tokenUsageUI.' + (projectId ? 'scopeProject' : 'scopeAccessible'), null, projectId ? '当前项目' : '可访问会话'); if (projectId) url += '&project_id=' + encodeURIComponent(projectId); try { const response = await apiFetch(url, { signal: controller.signal }); @@ -99,60 +101,167 @@ document.addEventListener('languagechange', function () { } }); +function tokenUsageNode(tag, className, text) { + const node = document.createElement(tag); + if (className) node.className = className; + if (text != null) node.textContent = text; + return node; +} + +// Fill only gaps between returned dates. The API's server-local day keys are +// authoritative; do not invent a browser-timezone endpoint for the period. +function tokenUsageTimeline(rows) { + const dated = (rows || []).filter(row => /^\d{4}-\d{2}-\d{2}$/.test(row.key || '')).sort((a, b) => a.key.localeCompare(b.key)); + if (!dated.length) return []; + const byDay = new Map(dated.map(row => [row.key, row])); + const start = Date.parse(dated[0].key + 'T00:00:00Z'); + const end = Date.parse(dated[dated.length - 1].key + 'T00:00:00Z'); + if (!Number.isFinite(start) || !Number.isFinite(end)) return dated; + const days = []; + for (let time = start; time <= end && days.length < 365; time += 86400000) { + const key = new Date(time).toISOString().slice(0, 10); + days.push(byDay.get(key) || { key, events: 0, totalTokens: 0, modelCalls: 0, promptTokens: 0 }); + } + return days; +} + +function setTokenUsageTrend(metric) { + if (!['totalTokens', 'modelCalls', 'cacheRate'].includes(metric)) return; + tokenUsageState.trendMetric = metric; + renderTokenUsageAnalysis(tokenUsageState.result); + const button = document.querySelector('[data-token-trend="' + metric + '"]'); + if (button) button.focus(); +} + function renderTokenUsageAnalysis(res) { const host = document.getElementById('settings-token-analysis'); if (!host) return; host.replaceChildren(); - if (!res || !res.summary) { - host.textContent = tokenUsageText('dashboard.usageUnavailable', null, '用量统计暂不可用'); - return; - } - const exact = value => tokenUsageCount(value).toLocaleString(window.__locale || 'zh-CN'); const label = key => tokenUsageText('dashboard.' + key, null, key); - const metrics = document.createElement('div'); - metrics.className = 'token-usage-metrics'; - tokenUsageMetrics(res.summary).forEach(([key, value]) => { - const card = document.createElement('div'); - const title = document.createElement('span'); - title.textContent = label(key); - const count = document.createElement('strong'); - count.textContent = typeof value === 'string' ? value : exact(value); - card.append(title, count); - metrics.append(card); - }); - host.append(metrics); - if (!tokenUsageCount(res.summary.events)) { - const empty = document.createElement('p'); - empty.textContent = tokenUsageText('dashboard.noTokenUsageYet', null, '暂无用量'); + const ui = key => tokenUsageText('tokenUsageUI.' + key, null, key); + const exact = value => tokenUsageCount(value).toLocaleString(window.__locale || 'zh-CN'); + if (!res || !res.summary) { + const empty = tokenUsageNode('div', 'token-usage-empty', label('usageUnavailable')); host.append(empty); return; } - // Tables use text nodes: provider model names and conversation IDs are untrusted. - const table = (titleKey, headings, rows) => { - const section = document.createElement('section'); - const title = document.createElement('h4'); - title.textContent = label(titleKey); - const wrap = document.createElement('div'); - wrap.className = 'token-usage-table-wrap'; - const grid = document.createElement('table'); - const caption = document.createElement('caption'); - caption.className = 'sr-only'; - caption.textContent = title.textContent; - const head = document.createElement('thead'); - const hr = document.createElement('tr'); - headings.forEach(key => { - const th = document.createElement('th'); - th.scope = 'col'; - th.textContent = label(key); - hr.append(th); - }); + const summary = res.summary; + const metrics = new Map(tokenUsageMetrics(summary)); + const primary = tokenUsageNode('div', 'token-usage-metrics token-usage-primary'); + const secondary = tokenUsageNode('div', 'token-usage-metrics token-usage-secondary'); + const primaryKeys = ['usageTotal', 'usageInput', 'usageOutput', 'usageCacheRate']; + [...primaryKeys, 'usageCalls', 'usageCacheRead', 'usageCacheWrite', 'usageReasoning'].forEach(key => { + const value = metrics.get(key); + const card = tokenUsageNode('div', 'token-usage-metric'); + card.dataset.metric = key; + card.append(tokenUsageNode('span', '', label(key)), tokenUsageNode('strong', '', typeof value === 'string' ? value : exact(value))); + (primaryKeys.includes(key) ? primary : secondary).append(card); + }); + host.append(primary, secondary); + if (!tokenUsageCount(summary.events)) { + host.append(tokenUsageNode('div', 'token-usage-empty', label('noTokenUsageYet'))); + return; + } + + const chartLayout = tokenUsageNode('div', 'token-usage-charts'); + const chart = tokenUsageNode('section', 'token-usage-chart-card'); + const chartHead = tokenUsageNode('div', 'token-usage-chart-head'); + chartHead.append(tokenUsageNode('h4', '', label('usageTrend'))); + const tabs = tokenUsageNode('div', 'token-usage-chart-tabs'); + tabs.setAttribute('role', 'group'); + tabs.setAttribute('aria-label', ui('trendMetric')); + for (const [metric, key] of [['totalTokens', 'usageTotal'], ['modelCalls', 'usageCalls'], ['cacheRate', 'usageCacheRate']]) { + const button = tokenUsageNode('button', '', label(key)); + button.type = 'button'; + button.dataset.tokenTrend = metric; + button.setAttribute('aria-pressed', String(tokenUsageState.trendMetric === metric)); + button.addEventListener('click', () => setTokenUsageTrend(metric)); + tabs.append(button); + } + chartHead.append(tabs); + chart.append(chartHead); + const tooltip = tokenUsageNode('p', 'token-usage-chart-tooltip', ui('chartHint')); + tooltip.setAttribute('aria-live', 'polite'); + const days = tokenUsageTimeline(res.byDay); + const metric = tokenUsageState.trendMetric; + const valueOf = row => metric === 'cacheRate' ? (tokenUsageCount(row.promptTokens) ? Math.min(tokenUsageCount(row.cachedTokens), tokenUsageCount(row.promptTokens)) / tokenUsageCount(row.promptTokens) * 100 : 0) : tokenUsageCount(row[metric]); + const peak = metric === 'cacheRate' ? 100 : Math.max(1, ...days.map(valueOf)); + const svgNS = 'http://www.w3.org/2000/svg'; + const svg = document.createElementNS(svgNS, 'svg'); + svg.setAttribute('viewBox', '0 0 640 230'); + svg.setAttribute('role', 'group'); + svg.setAttribute('aria-label', label('usageTrend')); + svg.classList.add('token-usage-trend-svg'); + const element = (tag, attrs, text) => { + const node = document.createElementNS(svgNS, tag); + Object.entries(attrs).forEach(([key, value]) => node.setAttribute(key, String(value))); + if (text != null) node.textContent = text; + return node; + }; + for (let tick = 0; tick <= 4; tick++) { + const y = 18 + tick * 44; + svg.append(element('line', { x1: 54, x2: 624, y1: y, y2: y, class: 'token-usage-chart-grid' })); + svg.append(element('text', { x: 46, y: y + 4, 'text-anchor': 'end', class: 'token-usage-chart-label' }, metric === 'cacheRate' ? (100 - tick * 25) + '%' : formatTokenUsageCompact(peak * (4 - tick) / 4))); + } + const slot = 570 / Math.max(days.length, 1); + const every = Math.max(1, Math.ceil(days.length / 7)); + days.forEach((row, index) => { + const x = 54 + slot * (index + .5); + const height = valueOf(row) / peak * 176; + const detail = row.key + ' · ' + label('usageTotal') + ' ' + exact(row.totalTokens) + ' · ' + label('usageCalls') + ' ' + exact(row.modelCalls) + ' · ' + label('usageCacheRate') + ' ' + tokenUsageCacheRate(row); + const bar = element('rect', { x: x - Math.min(24, slot * .55) / 2, y: 194 - height, width: Math.min(24, slot * .55), height: Math.max(height, 1), rx: 3, class: 'token-usage-chart-bar', tabindex: 0, 'aria-label': detail }); + bar.append(element('title', {}, detail)); + for (const event of ['mouseenter', 'focus']) bar.addEventListener(event, () => { tooltip.textContent = detail; }); + for (const event of ['mouseleave', 'blur']) bar.addEventListener(event, () => { tooltip.textContent = ui('chartHint'); }); + svg.append(bar); + if (index % every === 0 || index === days.length - 1) { + svg.append(element('text', { x, y: 218, 'text-anchor': 'middle', class: 'token-usage-chart-label' }, row.key.slice(5))); + } + }); + const plot = tokenUsageNode('div', 'token-usage-svg-wrap'); + plot.append(svg); + chart.append(plot); + chart.append(tooltip); + if (!days.length) chart.append(tokenUsageNode('p', 'token-usage-empty', ui('noDailyData'))); + chartLayout.append(chart); + + const cache = tokenUsageNode('section', 'token-usage-chart-card token-usage-cache-card'); + cache.append(tokenUsageNode('h4', '', ui('cacheComposition'))); + const input = tokenUsageCount(summary.promptTokens); + const cached = Math.min(input, tokenUsageCount(summary.cachedTokens)); + const ratio = input ? cached / input * 100 : 0; + const ring = tokenUsageNode('div', 'token-usage-cache-ring'); + ring.style.setProperty('--cache-ratio', ratio + '%'); + ring.append(tokenUsageNode('strong', '', tokenUsageCacheRate(summary)), tokenUsageNode('span', '', ui('cacheHit'))); + cache.append(ring); + for (const [key, value, className] of [['usageCacheRead', cached, 'is-cached'], ['uncachedInput', input - cached, 'is-uncached']]) { + const line = tokenUsageNode('div', 'token-usage-cache-legend ' + className); + line.append(tokenUsageNode('span', '', key === 'uncachedInput' ? ui(key) : label(key)), tokenUsageNode('strong', '', exact(value))); + cache.append(line); + } + cache.append(tokenUsageNode('p', 'token-usage-chart-note', ui('cacheNote'))); + chartLayout.append(cache); + host.append(chartLayout); + + // Provider labels are untrusted: render all values as text or constructed + // nodes, never as HTML. Full precision is retained in every table. + const table = (titleKey, headings, rows, collapsed = false) => { + const section = tokenUsageNode(collapsed ? 'details' : 'section', 'token-usage-data-card'); + const title = tokenUsageNode(collapsed ? 'summary' : 'h4', '', label(titleKey)); + const wrap = tokenUsageNode('div', 'token-usage-table-wrap'); + const grid = tokenUsageNode('table'); + const caption = tokenUsageNode('caption', 'sr-only', label(titleKey)); + const head = tokenUsageNode('thead'); + const hr = tokenUsageNode('tr'); + headings.forEach(key => { const th = tokenUsageNode('th', '', label(key)); th.scope = 'col'; hr.append(th); }); head.append(hr); - const body = document.createElement('tbody'); + const body = tokenUsageNode('tbody'); rows.forEach(values => { - const tr = document.createElement('tr'); + const tr = tokenUsageNode('tr'); values.forEach(value => { - const td = document.createElement('td'); - td.textContent = String(value == null ? '' : value); + const td = tokenUsageNode('td'); + if (value && value.nodeType) td.append(value); + else td.textContent = String(value == null ? '' : value); tr.append(td); }); body.append(tr); @@ -162,36 +271,21 @@ function renderTokenUsageAnalysis(res) { section.append(title, wrap); host.append(section); }; - const counts = row => [exact(row.promptTokens), exact(row.completionTokens), exact(row.cachedTokens), - exact(row.cacheWriteTokens), exact(row.reasoningTokens), exact(row.totalTokens), exact(row.modelCalls)]; + const counts = row => [exact(row.promptTokens), exact(row.completionTokens), exact(row.cachedTokens), exact(row.cacheWriteTokens), exact(row.reasoningTokens), exact(row.totalTokens), exact(row.modelCalls)]; const fields = ['usageInput', 'usageOutput', 'usageCacheRead', 'usageCacheWrite', 'usageReasoning', 'usageTotal', 'usageCalls']; - table('usageByModel', ['usageModel', 'usageShare', ...fields], (res.byModel || []).map(row => [row.label || row.key, - tokenUsageCount(res.summary.totalTokens) > 0 ? (tokenUsageCount(row.totalTokens) / tokenUsageCount(res.summary.totalTokens) * 100).toFixed(1) + '%' : '—', ...counts(row)])); - const trendTitle = document.createElement('h4'); - trendTitle.textContent = label('usageTrend'); - const trend = document.createElement('div'); - trend.className = 'token-usage-trend'; - const days = (res.byDay || []).slice().reverse(); - const peak = Math.max(1, ...days.map(row => tokenUsageCount(row.totalTokens))); - days.forEach(row => { - const bar = document.createElement('div'); - bar.className = 'token-usage-day'; - const count = document.createElement('span'); - count.textContent = formatTokenUsageCompact(row.totalTokens); - const fill = document.createElement('div'); - fill.className = 'token-usage-day-bar'; - fill.style.height = Math.max(1, tokenUsageCount(row.totalTokens) / peak * 90) + 'px'; - const date = document.createElement('span'); - date.textContent = String(row.key || '').slice(5); - bar.title = row.key + ': ' + exact(row.totalTokens); - bar.append(count, fill, date); - trend.append(bar); - }); - host.append(trendTitle, trend); - table('usageByDay', ['usageDate', ...fields], (res.byDay || []).map(row => [row.key, ...counts(row)])); + table('usageByModel', ['usageModel', 'usageShare', ...fields], (res.byModel || []).map(row => { + const ratio = tokenUsageCount(summary.totalTokens) ? tokenUsageCount(row.totalTokens) / tokenUsageCount(summary.totalTokens) * 100 : 0; + const share = tokenUsageNode('div', 'token-usage-share'); + share.append(tokenUsageNode('span', '', ratio.toFixed(1) + '%')); + const track = tokenUsageNode('span', 'token-usage-share-track'); + const fill = tokenUsageNode('span'); fill.style.width = Math.min(100, ratio) + '%'; track.append(fill); share.append(track); + return [row.label || row.key, share, ...counts(row)]; + })); + table('usageByDay', ['usageDate', ...fields], (res.byDay || []).map(row => [row.key, ...counts(row)]), true); table('usageRecent', ['usageTime', 'usageModel', 'usageStatus', 'usageTotal', 'usageCalls'], (res.recent || []).map(row => { const time = new Date(row.createdAt); - return [Number.isFinite(time.getTime()) ? time.toLocaleString(window.__locale || 'zh-CN') : '—', - row.model || '—', row.reason === 'final' ? label('usageFinal') : row.reason === 'partial' ? label('usagePartial') : (row.reason || '—'), exact(row.totalTokens), exact(row.modelCalls)]; + const status = tokenUsageNode('span', 'token-usage-status', row.reason === 'final' ? label('usageFinal') : row.reason === 'partial' ? label('usagePartial') : (row.reason || '—')); + if (row.reason === 'final' || row.reason === 'partial') status.classList.add('is-' + row.reason); + return [Number.isFinite(time.getTime()) ? time.toLocaleString(window.__locale || 'zh-CN') : '—', row.model || '—', status, exact(row.totalTokens), exact(row.modelCalls)]; })); } diff --git a/web/templates/index.html b/web/templates/index.html index 147d3cee..8698ffa3 100644 --- a/web/templates/index.html +++ b/web/templates/index.html @@ -39,7 +39,7 @@ } })(); - + @@ -3642,8 +3642,8 @@
-

Token 用量

- +

Token 用量

查看模型消耗、缓存利用与使用趋势

+
@@ -3652,8 +3652,9 @@ +
-

统计已记录的 Agent 运行中模型返回的用量,包括子 Agent。缓存读取和写入属于输入,推理属于输出,不重复加入总量。未返回或未采集的用量不在统计内;这不是供应商账单。模型按运行配置归类,切换模型的运行可能混合用量。

+
统计口径与说明

统计已记录的 Agent 运行中模型返回的用量,包括子 Agent。缓存读取和写入属于输入,推理属于输出,不重复加入总量。未返回或未采集的用量不在统计内;这不是供应商账单。模型按运行配置归类,切换模型的运行可能混合用量。

@@ -7023,7 +7024,7 @@ - +