mirror of
https://github.com/esengine/DeepSeek-Reasonix.git
synced 2026-11-07 10:45:59 -10:00
Merge commit 'refs/pr12804' into takeover/12804
This commit is contained in:
commit
9a26301d83
5 files changed
+181
-16
No files matched your search
@@ -24,8 +24,10 @@ export const EN_TRAJECTORY: Record<string, string> = {
|
||||
"系统": "System",
|
||||
"信号轨": "Signal rail",
|
||||
"与下方共用时间轴": "Shares the timeline below",
|
||||
"没有时长、也不是工具调用的运行记录(最多前 8 条),按发生的时刻摆在时间轴上,所以位置就是时间,无法拖动;点一个看它的记录。":
|
||||
"Records of the run that have no duration and are not tool calls (the first 8 at most), placed on the timeline at the moment they happened. Their position is their time, so they cannot be dragged; click one to see its record.",
|
||||
"时间轴缩放": "Timeline zoom",
|
||||
"重置缩放": "Reset zoom",
|
||||
"没有时长、也不是工具调用的运行记录,位置就是时间,无法拖动;缩放后可横向滚动,重叠信号分行显示。点一个看它的记录。":
|
||||
"Records with no duration that are not tool calls. Their position is their time, so they cannot be dragged. Zoom and scroll horizontally; overlapping signals use separate lanes. Select one to inspect its record.",
|
||||
"活动回合": "Activity rounds",
|
||||
"点击一行查看记录": "Select a row to inspect it",
|
||||
"模型回合": "Model round",
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
import { expect, it } from "vitest";
|
||||
|
||||
const CSS = import.meta.glob("./studio.css", { query: "?raw", import: "default", eager: true }) as Record<string, string>;
|
||||
|
||||
it("keeps the activity visibility floor in fixed pixels rather than timeline percentages", () => {
|
||||
const rule = Object.values(CSS)[0].match(/\.run-round-track\s*>\s*i\s*\{([^}]+)\}/)?.[1];
|
||||
expect(rule).toBeDefined();
|
||||
expect(rule).toMatch(/min-width:\s*3px\s*;/);
|
||||
});
|
||||
@@ -3454,6 +3454,7 @@ body { font-size: 14px; line-height: 1.75; }
|
||||
events and the execution graph stay in Runtime details; this surface answers
|
||||
the common question first: where did the turn spend its time? */
|
||||
.run-analysis {
|
||||
--ra-columns: 34px minmax(180px, 1fr) 180px 116px;
|
||||
--ra-model: color-mix(in srgb, var(--accent) 82%, var(--text));
|
||||
--ra-tool: color-mix(in srgb, var(--net) 78%, var(--text));
|
||||
--ra-recovery: color-mix(in srgb, var(--err) 76%, var(--text));
|
||||
@@ -3536,7 +3537,14 @@ body { font-size: 14px; line-height: 1.75; }
|
||||
.run-legend { display: flex; flex-wrap: wrap; gap: 12px; margin-top: 7px; }
|
||||
.run-legend span { display: inline-flex; align-items: center; gap: 6px; color: var(--muted); font: var(--w-reg) 10.5px var(--ui); }
|
||||
.run-legend i { width: 8px; height: 8px; border-radius: 2px; background: var(--ra-color); }
|
||||
.run-signal-line { position: relative; height: 34px; border-block: 1px dashed var(--border); background: color-mix(in srgb, var(--surface) 56%, transparent); }
|
||||
.run-timeline-controls { display: flex; align-items: center; flex-wrap: wrap; gap: 10px; margin-top: 14px; font: var(--w-reg) 11px var(--ui); }
|
||||
.run-timeline-controls label { display: flex; align-items: center; gap: 10px; }
|
||||
.run-timeline-controls input { width: 130px; accent-color: var(--accent); }
|
||||
.run-timeline-scroll { overflow-x: auto; container-type: inline-size; }
|
||||
.run-timeline-scroll:focus-visible { outline: 2px solid var(--focus); outline-offset: 2px; }
|
||||
.run-timeline-canvas .run-inspect { position: sticky; left: 16px; max-width: calc(100cqw - 76px); }
|
||||
.run-signal-track { display: grid; grid-template-columns: var(--ra-columns); gap: 12px; padding: 5px 10px; }
|
||||
.run-signal-line { grid-column: 2; position: relative; height: 34px; border-block: 1px dashed var(--border); background: color-mix(in srgb, var(--surface) 56%, transparent); }
|
||||
.run-signal-line button { position: absolute; top: 50%; width: 20px; height: 30px; translate: -50% -50%; border: 0; background: transparent; cursor: pointer; }
|
||||
.run-signal-line button::after { content: ""; position: absolute; left: 50%; top: 3px; bottom: 3px; width: 1px; background: var(--border); }
|
||||
.run-signal-line button i { position: absolute; z-index: 1; left: 50%; top: 50%; width: 9px; height: 9px; translate: -50% -50%; rotate: 45deg; border: 2px solid var(--raised); background: var(--ra-color); box-shadow: 0 0 0 1px color-mix(in srgb, var(--ra-color) 62%, var(--border)); }
|
||||
@@ -3553,11 +3561,11 @@ body { font-size: 14px; line-height: 1.75; }
|
||||
.run-rounds li[data-on] .run-round-id,
|
||||
.run-rounds li[data-on] .run-round-copy b { color: var(--text); }
|
||||
.run-rounds button {
|
||||
width: 100%; min-height: 38px; display: grid; grid-template-columns: 34px minmax(180px, 1fr) minmax(150px, .72fr) 116px;
|
||||
width: 100%; min-height: 38px; display: grid; grid-template-columns: var(--ra-columns);
|
||||
align-items: center; gap: 12px; padding: 5px 10px; border: 0; color: inherit; background: transparent; text-align: left; cursor: pointer;
|
||||
}
|
||||
.run-round-id { color: var(--muted); font: var(--w-reg) 10.5px var(--mono); }
|
||||
.run-round-track { position: relative; height: 14px; overflow: hidden; border-radius: 3px; border-left: 1px solid var(--border); background-color: var(--surface); background-image: linear-gradient(90deg, var(--hair) 1px, transparent 1px); background-size: 25% 100%; }
|
||||
.run-round-track { position: relative; height: 14px; overflow: hidden; border-radius: 3px; background-color: var(--surface); background-image: linear-gradient(90deg, var(--hair) 1px, transparent 1px); background-size: 25% 100%; }
|
||||
.run-round-track > i { position: absolute; top: 3px; height: 8px; min-width: 3px; border-radius: 2px; background: var(--ra-color, var(--ra-tool)); }
|
||||
.run-round-track > i[data-c="round"] { --ra-color: var(--ra-model); }
|
||||
.run-round-track > i[data-c="read"] { --ra-color: var(--ghost); }
|
||||
@@ -3582,7 +3590,7 @@ body { font-size: 14px; line-height: 1.75; }
|
||||
.run-kpis { grid-template-columns: repeat(3, 1fr); }
|
||||
.run-kpis > div:nth-child(3) { border-right: 0; }
|
||||
.run-kpis > div:nth-child(-n+3) { border-bottom: 1px solid var(--hair); }
|
||||
.run-rounds button { grid-template-columns: 30px minmax(120px, 1fr) minmax(120px, .8fr); }
|
||||
.run-analysis { --ra-columns: 30px minmax(120px, 1fr) 140px; }
|
||||
.run-round-kind { display: none; }
|
||||
}
|
||||
@media (max-width: 580px) {
|
||||
@@ -3591,7 +3599,7 @@ body { font-size: 14px; line-height: 1.75; }
|
||||
.run-kpis > div:nth-child(2n) { border-right: 0; }
|
||||
.run-kpis > div:nth-child(3) { border-right: 1px solid var(--hair); }
|
||||
.run-kpis > div:nth-child(-n+4) { border-bottom: 1px solid var(--hair); }
|
||||
.run-rounds button { grid-template-columns: 26px minmax(100px, 1fr); }
|
||||
.run-analysis { --ra-columns: 26px minmax(100px, 1fr); }
|
||||
.run-round-copy { display: none; }
|
||||
}
|
||||
:root[data-theme="light"] .grp,
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
// @vitest-environment jsdom
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
|
||||
import { act, cleanup, fireEvent, render, screen } from "@testing-library/react";
|
||||
import "./testkit";
|
||||
import type { TrajRow } from "../state/trajectory";
|
||||
import { RunAnalysis } from "./RunAnalysis";
|
||||
|
||||
afterEach(cleanup);
|
||||
afterEach(() => { cleanup(); vi.restoreAllMocks(); vi.unstubAllGlobals(); });
|
||||
|
||||
const rows: TrajRow[] = [
|
||||
{ seq: 1, at: 0, dur: 4, kind: "model_round", payload: [{ t: "model_round" }], subs: [] },
|
||||
@@ -84,4 +84,117 @@ describe("RunAnalysis", () => {
|
||||
fireEvent.click(container.querySelector<HTMLButtonElement>("[data-action='analysis.signal'][data-target='9']")!);
|
||||
expect(container.querySelector(".run-signals .run-inspect")).not.toBeNull();
|
||||
});
|
||||
|
||||
it("zooms one shared timeline, preserves selection and resets the scroll position", () => {
|
||||
const { container } = render(<RunAnalysis rows={rows} onSave={onSave} />);
|
||||
const slider = screen.getByRole("slider", { name: "时间轴缩放" });
|
||||
const scroller = screen.getByRole("region", { name: "时间轴" });
|
||||
const canvas = container.querySelector<HTMLElement>(".run-timeline-canvas")!;
|
||||
const signal = container.querySelector<HTMLButtonElement>("[data-action='analysis.signal']")!;
|
||||
fireEvent.click(signal);
|
||||
fireEvent.change(slider, { target: { value: "8" } });
|
||||
expect(canvas.style.width).toBe("800%");
|
||||
expect(canvas.contains(container.querySelector(".run-round-track"))).toBe(true);
|
||||
expect(canvas.contains(signal)).toBe(true);
|
||||
expect(signal.getAttribute("aria-expanded")).toBe("true");
|
||||
scroller.scrollLeft = 300;
|
||||
fireEvent.click(screen.getByRole("button", { name: "重置缩放" }));
|
||||
expect(canvas.style.width).toBe("100%");
|
||||
expect(scroller.scrollLeft).toBe(0);
|
||||
expect(signal.getAttribute("aria-expanded")).toBe("true");
|
||||
});
|
||||
|
||||
it("keeps every signal inspectable, including simultaneous and late records", () => {
|
||||
const moments: TrajRow[] = Array.from({ length: 12 }, (_, seq) => ({
|
||||
seq, at: 0, kind: "checkpoint", payload: [{ t: `signal ${seq}` }], subs: [],
|
||||
}));
|
||||
const { container } = render(<RunAnalysis rows={moments} onSave={onSave} />);
|
||||
const buttons = [...container.querySelectorAll<HTMLButtonElement>("[data-action='analysis.signal']")];
|
||||
expect(buttons).toHaveLength(12);
|
||||
expect(new Set(buttons.map((b) => b.style.top)).size).toBe(12);
|
||||
for (const [index, button] of buttons.entries()) {
|
||||
expect(button.getAttribute("aria-label")).toContain(`signal ${index}`);
|
||||
fireEvent.click(button);
|
||||
expect(container.querySelector(".run-inspect p")?.textContent).toBe(`signal ${index}`);
|
||||
}
|
||||
fireEvent.click(buttons[11]);
|
||||
expect(container.querySelector(".run-inspect")).toBeNull();
|
||||
});
|
||||
|
||||
it("handles a single zero-time signal and newly arriving records without resetting zoom", () => {
|
||||
const moment: TrajRow = { seq: 1, at: 0, kind: "note", payload: [{ t: "start" }], subs: [] };
|
||||
const { container, rerender } = render(<RunAnalysis rows={[moment]} onSave={onSave} />);
|
||||
fireEvent.change(screen.getByRole("slider"), { target: { value: "16" } });
|
||||
rerender(<RunAnalysis rows={[moment, { ...moment, seq: 2, at: 1 }]} onSave={onSave} />);
|
||||
expect((screen.getByRole("slider") as HTMLInputElement).value).toBe("16");
|
||||
expect(container.querySelector("[data-target='1']")?.getAttribute("style")).not.toMatch(/NaN|Infinity/);
|
||||
expect(container.querySelectorAll("[data-action='analysis.signal']")).toHaveLength(2);
|
||||
});
|
||||
|
||||
|
||||
it("reflows crowded lanes as the measured timeline widens without changing timestamps", () => {
|
||||
let measure = () => {};
|
||||
let width = 100;
|
||||
vi.stubGlobal("ResizeObserver", class {
|
||||
constructor(fn: () => void) { measure = fn; }
|
||||
observe() {}
|
||||
disconnect() {}
|
||||
});
|
||||
vi.spyOn(HTMLElement.prototype, "clientWidth", "get").mockImplementation(() => width);
|
||||
const moments: TrajRow[] = [1, 1.1, 10].map((at, seq) => ({
|
||||
seq, at, kind: "checkpoint", payload: [{ t: `signal ${seq}` }], subs: [],
|
||||
}));
|
||||
const { container } = render(<RunAnalysis rows={moments} onSave={onSave} />);
|
||||
const signals = [...container.querySelectorAll<HTMLButtonElement>("[data-action='analysis.signal']")];
|
||||
expect(signals[0].style.top).not.toBe(signals[1].style.top);
|
||||
const positions = signals.map((signal) => signal.style.left);
|
||||
act(() => { width = 3000; measure(); });
|
||||
expect(signals[0].style.top).toBe(signals[1].style.top);
|
||||
expect(signals.map((signal) => signal.style.left)).toEqual(positions);
|
||||
});
|
||||
|
||||
|
||||
it("keeps hit targets apart when interface zoom scales the rail's visual width", () => {
|
||||
vi.spyOn(HTMLElement.prototype, "clientWidth", "get").mockReturnValue(100);
|
||||
vi.spyOn(HTMLElement.prototype, "getBoundingClientRect").mockReturnValue({ width: 180 } as DOMRect);
|
||||
const moments: TrajRow[] = [0, 13, 100].map((at, seq) => ({
|
||||
seq, at, kind: "checkpoint", payload: [{ t: `signal ${seq}` }], subs: [],
|
||||
}));
|
||||
const { container } = render(<RunAnalysis rows={moments} onSave={onSave} />);
|
||||
const signals = [...container.querySelectorAll<HTMLButtonElement>("[data-action='analysis.signal']")];
|
||||
expect(signals[0].style.top).not.toBe(signals[1].style.top);
|
||||
});
|
||||
|
||||
it.each([1, 16])("keeps activity duration percentages exact at %s× zoom and after reset", (zoom) => {
|
||||
const timed: TrajRow[] = [
|
||||
{ seq: 1, at: 10, dur: .1, kind: "tool", tool: "bash", payload: [{ t: "short" }], subs: [] },
|
||||
{ seq: 2, at: 20, dur: 0, kind: "protocol_recovery", payload: [{ t: "retry zero" }], subs: [] },
|
||||
{ seq: 3, at: 30, kind: "protocol_recovery", payload: [{ t: "retry unknown" }], subs: [] },
|
||||
{ seq: 4, at: 40, dur: 10, kind: "model_round", payload: [{ t: "long" }], subs: [] },
|
||||
{ seq: 5, at: 100, kind: "event", payload: [], subs: [] },
|
||||
];
|
||||
const { container } = render(<RunAnalysis rows={timed} onSave={onSave} />);
|
||||
const bars = [...container.querySelectorAll<HTMLElement>(".run-round-track > i")];
|
||||
fireEvent.click(container.querySelector("[data-action='analysis.row'][data-target='1']")!);
|
||||
fireEvent.change(screen.getByRole("slider"), { target: { value: String(zoom) } });
|
||||
expect(bars.map((bar) => bar.style.left)).toEqual(["10%", "20%", "30%", "40%"]);
|
||||
expect(bars.map((bar) => bar.style.width)).toEqual(["0.1%", "0%", "0%", "10%"]);
|
||||
expect(container.querySelector<HTMLElement>(".run-timeline-canvas")?.style.width).toBe(`${zoom * 100}%`);
|
||||
expect(container.querySelector(".run-inspect p")?.textContent).toBe("short");
|
||||
fireEvent.click(screen.getByRole("button", { name: "重置缩放" }));
|
||||
expect(bars.map((bar) => bar.style.width)).toEqual(["0.1%", "0%", "0%", "10%"]);
|
||||
expect(container.querySelector(".run-inspect p")?.textContent).toBe("short");
|
||||
});
|
||||
|
||||
it("keeps zero-span recovery markers finite and duration-free at maximum zoom", () => {
|
||||
const recovery: TrajRow = { seq: 1, at: 0, dur: 0, kind: "protocol_recovery", payload: [{ t: "retry" }], subs: [] };
|
||||
const { container } = render(<RunAnalysis rows={[recovery]} onSave={onSave} />);
|
||||
fireEvent.change(screen.getByRole("slider"), { target: { value: "16" } });
|
||||
const bar = container.querySelector<HTMLElement>(".run-round-track > i")!;
|
||||
expect(bar.style.left).toBe("0%");
|
||||
expect(bar.style.width).toBe("0%");
|
||||
fireEvent.click(container.querySelector("[data-action='analysis.row']")!);
|
||||
expect(container.querySelector(".run-inspect p")?.textContent).toBe("retry");
|
||||
});
|
||||
|
||||
});
|
||||
@@ -71,9 +71,31 @@ export function RunAnalysis({ rows, availability, onSave }: {
|
||||
const rounds = activities.filter((r) => r.kind === "model_round" && r.dur);
|
||||
const tools = activities.filter((r) => r.tool);
|
||||
const recoveries = rows.filter(isRecovery);
|
||||
const signals = rows.filter((r) => !r.dur && !r.tool && r.kind !== "event").slice(0, 8);
|
||||
const signals = rows.filter((r) => !r.dur && !r.tool && r.kind !== "event");
|
||||
const first = activities.length ? Math.min(...activities.map((r) => r.at)) : 0;
|
||||
const [pick, setPick] = useState<{ seq: number; from: "row" | "signal" } | null>(null);
|
||||
const [zoom, setZoom] = useState(1);
|
||||
const [signalWidth, setSignalWidth] = useState(0);
|
||||
const timeline = useRef<HTMLDivElement>(null);
|
||||
const signalLine = useRef<HTMLDivElement>(null);
|
||||
const lanes: number[] = [];
|
||||
const placedSignals = [...signals].sort((a, b) => a.at - b.at).map((row) => {
|
||||
const left = row.at / Math.max(span, 1);
|
||||
const x = left * signalWidth;
|
||||
const free = lanes.findIndex((end) => x - end >= 22);
|
||||
const lane = free < 0 ? lanes.length : free;
|
||||
lanes[lane] = x;
|
||||
return { row, left, lane };
|
||||
});
|
||||
useLayoutEffect(() => {
|
||||
const line = signalLine.current;
|
||||
if (!line) return;
|
||||
const measure = () => setSignalWidth(line.clientWidth);
|
||||
measure();
|
||||
const observer = new ResizeObserver(measure);
|
||||
observer.observe(line);
|
||||
return () => observer.disconnect();
|
||||
}, [signals.length > 0]);
|
||||
const [saved, setSaved] = useState<string | null>(null);
|
||||
const picked = rows.find((r) => r.seq === pick?.seq) ?? null;
|
||||
const selected = pick?.from === "row" ? pick.seq : null;
|
||||
@@ -151,17 +173,26 @@ export function RunAnalysis({ rows, availability, onSave }: {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="run-timeline-controls">
|
||||
<label>{t("时间轴缩放")}<input type="range" min="1" max="16" step="1" value={zoom}
|
||||
aria-label={t("时间轴缩放")} aria-valuetext={`${zoom}×`} onChange={(e) => setZoom(Number(e.target.value))} /></label>
|
||||
<output>{zoom}×</output>
|
||||
<button className="run-export" onClick={() => { setZoom(1); if (timeline.current) timeline.current.scrollLeft = 0; }}>{t("重置缩放")}</button>
|
||||
</div>
|
||||
<div className="run-timeline-scroll" ref={timeline} role="region" aria-label={t("时间轴")} tabIndex={0}>
|
||||
<div className="run-timeline-canvas" style={{ width: `${zoom * 100}%` }}>
|
||||
{signals.length > 0 && (
|
||||
<section className="run-signals">
|
||||
<div className="run-section-head"><b>{t("信号轨")}</b><span>{t("与下方共用时间轴")}</span></div>
|
||||
<p className="run-section-note">{t("没有时长、也不是工具调用的运行记录(最多前 8 条),按发生的时刻摆在时间轴上,所以位置就是时间,无法拖动;点一个看它的记录。")}</p>
|
||||
<div className="run-signal-line">
|
||||
{signals.map((row) => (
|
||||
<button data-action="analysis.signal" data-target={String(row.seq)} key={row.seq} style={{ left: `${(row.at / Math.max(span, 1)) * 100}%` }} onClick={(e) => toggle(row.seq, "signal", e.currentTarget)} title={spanText(row.payload)}>
|
||||
<p className="run-section-note">{t("没有时长、也不是工具调用的运行记录,位置就是时间,无法拖动;缩放后可横向滚动,重叠信号分行显示。点一个看它的记录。")}</p>
|
||||
<div className="run-signal-track"><div className="run-signal-line" ref={signalLine} style={{ height: `${lanes.length * 30 + 4}px` }}>
|
||||
{placedSignals.map(({ row, left, lane }) => (
|
||||
<button data-action="analysis.signal" data-target={String(row.seq)} key={row.seq} style={{ left: `${left * 100}%`, top: `${lane * 30 + 17}px` }} onClick={(e) => toggle(row.seq, "signal", e.currentTarget)} title={spanText(row.payload)}
|
||||
aria-label={`${labelOf(row)} · +${decimals(row.at, 2)}s`} aria-expanded={pick?.from === "signal" && picked?.seq === row.seq}>
|
||||
<i data-c={bucketOf(row)} />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div></div>
|
||||
{picked && pick?.from === "signal" && <Inspect row={picked} index={activities.findIndex((r) => r.seq === picked.seq)} />}
|
||||
</section>
|
||||
)}
|
||||
@@ -171,7 +202,7 @@ export function RunAnalysis({ rows, availability, onSave }: {
|
||||
<ol>
|
||||
{activities.map((row, index) => {
|
||||
const start = (row.at / Math.max(span, 1)) * 100;
|
||||
const width = Math.max(1.2, ((row.dur ?? 0) / Math.max(span, 1)) * 100);
|
||||
const width = ((row.dur ?? 0) / Math.max(span, 1)) * 100;
|
||||
const bucket = bucketOf(row);
|
||||
const cat = row.tool ? categoryOf(row.tool) : bucket;
|
||||
return (
|
||||
@@ -188,6 +219,8 @@ export function RunAnalysis({ rows, availability, onSave }: {
|
||||
})}
|
||||
</ol>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user