diff --git a/CHANGELOG.md b/CHANGELOG.md index b0e65a07..73fa1c76 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -40,6 +40,8 @@ - PR 头部展示 reviewer 头像栈:在标题右侧、动作按钮行之上垂直居中展示评审者头像(Bitbucket 风格略重叠、灰色细描边环),按 needsWork > approved > 待评审 优先排序并过滤掉当前用户自己;approved / needsWork 的头像右上角带决断角标(圆环内绿勾 / 琥珀叹号)。至多展示 4 个,超出则显示 3 个 + 「+n」,点击「+n」下拉展示其余评审者(头像 + 名 + 决断 chip);直接展示的头像 hover 出名字。 +- run 卡片 / 思考步骤展示「模型实际交互规模」(缓存命中量 + 轮次):本机 CLI(claude / codex)接管 LLM 时,token 用量是 agentic 多轮累加、且每轮的 `cache_read` 反复计入,累计值远超模型单请求窗口,易被误读为超限或计量出错。现采集层从 CLI 返回的 usage 补充真实的**提示缓存命中量(`cache_read`)**与**模型交互轮次(`num_turns`)**,运行卡片与思考步骤统一呈现为「↑输入 (⛁缓存命中) / ↓输出 · ↻N」——蓝色数据库柱体图标标缓存命中量(属输入的一部分,无命中则整段不显示)、循环箭头图标标多轮次(单轮不显示),输入 / 输出各自独立悬浮提示。litellm / API 路径同步补 `cache_read` 采集(Anthropic `cache_read_input_tokens` / OpenAI `prompt_tokens_details.cached_tokens`),与 CLI 路径一致;`TokenUsage` 新增 `cacheReadTokens` / `turns` 字段(缺失向后兼容)。 + ### Changed - ChatPane 评审结果交互打磨(一批小优化): diff --git a/apps/desktop/scripts/pragent-shim/meebox_pragent_shim/cli/install.py b/apps/desktop/scripts/pragent-shim/meebox_pragent_shim/cli/install.py index 30e3a300..3f7fd4b0 100644 --- a/apps/desktop/scripts/pragent-shim/meebox_pragent_shim/cli/install.py +++ b/apps/desktop/scripts/pragent-shim/meebox_pragent_shim/cli/install.py @@ -88,13 +88,21 @@ async def chat_completion(self, model, system, user, temperature=0.2, img_path=N ) text, usage = spec["parser"]((out or b"").decode("utf-8", "replace")) if usage: - # input_tokens(+cache_*) ≈ prompt,output_tokens ≈ completion(两家 usage 同字段名) + # input_tokens(+cache_*) ≈ prompt(输入侧总规模),output_tokens ≈ completion(两家 usage + # 同字段名)。cache_read 既计入 prompt 总量、又单列上抛供 UI 拆分展示「↑总量 (cache N)」。 prompt_tokens = usage.get("input_tokens") for k in ("cache_read_input_tokens", "cache_creation_input_tokens"): v = usage.get(k) if isinstance(v, int): prompt_tokens = (prompt_tokens or 0) + v - _emit_usage_tokens(prompt_tokens, usage.get("output_tokens")) + cache_read = usage.get("cache_read_input_tokens") + turns = usage.get("num_turns") + _emit_usage_tokens( + prompt_tokens, + usage.get("output_tokens"), + cache_read_tokens=cache_read if isinstance(cache_read, int) else None, + turns=turns if isinstance(turns, int) else None, + ) return text, "stop" handler_cls.chat_completion = chat_completion diff --git a/apps/desktop/scripts/pragent-shim/meebox_pragent_shim/cli/parsers.py b/apps/desktop/scripts/pragent-shim/meebox_pragent_shim/cli/parsers.py index 85b4968f..13d89d12 100644 --- a/apps/desktop/scripts/pragent-shim/meebox_pragent_shim/cli/parsers.py +++ b/apps/desktop/scripts/pragent-shim/meebox_pragent_shim/cli/parsers.py @@ -4,8 +4,12 @@ def _parse_claude_output(stdout): """解析 `claude -p --output-format json` 的 stdout,返回 (text, usage_dict_or_None)。 - 成功形如 {"result": "...", "usage": {"input_tokens":..,"output_tokens":..}, "is_error": false}。 - 非 JSON / 缺字段退化为「整段 stdout 当文本、usage=None」;仅 is_error=True 时抛错。""" + 成功形如 {"result": "...", "num_turns": N, "usage": {"input_tokens":..,"output_tokens":.., + "cache_read_input_tokens":..}, "is_error": false}。非 JSON / 缺字段退化为「整段 stdout 当文本、 + usage=None」;仅 is_error=True 时抛错。 + + claude -p 是 agentic 多轮:顶层 num_turns 为本次会话内部的模型轮次(可远大于 1),把它并入 + usage dict 的 num_turns 字段一并上抛(usage 同字段名供采集层统一读取,见 install.py)。""" import json s = (stdout or "").strip() @@ -23,18 +27,25 @@ def _parse_claude_output(stdout): if not isinstance(text, str): text = s usage = obj.get("usage") - return text, (usage if isinstance(usage, dict) else None) + if not isinstance(usage, dict): + return text, None + nt = obj.get("num_turns") + if isinstance(nt, int): + usage["num_turns"] = nt + return text, usage def _parse_codex_output(stdout): """解析 `codex exec --json` 的 JSONL 事件流,返回 (text, usage_dict_or_None): - type==item.completed 且 item.type==agent_message → item.text 为模型回复,取最后一条; - - type==turn.completed → usage {input_tokens, output_tokens} 为 token。 + - type==turn.completed → usage {input_tokens, output_tokens} 为 token,并计一轮。 + turn.completed 出现次数作模型轮次 num_turns(并入 usage dict,与 claude 路径同字段名)。 逐行容错:非 JSON 行跳过、事件缺字段不致命;text 缺失退到空串(让上层 load_yaml 兜底)。""" import json text = None usage = None + turns = 0 for line in (stdout or "").splitlines(): line = line.strip() if not line: @@ -53,7 +64,10 @@ def _parse_codex_output(stdout): if isinstance(txt, str): text = txt # 取最后一条 agent_message 作最终回复 elif etype == "turn.completed": + turns += 1 u = ev.get("usage") if isinstance(u, dict): usage = u + if isinstance(usage, dict) and turns: + usage["num_turns"] = turns return (text if isinstance(text, str) else ""), usage diff --git a/apps/desktop/scripts/pragent-shim/meebox_pragent_shim/usage.py b/apps/desktop/scripts/pragent-shim/meebox_pragent_shim/usage.py index 3f453728..9f8de2ab 100644 --- a/apps/desktop/scripts/pragent-shim/meebox_pragent_shim/usage.py +++ b/apps/desktop/scripts/pragent-shim/meebox_pragent_shim/usage.py @@ -27,14 +27,29 @@ def _g(key): } if rec["prompt_tokens"] is None and rec["completion_tokens"] is None: return # 没有任何可用数字(如流式 MockResponse)→ 不打 + # 提示缓存读取量:Anthropic 走 cache_read_input_tokens;OpenAI 兼容走 + # prompt_tokens_details.cached_tokens。两路尽力采集、缺失则不带(UI 据有无决定是否展示)。 + cache_read = _g("cache_read_input_tokens") + if not isinstance(cache_read, int): + details = _g("prompt_tokens_details") + if details is not None: + cache_read = ( + details.get("cached_tokens") + if isinstance(details, dict) + else getattr(details, "cached_tokens", None) + ) + if isinstance(cache_read, int): + rec["cache_read_tokens"] = cache_read print(f"@@MEEBOX_USAGE@@ {json.dumps(rec)}", file=sys.stderr, flush=True) except Exception as exc: # noqa: BLE001 _debug(f"emit usage failed (ignored): {exc}") -def _emit_usage_tokens(prompt_tokens, completion_tokens) -> None: +def _emit_usage_tokens( + prompt_tokens, completion_tokens, cache_read_tokens=None, turns=None +) -> None: """CLI 模式下从 CLI 返回的 JSON usage 直接构造哨兵(与 _emit_usage 同格式,主进程同一套 - 累加逻辑)。两个数都为 None 则不打。""" + 累加逻辑)。两个 token 数都为 None 则不打;cache_read / turns 仅在有值时附带。""" try: if prompt_tokens is None and completion_tokens is None: return @@ -45,6 +60,10 @@ def _emit_usage_tokens(prompt_tokens, completion_tokens) -> None: "completion_tokens": completion_tokens, "total_tokens": (prompt_tokens or 0) + (completion_tokens or 0), } + if cache_read_tokens is not None: + rec["cache_read_tokens"] = cache_read_tokens + if turns is not None: + rec["turns"] = turns print(f"@@MEEBOX_USAGE@@ {json.dumps(rec)}", file=sys.stderr, flush=True) except Exception as exc: # noqa: BLE001 _debug(f"emit cli usage failed (ignored): {exc}") diff --git a/apps/desktop/src/main/services/pr-agent/usage.ts b/apps/desktop/src/main/services/pr-agent/usage.ts index 5fc6123a..7b48bc63 100644 --- a/apps/desktop/src/main/services/pr-agent/usage.ts +++ b/apps/desktop/src/main/services/pr-agent/usage.ts @@ -8,12 +8,16 @@ export interface UsageAcc { completion: number; total: number; calls: number; + /** 累计提示缓存读取 token(cache_read),是 prompt 的一部分 */ + cacheRead: number; + /** 累计模型交互轮次:CLI agentic 模式来自各次哨兵的 num_turns(一次 run 内可累加多段) */ + turns: number; any: boolean; } /** 新建一个空 usage 累加器。 */ export function newUsageAcc(): UsageAcc { - return { prompt: 0, completion: 0, total: 0, calls: 0, any: false }; + return { prompt: 0, completion: 0, total: 0, calls: 0, cacheRead: 0, turns: 0, any: false }; } /** @@ -29,6 +33,8 @@ export function accumulateUsageSentinel(line: string, acc: UsageAcc): boolean { prompt_tokens?: number; completion_tokens?: number; total_tokens?: number; + cache_read_tokens?: number; + turns?: number; }; acc.calls += 1; if (typeof r.prompt_tokens === 'number') { @@ -43,6 +49,8 @@ export function accumulateUsageSentinel(line: string, acc: UsageAcc): boolean { acc.total += r.total_tokens; acc.any = true; } + if (typeof r.cache_read_tokens === 'number') acc.cacheRead += r.cache_read_tokens; + if (typeof r.turns === 'number') acc.turns += r.turns; } catch { // 坏哨兵行:仍吞掉,不计数 } @@ -58,6 +66,9 @@ export function finalizeUsage(acc: UsageAcc): TokenUsage | undefined { // 优先各次 total 累加;个别次缺 total 时用 prompt+completion 兜底 totalTokens: acc.total || acc.prompt + acc.completion, calls: acc.calls, + // cache_read 无命中(0)则不带;turns 优先 CLI 上报的轮次,缺失回退为调用次数 + cacheReadTokens: acc.cacheRead || undefined, + turns: acc.turns || acc.calls, }; } diff --git a/apps/desktop/src/renderer/src/components/common/icons.tsx b/apps/desktop/src/renderer/src/components/common/icons.tsx index 4d029f11..64982eb8 100644 --- a/apps/desktop/src/renderer/src/components/common/icons.tsx +++ b/apps/desktop/src/renderer/src/components/common/icons.tsx @@ -632,6 +632,51 @@ export function SyncIcon({ size = 12 }: IconProps) { ); } +/** 数据库柱体(Lucide database,viewBox 24):表示提示缓存命中量(cache_read) */ +export function DatabaseIcon({ size = 12, className }: IconProps) { + return ( + + ); +} + +/** 循环箭头(Lucide repeat,viewBox 24):表示模型交互轮次(agentic 多轮) */ +export function RepeatIcon({ size = 12, className }: IconProps) { + return ( + + ); +} + /** 齿轮(Lucide settings,viewBox 24):设置按钮 */ export function SettingsIcon({ size = 14 }: IconProps) { return ( diff --git a/apps/desktop/src/renderer/src/components/features/chat/components/AgentStep.tsx b/apps/desktop/src/renderer/src/components/features/chat/components/AgentStep.tsx index 5c418dee..09cfef31 100644 --- a/apps/desktop/src/renderer/src/components/features/chat/components/AgentStep.tsx +++ b/apps/desktop/src/renderer/src/components/features/chat/components/AgentStep.tsx @@ -2,8 +2,8 @@ import { useEffect, useState } from 'react'; import { useTranslation } from 'react-i18next'; import type { AgentStep } from '@meebox/shared'; import { RobotIcon } from '../../../common'; -import { formatElapsed, formatTokens } from '../utils/format'; -import { Spinner } from './shared'; +import { formatElapsed } from '../utils/format'; +import { Spinner, TokenStat } from './shared'; /** * 内联思考步骤(类 Claude Code「先思考→定步骤→执行步骤」):穿插在时间线里、排在所选工具的 run @@ -33,31 +33,17 @@ export function AgentStepRow({ step }: { step: AgentStep }) { )} {headText && {headText}} {/* 本步**单独**的 token 用量(不累计):judge / 总结 / 规划等经独立 LLM 通道的推理步带值; - 与 run 卡片同款 ↑输入(绿) / ↓输出(红) 计法,靠行尾对齐。describe/review/ask 的开销在各自 run 卡片上。 */} + 与 run 卡片同款 ↑输入(绿)[⛁缓存]/↓输出(红),输入输出各自独立 hover、靠行尾对齐。 + describe/review/ask 的开销在各自 run 卡片上。 */} {step.usage && (step.usage.promptTokens !== undefined || step.usage.completionTokens !== undefined) ? ( - - {step.usage.promptTokens !== undefined && ( - <> - - {formatTokens(step.usage.promptTokens)} - - )} - {step.usage.promptTokens !== undefined && step.usage.completionTokens !== undefined - ? ' ' - : ''} - {step.usage.completionTokens !== undefined && ( - <> - - {formatTokens(step.usage.completionTokens)} - - )} + + ) : null} diff --git a/apps/desktop/src/renderer/src/components/features/chat/components/RunResultView.tsx b/apps/desktop/src/renderer/src/components/features/chat/components/RunResultView.tsx index be89a32a..2bc1f90c 100644 --- a/apps/desktop/src/renderer/src/components/features/chat/components/RunResultView.tsx +++ b/apps/desktop/src/renderer/src/components/features/chat/components/RunResultView.tsx @@ -1,11 +1,11 @@ import { useState } from 'react'; import { useTranslation } from 'react-i18next'; import type { Finding, FindingClosure, ReviewDraft, ReviewRun } from '@meebox/shared'; -import { RetryIcon, ShareIcon, TrashIcon } from '../../../common'; +import { RepeatIcon, RetryIcon, ShareIcon, TrashIcon } from '../../../common'; import { orderFindings } from '../utils/findings'; -import { formatStartTime, formatTokens, runStatusLabel } from '../utils/format'; +import { formatStartTime, runStatusLabel } from '../utils/format'; import { extractTokenUsage, type TokenUsage } from '../utils/tokens'; -import { AnsiPre, AskQuestion, BreakablePath } from './shared'; +import { AnsiPre, AskQuestion, BreakablePath, TokenStat } from './shared'; import { FindingCard } from './FindingCard'; function RunMeta({ run, onDelete }: { run: ReviewRun; onDelete: () => void }) { @@ -18,6 +18,8 @@ function RunMeta({ run, onDelete }: { run: ReviewRun; onDelete: () => void }) { prompt: run.tokenUsage.promptTokens, completion: run.tokenUsage.completionTokens, total: run.tokenUsage.totalTokens, + cacheRead: run.tokenUsage.cacheReadTokens, + turns: run.tokenUsage.turns, } : run.stdout ? extractTokenUsage(run.stdout) @@ -38,28 +40,24 @@ function RunMeta({ run, onDelete }: { run: ReviewRun; onDelete: () => void }) { {run.model} )} - {/* 只分别展示输入(↑prompt,绿) / 输出(↓completion,红),不显示总数。旧 run 可能只有 prompt */} + {/* 输入(↑绿)[⛁缓存]/输出(↓红):输入输出各自独立 hover;缓存为输入一部分、无命中不显示。旧 run 可能只有 prompt */} {usage.prompt !== undefined || usage.completion !== undefined ? ( + + + + ) : null} + {/* 模型交互轮次:循环箭头图标 + 次数(取代「N 轮」文案,省空间 / 免复数);仅多轮(agentic) 时展示 */} + {usage.turns !== undefined && usage.turns > 1 ? ( - {usage.prompt !== undefined && ( - <> - - {formatTokens(usage.prompt)} - - )} - {usage.prompt !== undefined && usage.completion !== undefined ? ' / ' : ''} - {usage.completion !== undefined && ( - <> - - {formatTokens(usage.completion)} - - )} + + {usage.turns} ) : null} diff --git a/apps/desktop/src/renderer/src/components/features/chat/components/shared.tsx b/apps/desktop/src/renderer/src/components/features/chat/components/shared.tsx index d6d9b02d..0fedc631 100644 --- a/apps/desktop/src/renderer/src/components/features/chat/components/shared.tsx +++ b/apps/desktop/src/renderer/src/components/features/chat/components/shared.tsx @@ -3,7 +3,8 @@ import { useTranslation } from 'react-i18next'; import ReactMarkdown, { type Components } from 'react-markdown'; import remarkBreaks from 'remark-breaks'; import remarkGfm from 'remark-gfm'; -import { QuestionIcon, mermaidComponents } from '../../../common'; +import { DatabaseIcon, QuestionIcon, mermaidComponents } from '../../../common'; +import { formatTokens } from '../utils/format'; import { REMOTE_REHYPE_PLUGINS } from '../../../../lib/markdown'; import { parseAnsi, segmentStyle } from '../../../../utils/ansi'; @@ -122,6 +123,52 @@ export function AnsiPre({ ); } +/** + * Token 用量内联展示:↑输入(绿) [⛁缓存命中] / ↓输出(红)。输入、输出**各自独立 hover 提示**; + * 缓存命中(cache_read)为输入的一部分,柱体图标拆分展示(间距在 cache 前,无命中时整段不渲染、不留空), + * 悬浮另给说明。run 卡片(RunMeta) 与思考步骤(AgentStep) 共用;分隔符按上下文传入(chip 内 ` / `、步骤行空格)。 + */ +export function TokenStat({ + prompt, + completion, + cacheRead, + separator = ' / ', +}: { + prompt?: number; + completion?: number; + cacheRead?: number; + separator?: string; +}) { + const { t } = useTranslation(); + if (prompt === undefined && completion === undefined) return null; + return ( + <> + {prompt !== undefined && ( + + + {formatTokens(prompt)} + {cacheRead !== undefined && cacheRead > 0 && ( + + + {formatTokens(cacheRead)} + + )} + + )} + {prompt !== undefined && completion !== undefined ? separator : ''} + {completion !== undefined && ( + + + {formatTokens(completion)} + + )} + + ); +} + /** /ask 提问行:问号图标 + markdown 渲染的提问内容(Agent 自拟的追问常含内联代码 / 列表)。 */ export function AskQuestion({ text }: { text: string }) { const { t } = useTranslation(); diff --git a/apps/desktop/src/renderer/src/components/features/chat/utils/tokens.ts b/apps/desktop/src/renderer/src/components/features/chat/utils/tokens.ts index 5df78095..e1c7fb6d 100644 --- a/apps/desktop/src/renderer/src/components/features/chat/utils/tokens.ts +++ b/apps/desktop/src/renderer/src/components/features/chat/utils/tokens.ts @@ -6,6 +6,10 @@ export interface TokenUsage { completion?: number; /** 总 token;优先用 litellm 给的,缺时算 prompt+completion */ total?: number; + /** 提示缓存读取量(cache_read),是 prompt 的一部分;缺/0 时不展示「(cache N)」括号 */ + cacheRead?: number; + /** 模型交互轮次;≤1 时不单独展示 */ + turns?: number; } /** diff --git a/apps/desktop/src/renderer/src/i18n/locales/de-DE.json b/apps/desktop/src/renderer/src/i18n/locales/de-DE.json index fc29fd37..f04dd454 100644 --- a/apps/desktop/src/renderer/src/i18n/locales/de-DE.json +++ b/apps/desktop/src/renderer/src/i18n/locales/de-DE.json @@ -47,6 +47,7 @@ "bulletDescribe": "PR-Zusammenfassung / Labels automatisch generieren", "bulletImprove": "zeilenweise Vorschläge zur Codeverbesserung (mit Wichtigkeitsbewertung)", "bulletReview": "einen KI-Review ausführen; Ergebnisse erscheinen in der Findings-Liste", + "cacheInline": "Cache {{n}}", "cancelQueuedTitle": "Eingereihte Aufgabe abbrechen", "clearConfirmLabel": "Leeren", "clearConfirmMessage": "Den gesamten PR-Agent-Ausführungsverlauf für den aktuellen PR löschen? Dies betrifft nur diesen PR und kann nicht rückgängig gemacht werden.", @@ -163,7 +164,9 @@ "statusSucceeded": "Fertig", "stopAria": "Stoppen", "stopTitle": "Ausführung stoppen", - "tokensTitle": "Eingabe (prompt) {{prompt}} · Ausgabe (completion) {{completion}} Tokens", + "tokensInTitle": "Eingabe (prompt) {{n}} Tokens", + "tokensOutTitle": "Ausgabe (completion) {{n}} Tokens", + "turnsTitle": "Modell-Interaktionsrunden (ein Modellaufruf pro Runde)", "unknownCommand": "Unbekannter Befehl {{head}}; unterstützt: {{cmds}}", "userQuestionAria": "Benutzerfrage", "waitingOutput": "(Warte auf PR-Agent-Ausgabe…)" diff --git a/apps/desktop/src/renderer/src/i18n/locales/en-US.json b/apps/desktop/src/renderer/src/i18n/locales/en-US.json index 607a9594..48b89e92 100644 --- a/apps/desktop/src/renderer/src/i18n/locales/en-US.json +++ b/apps/desktop/src/renderer/src/i18n/locales/en-US.json @@ -47,6 +47,7 @@ "bulletDescribe": "auto-generate PR summary / labels", "bulletImprove": "line-by-line code improvement suggestions (with importance scores)", "bulletReview": "run an AI review; results land in the findings list", + "cacheInline": "cache {{n}}", "cancelQueuedTitle": "Cancel queued task", "clearConfirmLabel": "Clear", "clearConfirmMessage": "Clear all PR Agent run history for the current PR? This affects only this PR and cannot be undone.", @@ -163,7 +164,9 @@ "statusSucceeded": "Done", "stopAria": "Stop", "stopTitle": "Stop execution", - "tokensTitle": "Input (prompt) {{prompt}} · Output (completion) {{completion}} tokens", + "tokensInTitle": "Input (prompt) {{n}} tokens", + "tokensOutTitle": "Output (completion) {{n}} tokens", + "turnsTitle": "Model interaction turns (one model call per turn)", "unknownCommand": "Unknown command {{head}}; supported: {{cmds}}", "userQuestionAria": "User question", "waitingOutput": "(Waiting for PR Agent output…)" diff --git a/apps/desktop/src/renderer/src/i18n/locales/ja-JP.json b/apps/desktop/src/renderer/src/i18n/locales/ja-JP.json index 49a10ae3..2fc80f39 100644 --- a/apps/desktop/src/renderer/src/i18n/locales/ja-JP.json +++ b/apps/desktop/src/renderer/src/i18n/locales/ja-JP.json @@ -47,6 +47,7 @@ "bulletDescribe": "PR の概要 / ラベルを自動生成", "bulletImprove": "行単位のコード改善提案(重要度スコア付き)", "bulletReview": "AI レビューを実行し、結果は 指摘リストに表示されます", + "cacheInline": "キャッシュ {{n}}", "cancelQueuedTitle": "待機中のタスクをキャンセル", "clearConfirmLabel": "クリア", "clearConfirmMessage": "現在の PR の PR Agent 実行履歴をすべてクリアしますか? この PR にのみ影響し、元に戻せません。", @@ -163,7 +164,9 @@ "statusSucceeded": "完了", "stopAria": "停止", "stopTitle": "実行を停止", - "tokensTitle": "入力(prompt) {{prompt}} · 出力(completion) {{completion}} トークン", + "tokensInTitle": "入力(prompt) {{n}} トークン", + "tokensOutTitle": "出力(completion) {{n}} トークン", + "turnsTitle": "モデルとのやり取りの回数(1 回につきモデル呼び出し 1 回)", "unknownCommand": "不明なコマンド {{head}}。サポート対象:{{cmds}}", "userQuestionAria": "ユーザーの質問", "waitingOutput": "(PR Agent の出力を待機中…)" diff --git a/apps/desktop/src/renderer/src/i18n/locales/zh-CN.json b/apps/desktop/src/renderer/src/i18n/locales/zh-CN.json index 30b4cac8..f16d2fbc 100644 --- a/apps/desktop/src/renderer/src/i18n/locales/zh-CN.json +++ b/apps/desktop/src/renderer/src/i18n/locales/zh-CN.json @@ -47,6 +47,7 @@ "bulletDescribe": "自动生成 PR 摘要 / labels", "bulletImprove": "逐行代码改进建议(带重要度评分)", "bulletReview": "跑一次 AI review,结果落到 findings 列表", + "cacheInline": "缓存 {{n}}", "cancelQueuedTitle": "取消排队任务", "clearConfirmLabel": "清空", "clearConfirmMessage": "确定清空当前 PR 的全部 PR Agent 执行历史记录?仅影响该 PR,不可恢复。", @@ -163,7 +164,9 @@ "statusSucceeded": "完成", "stopAria": "停止", "stopTitle": "停止执行", - "tokensTitle": "输入(prompt) {{prompt}} · 输出(completion) {{completion}} tokens", + "tokensInTitle": "输入(prompt) {{n}} tokens", + "tokensOutTitle": "输出(completion) {{n}} tokens", + "turnsTitle": "模型交互轮次(每轮一次模型调用)", "unknownCommand": "未知命令 {{head}};支持:{{cmds}}", "userQuestionAria": "用户提问", "waitingOutput": "(等待 PR Agent 输出…)" diff --git a/apps/desktop/src/renderer/src/styles/_tokens.scss b/apps/desktop/src/renderer/src/styles/_tokens.scss index a4eb69f3..a27f53c3 100644 --- a/apps/desktop/src/renderer/src/styles/_tokens.scss +++ b/apps/desktop/src/renderer/src/styles/_tokens.scss @@ -48,6 +48,7 @@ $color-danger-strong: #e8512e; $color-danger-strong-fade: rgba(232, 81, 46, 0.12); // ghost 危险按钮 hover 浅红底 $color-token-in: #22c55e; // token 用量 ↑输入 (Tailwind green-500) $color-token-out: #ef4444; // token 用量 ↓输出 (Tailwind red-500) +$color-token-cache: #6cb6ff; // token 用量 缓存命中(cache_read) 柱体图标,浅蓝 // 语义色浅底 (chip / banner 背景,与对应主色同色相;chat 卡片大量复用) $color-accent-bg-hover: rgba(14, 99, 156, 0.28); // 蓝调 chip hover 加深 (rule-chip) diff --git a/apps/desktop/src/renderer/src/styles/features/chat/run.scss b/apps/desktop/src/renderer/src/styles/features/chat/run.scss index 814529c8..19524484 100644 --- a/apps/desktop/src/renderer/src/styles/features/chat/run.scss +++ b/apps/desktop/src/renderer/src/styles/features/chat/run.scss @@ -146,10 +146,22 @@ // 中性胶囊:模型 / token 用量 / 耗时共用 chat-chip + chat-chip-quiet + chat-chip-neutral; // 各自只补字体差异(下方 mono / 截断)。 .chat-run-tokens, +.chat-run-turns, .chat-run-duration { font-family: $font-mono; font-variant-numeric: tabular-nums; } +// 轮次胶囊:循环箭头图标 + 次数,图标与数字对齐 +.chat-run-turns { + display: inline-flex; + align-items: center; + gap: 3px; +} +// 输入 / 输出各自一组(箭头 + 数字[+ 缓存]):独立 hover 提示的悬浮目标,内部行内对齐 +.chat-token-grp { + display: inline-flex; + align-items: center; +} // token 用量箭头:↑输入(绿) / ↓输出(红),run 卡片与思考步骤共用 .chat-token-in { color: $color-token-in; @@ -157,6 +169,17 @@ .chat-token-out { color: $color-token-out; } +// cache_read 拆分(↑总量 后的缓存命中量):与输入数字拉开间隔,数字同色、柱体图标浅蓝并与数字对齐 +.chat-token-cache { + display: inline-flex; + align-items: center; + gap: 2px; + margin-left: 5px; + + svg { + color: $color-token-cache; + } +} // 模型名可能很长:单行截断 + 省略号,避免一个 chip 撑爆整行 .chat-run-model { max-width: 100%; diff --git a/packages/shared/src/poller-contract.ts b/packages/shared/src/poller-contract.ts index fdde9db7..ded9b84b 100644 --- a/packages/shared/src/poller-contract.ts +++ b/packages/shared/src/poller-contract.ts @@ -239,6 +239,13 @@ export interface TokenUsage { totalTokens?: number; /** 本次 run 捕获到的 LLM 调用次数(累加来源) */ calls?: number; + /** 提示缓存读取(cache_read)token 数:promptTokens 的一部分,供 UI 拆分展示「↑总量 (cache N)」。 + * CLI 路径取自 claude/codex usage,API 路径取自 litellm(Anthropic cache_read / OpenAI cached_tokens)。 + * 缺失或为 0 = 无缓存命中信息(UI 不展示该括号)。 */ + cacheReadTokens?: number; + /** 模型实际交互轮次:CLI agentic 模式为本次 run 内部累计的 num_turns(可远大于 calls); + * 其它情况回退为 LLM 调用次数(calls)。≤1 时 UI 不单独展示。 */ + turns?: number; } export interface ReviewRun {