From 0dfad592bfe721c1dcf0d4b21ed3f004a8c3f9ac Mon Sep 17 00:00:00 2001 From: Yuchao Wang Date: Wed, 4 Nov 2026 07:14:54 +0000 Subject: [PATCH] feat(research): inspect and download captured Turn ranges Signed-off-by: Yuchao Wang --- .../goal-research-progress.css | 14 +++++ .../goal-research-progress.tsx | 2 +- .../research-turn-costs.tsx | 56 +++++++++++++---- .../research-usage-selection.test.mjs | 60 +++++++++++++++++++ .../research-usage-selection.ts | 48 +++++++++++++++ docs/reference/trajectory-assessment.md | 33 ++++++++++ .../research-turn-costs.mjs | 57 +++++++++++++++++- 7 files changed, 258 insertions(+), 12 deletions(-) create mode 100644 apps/presentation/dashboard/src/features/personal-workspace/research-usage-selection.test.mjs create mode 100644 apps/presentation/dashboard/src/features/personal-workspace/research-usage-selection.ts diff --git a/apps/presentation/dashboard/src/features/personal-workspace/goal-research-progress.css b/apps/presentation/dashboard/src/features/personal-workspace/goal-research-progress.css index ed00edf098..81f73e885a 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/goal-research-progress.css +++ b/apps/presentation/dashboard/src/features/personal-workspace/goal-research-progress.css @@ -165,3 +165,17 @@ .research-turn-cost-list dl { grid-template-columns: repeat(2, minmax(0, 1fr)); } .research-turn-costs-sort select { min-height: 44px; } } + +.research-usage-range { border: 1px solid var(--pw-line); border-radius: 6px; padding: 12px; min-width: 0; } +.research-usage-range legend { font-size: var(--text-body); } +.research-usage-range-controls { display: flex; flex-wrap: wrap; align-items: end; gap: 12px; } +.research-usage-range-controls label { display: grid; gap: 4px; width: auto; font-size: 12px; } +.research-usage-range input { width: 96px; min-height: 36px; padding: 4px 8px; border: 1px solid var(--pw-line); border-radius: 6px; background: var(--pw-card); color: var(--pw-text); font: inherit; } +.research-usage-range-summary { font-size: 12px; } +.research-usage-range-summary dl { display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)); gap: 12px; } +.research-usage-range-summary dt, .research-usage-range-summary p { color: var(--pw-muted); } +.research-usage-range-summary dd { margin: 4px 0; font-variant-numeric: tabular-nums; overflow-wrap: anywhere; } +@media (max-width: 600px) { + .research-usage-range input { min-height: 44px; } + .research-usage-range-summary dl { grid-template-columns: repeat(2, minmax(0, 1fr)); } +} diff --git a/apps/presentation/dashboard/src/features/personal-workspace/goal-research-progress.tsx b/apps/presentation/dashboard/src/features/personal-workspace/goal-research-progress.tsx index 17220c65c9..9252d527a6 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/goal-research-progress.tsx +++ b/apps/presentation/dashboard/src/features/personal-workspace/goal-research-progress.tsx @@ -335,7 +335,7 @@ export function ResearchProgress({pack: p, packs, selectedId, zh, onSelect, onCi : null} : null} - {usage && onUsageTurn ? : null} + {usage && onUsageTurn ? : null} {usage && measured ?
{zh ? "计数明细与来源" : "Counter details and source"}
{zh ? "捕获时间(UTC)" : "Capture window (UTC)"}
—
diff --git a/apps/presentation/dashboard/src/features/personal-workspace/research-turn-costs.tsx b/apps/presentation/dashboard/src/features/personal-workspace/research-turn-costs.tsx index 499b988055..c561efea40 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/research-turn-costs.tsx +++ b/apps/presentation/dashboard/src/features/personal-workspace/research-turn-costs.tsx @@ -1,24 +1,30 @@ import {useId, useState} from "react"; import type {TrajectorySolverUsage} from "../../../../../../loopx/control_plane/capabilities/trajectory_assessment_types.js"; +import {downloadReport} from "./report-download"; +import {exportResearchUsage, researchUsageRows, selectResearchUsage} from "./research-usage-selection"; import {ContextHelp} from "./context-help"; /** Presentation of existing Turn counters; never estimates missing usage or attributes it to control work. */ -export function ResearchTurnCosts({usage, zh, onRead}: {usage: TrajectorySolverUsage; zh: boolean; +export function ResearchTurnCosts({usage, source, zh, onRead}: {usage: TrajectorySolverUsage; zh: boolean; + source: Parameters[3]; onRead: (turnId: string, origin: HTMLButtonElement) => void}) { const id = useId(); const [sort, setSort] = useState("input"); const [page, setPage] = useState(0); - const rows: Array & {usage: TrajectorySolverUsage["turns"][number]["usage"] | null; ordinal: number; incomplete: boolean}> = usage.turns.map((turn, index) => ({...turn, ordinal: index + 1, incomplete: false})); - const tail = usage.incomplete_turn; - if (tail) rows.push({...tail, end: tail.observed_through, - elapsed_seconds: Math.max(0, (Date.parse(tail.observed_through) - Date.parse(tail.start)) / 1000), - ordinal: rows.length + 1, incomplete: true, usage: tail.usage}); - const ordered = [...rows].sort((a, b) => { + const rows = researchUsageRows(usage); + const [first, setFirst] = useState("1"); + const [last, setLast] = useState(String(rows.length)); + const [downloadFailed, setDownloadFailed] = useState(false); + const valid = Number.isInteger(Number(first)) && Number.isInteger(Number(last)) + && Number(first) >= 1 && Number(last) >= Number(first) && Number(last) <= rows.length; + const selection = valid ? selectResearchUsage(usage, Number(first), Number(last)) : null; + const selectedRows = selection?.turns ?? []; + const ordered = [...selectedRows].sort((a, b) => { const value = (row: typeof a) => sort === "elapsed" ? row.elapsed_seconds : sort === "time" ? row.ordinal : row.usage?.input_tokens ?? -1; return (sort === "time" ? value(a) - value(b) : value(b) - value(a)) || a.ordinal - b.ordinal; }); - const size = 8, pages = Math.max(1, Math.ceil(rows.length / size)), current = Math.min(page, pages - 1); + const size = 8, pages = Math.max(1, Math.ceil(selectedRows.length / size)), current = Math.min(page, pages - 1); const number = (n: number | undefined | null) => n == null ? "—" : n.toLocaleString(zh ? "zh-CN" : "en"); return
{zh ? "定位耗时与输入最多的轮次" : "Find time and input hotspots"} @@ -35,7 +41,37 @@ export function ResearchTurnCosts({usage, zh, onRead}: {usage: TrajectorySolverU

{zh ? "仅比较当前捕获内的轮次。耗时含工具、等待与编排,不是可节省时间;轮次输入不等于 LoopX 控制输入。金额未采集。" : "Compares Turns within this capture only. Elapsed time includes tools, waiting and orchestration, not avoidable time. Turn input is not LoopX control input. Dollar cost is unmeasured."}

- {rows.length ?
    + {rows.length ?
    + {zh ? "分析轮次范围" : "Analyze a Turn range"} +
    + + + + +
    + {!valid ?

    {zh ? `请选择 1 至 ${rows.length} 轮之间的有效范围。` : `Choose a valid range between Turns 1 and ${rows.length}.`}

    : null} + {selection ?
    +

    {zh ? `已选 ${selection.selected_turns} / ${rows.length} 轮 · 完成 ${selection.completed_turns} 轮 · 无计数 ${selection.turns_without_counters} 轮` + : `${selection.selected_turns} / ${rows.length} Turns selected · ${selection.completed_turns} completed · ${selection.turns_without_counters} without counters`} + {source.applicability === "historical" ? (zh ? " · 历史捕获" : " · Historical capture") : ""}

    +
    +
    {zh ? "已观测输入" : "Observed input"}
    {number(selection.observed_input_tokens)}
    +
    {zh ? "其中缓存" : "Cached subset"}
    {number(selection.observed_cached_input_tokens)}
    +
    {zh ? "未缓存" : "Uncached"}
    {number(selection.observed_uncached_input_tokens)}
    +
    {zh ? "已观测输出" : "Observed output"}
    {number(selection.observed_output_tokens)}
    +
    {zh ? "轮次耗时合计" : "Summed Turn time"}
    {(selection.observed_turn_seconds / 60).toLocaleString(zh ? "zh-CN" : "en", {maximumFractionDigits: 1})} {zh ? "分钟" : "min"}
    +
    +

    {zh ? "仅合计已观测计数;无计数不记零,未完成轮不代表最终用量。耗时不含轮次间空档。" : "Sums observed counters only. Missing counters are not zero; unfinished Turns are not final usage. Time excludes gaps between Turns."}

    +
    : null} + {downloadFailed ?

    {zh ? "无法开始下载,请重试。" : "Could not start the download. Try again."}

    : null} +
    : null} + {selectedRows.length ?
      {ordered.slice(current * size, (current + 1) * size).map(turn =>
    1. {zh ? `第 ${turn.ordinal} 轮` : `Turn ${turn.ordinal}`} {turn.incomplete ? {zh ? " · 未完成(仅已观测)" : " · Incomplete (observed only)"} : null} @@ -51,7 +87,7 @@ export function ResearchTurnCosts({usage, zh, onRead}: {usage: TrajectorySolverU
    2. )} -
    :

    {zh ? "此捕获没有可读轮次。" : "No captured Turns are available."}

    } +
: rows.length ? null :

{zh ? "此捕获没有可读轮次。" : "No captured Turns are available."}

} {pages > 1 ?