Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 评审结果交互打磨(一批小优化):
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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:
Expand All @@ -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
23 changes: 21 additions & 2 deletions apps/desktop/scripts/pragent-shim/meebox_pragent_shim/usage.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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}")
13 changes: 12 additions & 1 deletion apps/desktop/src/main/services/pr-agent/usage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
}

/**
Expand All @@ -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') {
Expand All @@ -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 {
// 坏哨兵行:仍吞掉,不计数
}
Expand All @@ -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,
};
}

Expand Down
45 changes: 45 additions & 0 deletions apps/desktop/src/renderer/src/components/common/icons.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -632,6 +632,51 @@ export function SyncIcon({ size = 12 }: IconProps) {
);
}

/** 数据库柱体(Lucide database,viewBox 24):表示提示缓存命中量(cache_read) */
export function DatabaseIcon({ size = 12, className }: IconProps) {
return (
<svg
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
className={className}
>
<ellipse cx="12" cy="5" rx="9" ry="3" />
<path d="M3 5v14a9 3 0 0 0 18 0V5" />
<path d="M3 12a9 3 0 0 0 18 0" />
</svg>
);
}

/** 循环箭头(Lucide repeat,viewBox 24):表示模型交互轮次(agentic 多轮) */
export function RepeatIcon({ size = 12, className }: IconProps) {
return (
<svg
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
className={className}
>
<polyline points="17 1 21 5 17 9" />
<path d="M3 11V9a4 4 0 0 1 4-4h14" />
<polyline points="7 23 3 19 7 15" />
<path d="M21 13v2a4 4 0 0 1-4 4H3" />
</svg>
);
}

/** 齿轮(Lucide settings,viewBox 24):设置按钮 */
export function SettingsIcon({ size = 14 }: IconProps) {
return (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -33,31 +33,17 @@ export function AgentStepRow({ step }: { step: AgentStep }) {
)}
{headText && <span>{headText}</span>}
{/* 本步**单独**的 token 用量(不累计):judge / 总结 / 规划等经独立 LLM 通道的推理步带值;
与 run 卡片同款 ↑输入(绿) / ↓输出(红) 计法,靠行尾对齐。describe/review/ask 的开销在各自 run 卡片上。 */}
与 run 卡片同款 ↑输入(绿)[⛁缓存]/↓输出(红),输入输出各自独立 hover、靠行尾对齐。
describe/review/ask 的开销在各自 run 卡片上。 */}
{step.usage &&
(step.usage.promptTokens !== undefined || step.usage.completionTokens !== undefined) ? (
<span
className="chat-agent-step-tokens"
title={t('chatPane.tokensTitle', {
prompt: step.usage.promptTokens ?? '—',
completion: step.usage.completionTokens ?? '—',
})}
>
{step.usage.promptTokens !== undefined && (
<>
<span className="chat-token-in">↑</span>
{formatTokens(step.usage.promptTokens)}
</>
)}
{step.usage.promptTokens !== undefined && step.usage.completionTokens !== undefined
? ' '
: ''}
{step.usage.completionTokens !== undefined && (
<>
<span className="chat-token-out">↓</span>
{formatTokens(step.usage.completionTokens)}
</>
)}
<span className="chat-agent-step-tokens">
<TokenStat
prompt={step.usage.promptTokens}
completion={step.usage.completionTokens}
cacheRead={step.usage.cacheReadTokens}
separator=" "
/>
</span>
) : null}
</div>
Expand Down
Original file line number Diff line number Diff line change
@@ -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 }) {
Expand All @@ -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)
Expand All @@ -38,28 +40,24 @@ function RunMeta({ run, onDelete }: { run: ReviewRun; onDelete: () => void }) {
{run.model}
</span>
)}
{/* 只分别展示输入(↑prompt,绿) / 输出(↓completion,红),不显示总数。旧 run 可能只有 prompt */}
{/* 输入(↑绿)[⛁缓存]/输出(↓红):输入输出各自独立 hover;缓存为输入一部分、无命中不显示。旧 run 可能只有 prompt */}
{usage.prompt !== undefined || usage.completion !== undefined ? (
<span className="chat-chip chat-chip-quiet chat-chip-neutral chat-run-tokens">
<TokenStat
prompt={usage.prompt}
completion={usage.completion}
cacheRead={usage.cacheRead}
/>
</span>
) : null}
{/* 模型交互轮次:循环箭头图标 + 次数(取代「N 轮」文案,省空间 / 免复数);仅多轮(agentic) 时展示 */}
{usage.turns !== undefined && usage.turns > 1 ? (
<span
className="chat-chip chat-chip-quiet chat-chip-neutral chat-run-tokens"
title={t('chatPane.tokensTitle', {
prompt: usage.prompt ?? '—',
completion: usage.completion ?? '—',
})}
className="chat-chip chat-chip-quiet chat-chip-neutral chat-run-turns"
title={t('chatPane.turnsTitle')}
>
{usage.prompt !== undefined && (
<>
<span className="chat-token-in">↑</span>
{formatTokens(usage.prompt)}
</>
)}
{usage.prompt !== undefined && usage.completion !== undefined ? ' / ' : ''}
{usage.completion !== undefined && (
<>
<span className="chat-token-out">↓</span>
{formatTokens(usage.completion)}
</>
)}
<RepeatIcon />
{usage.turns}
</span>
) : null}
<span className="chat-chip chat-chip-quiet chat-chip-neutral chat-run-duration">
Expand Down
Loading
Loading