mirror of
https://github.com/esengine/DeepSeek-Reasonix.git
synced 2026-11-07 10:45:59 -10:00
Merge pull request #12179 from esengine/feat/chart-desktop-renderer
feat(desktop): draw render_chart calls as inline SVG charts in the transcript
This commit is contained in:
21 files changed
+1186
-4
No files matched your search
@@ -52,6 +52,8 @@ export const ACTIONS: UIAction[] = [
|
||||
// Completion verification is adaptive now, rather than a user-facing preset.
|
||||
// The legacy endpoint remains in the port for older clients and sessions,
|
||||
// but it is intentionally not advertised as an interface action.
|
||||
{ id: "chart.scroll", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "chart.data", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "chrome.policy", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "chrome.rail", kind: "view", target: "none", proof: "browser" },
|
||||
{ id: "appearance.theme", kind: "view", target: "none", proof: "interaction" },
|
||||
|
||||
@@ -1318,4 +1318,15 @@ export const EN: Record<string, string> = {
|
||||
"费用显示币种已设为 {mode}": "Cost display currency set to {mode}",
|
||||
"上下文已用到压缩阈值的 {percent}%,已告知模型约剩 {remaining} 个词元的空间。": "Context at {percent}% of the compaction threshold — the model was told it has about {remaining} tokens of room left.",
|
||||
"模型卡在重复输出同一段文字,这一轮已停止;可以重试、补充引导,或换一个供应商/模型": "The model is stuck repeating the same text; this turn was stopped. Try again, add guidance, or switch provider/model.",
|
||||
"图表": "Chart",
|
||||
"图表:{title}(可横向滚动)": "Chart: {title} (scrolls sideways)",
|
||||
"没有可画的数据": "Nothing to draw",
|
||||
"柱状图": "Bar chart",
|
||||
"折线图": "Line chart",
|
||||
"饼图": "Pie chart",
|
||||
"{kind}:{title},共 {n} 行": "{kind}: {title}, {n} rows",
|
||||
"查看数据": "View data",
|
||||
"收起数据": "Hide data",
|
||||
"图表数据:{title}": "Chart data: {title}",
|
||||
"另有 {n} 行": "and {n} more rows",
|
||||
};
|
||||
@@ -31,6 +31,9 @@ export const count = (n: number) => nf({ maximumFractionDigits: 0 }).format(n);
|
||||
export const decimals = (v: number, digits: number) =>
|
||||
nf({ minimumFractionDigits: digits, maximumFractionDigits: digits }).format(v);
|
||||
|
||||
/** plain is a number kept to at most n places, with none padded on. */
|
||||
export const plain = (v: number, digits: number) => nf({ maximumFractionDigits: digits }).format(v);
|
||||
|
||||
/** tokens abbreviates a count of tokens or characters. Under ten thousand it
|
||||
* keeps one decimal and above it drops to none, so the field's width stays put
|
||||
* as a turn grows. */
|
||||
|
||||
@@ -8,6 +8,7 @@ import "./styles/app.css";
|
||||
import "./styles/studio.css";
|
||||
import "./styles/feedback.css";
|
||||
import "./styles/community.css";
|
||||
import "./styles/chart.css";
|
||||
import { App } from "./ui/App";
|
||||
import { SseHub } from "./port/hub";
|
||||
import { HttpError } from "./port/http_error";
|
||||
|
||||
@@ -127,6 +127,11 @@ grid to break.
|
||||
lightness band, chroma floor, adjacent-pair colour-vision separation
|
||||
(ΔE ≥ 8), normal-vision floor (≥ 15), and contrast with the ground.
|
||||
- In light, four segments fall below 3:1 and rely on the legend's text labels.
|
||||
- Chart series take `--cat-1` … `--cat-5` by index. A sixth series onward
|
||||
repeats the ramp with a second channel (dash for lines, hatch for bars,
|
||||
lower opacity for slices) because five distinguishable hues is the ceiling,
|
||||
and every chart carries a legend or a data table so colour is never the only
|
||||
way to read a series.
|
||||
- `--lights-w` / `--lights-h` are the window corner macOS draws its controls
|
||||
over, and they are zero everywhere the shell does not hand that corner to the
|
||||
page. Whichever element occupies the corner reserves them, so a layout change
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
.call[data-k="chart"] > .g .sym { color: var(--cat-1) }
|
||||
.chart { margin: 6px 0 0; max-width: 720px }
|
||||
.chart-defs { position: absolute; width: 0; height: 0 }
|
||||
.chart-hold { height: 300px; max-width: 720px }
|
||||
.chart-scroll { position: relative }
|
||||
.chart-scroll::before, .chart-scroll::after { content: ""; position: absolute; top: 0; bottom: 0; width: 14px; pointer-events: none; opacity: 0; z-index: 1 }
|
||||
.chart-scroll::before { left: 0; background: linear-gradient(to right, var(--border), transparent) }
|
||||
.chart-scroll::after { right: 0; background: linear-gradient(to left, var(--border), transparent) }
|
||||
.chart-scroll[data-more-left]::before, .chart-scroll[data-more-right]::after { opacity: 1 }
|
||||
.chart-pane { overflow-x: auto }
|
||||
.chart-pane:focus-visible { outline: 2px solid var(--text); outline-offset: 2px }
|
||||
.chart-mark + .chart-mark { margin-top: 14px }
|
||||
.chart-svg { display: block; width: 100%; height: auto; overflow: visible }
|
||||
.chart-svg.pie { max-width: 320px }
|
||||
.chart .grid { stroke: var(--hair); stroke-width: 1 }
|
||||
.chart .base { stroke: var(--border); stroke-width: 1 }
|
||||
.chart .tick { fill: var(--faint); font: var(--w-reg) var(--fs-micro) var(--ui) }
|
||||
.chart .cs0 { --c: var(--cat-1) }
|
||||
.chart .cs1 { --c: var(--cat-2) }
|
||||
.chart .cs2 { --c: var(--cat-3) }
|
||||
.chart .cs3 { --c: var(--cat-4) }
|
||||
.chart .cs4 { --c: var(--cat-5) }
|
||||
.chart .bar rect:first-child { fill: var(--c) }
|
||||
.chart .hatch-line { stroke: var(--surface); stroke-width: 2; opacity: .7 }
|
||||
.chart .line path { fill: none; stroke: var(--c); stroke-width: 2; stroke-linejoin: round; stroke-linecap: round }
|
||||
.chart .line.sv1 path { stroke-dasharray: 7 4 }
|
||||
.chart .line.sv2 path { stroke-dasharray: 2 4 }
|
||||
.chart .line .pt { fill: var(--surface); stroke: var(--c); stroke-width: 2 }
|
||||
.chart .slice { fill: var(--c); stroke: var(--surface); stroke-width: 2 }
|
||||
.chart .slice.sv1 { fill-opacity: .72 }
|
||||
.chart .slice.sv2 { fill-opacity: .5 }
|
||||
.chart [tabindex]:focus-visible { outline: 2px solid var(--text); outline-offset: 2px }
|
||||
.chart .bar:hover rect:first-child, .chart .slice:hover { filter: brightness(1.08) }
|
||||
.chart-key { display: flex; flex-wrap: wrap; gap: 4px 14px; margin: 8px 0 0; padding: 0; list-style: none; font: var(--w-reg) var(--fs-meta) var(--ui); color: var(--faint) }
|
||||
.chart-key li { display: inline-flex; align-items: center; gap: 6px; min-width: 0 }
|
||||
.chart-key .nm { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap }
|
||||
.chart-key .v { color: var(--ghost); font-variant-numeric: tabular-nums }
|
||||
.chart-key i { flex: none; width: 10px; height: 10px; border-radius: 2px; background: var(--c) }
|
||||
.chart-key i.sv1 { background: repeating-linear-gradient(45deg, var(--c) 0 3px, transparent 3px 5px) }
|
||||
.chart-key i.sv2 { background: repeating-linear-gradient(45deg, var(--c) 0 1.5px, transparent 1.5px 4px) }
|
||||
.chart-pie { display: flex; flex-wrap: wrap; align-items: center; gap: 12px 24px }
|
||||
.chart-pie .chart-key { flex-direction: column; flex-wrap: nowrap; margin: 0 }
|
||||
.chart-axes { margin-top: 4px; font: var(--w-reg) var(--fs-meta) var(--ui); color: var(--ghost) }
|
||||
.chart-read { min-height: 1.2em; margin-top: 2px; font: var(--w-reg) var(--fs-meta) var(--ui); color: var(--text); font-variant-numeric: tabular-nums }
|
||||
.chart-data { margin-top: 0 }
|
||||
.chart-toggle { padding: 2px 0; font: var(--w-reg) var(--fs-meta) var(--ui); color: var(--faint); background: none; border: 0; cursor: pointer }
|
||||
.chart-toggle:hover { color: var(--text) }
|
||||
.chart-table { max-height: 260px; max-width: 720px; overflow: auto; margin-top: 4px; border: 1px solid var(--hair); border-radius: var(--r-sm) }
|
||||
.chart-table table { width: 100%; border-collapse: collapse; font: var(--w-reg) var(--fs-meta) var(--ui) }
|
||||
.chart-table th, .chart-table td { padding: 4px 10px; text-align: left; white-space: nowrap; border-bottom: 1px solid var(--hair) }
|
||||
.chart-table th { position: sticky; top: 0; background: var(--surface); color: var(--faint); font-weight: var(--w-med) }
|
||||
.chart-table [data-num] { text-align: right; font-variant-numeric: tabular-nums }
|
||||
.chart-more { padding: 6px 10px; font: var(--w-reg) var(--fs-meta) var(--ui); color: var(--ghost) }
|
||||
.chart-empty { padding: 24px 0; font: var(--w-reg) var(--fs-meta) var(--ui); color: var(--ghost) }
|
||||
@@ -6,6 +6,9 @@ import type { ApprovalVerdict, Checkpoint, RewindPlan, RewindResult, RewindScope
|
||||
import { RMark } from "./RMark";
|
||||
import { Await } from "./Await";
|
||||
import { ToolCard } from "./cards/ToolCard";
|
||||
import { Boundary } from "./Boundary";
|
||||
import { LazyChart } from "./chart/LazyChart";
|
||||
import { chartOfTool } from "./chart/spec";
|
||||
import { GuardianCard } from "./cards/GuardianCard";
|
||||
import { ApprovalCard, type PlanAction } from "./cards/ApprovalCard";
|
||||
import { AskCard } from "./cards/AskCard";
|
||||
@@ -680,8 +683,10 @@ const Row = memo(function Row({
|
||||
// The ask tool also raises ask_request, which carries the id /answer
|
||||
// needs. Drawing the tool call too put two copies of the same question on
|
||||
// screen, each answerable.
|
||||
return it.tool.name === "ask" ? null : (
|
||||
<ToolCard
|
||||
if (it.tool.name === "ask") return null;
|
||||
return (() => {
|
||||
const card = (
|
||||
<ToolCard
|
||||
tool={it.tool}
|
||||
running={it.running}
|
||||
activity={activity}
|
||||
@@ -691,7 +696,14 @@ const Row = memo(function Row({
|
||||
onPrepareFileRevert={onPrepareFileRevert}
|
||||
onCommitFileRevert={onCommitFileRevert}
|
||||
/>
|
||||
);
|
||||
);
|
||||
const chart = activity ? null : chartOfTool(it.tool);
|
||||
return chart ? (
|
||||
<Boundary fallback={card} retryKey={it.tool.args}>
|
||||
<LazyChart spec={chart} callId={it.tool.id} />
|
||||
</Boundary>
|
||||
) : card;
|
||||
})();
|
||||
case "reads":
|
||||
return <ReadsCard tools={it.tools} />;
|
||||
case "guardian":
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
// @vitest-environment jsdom
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import "../testkit";
|
||||
import { ChartCard } from "./ChartCard";
|
||||
import { SALES, withSpec } from "./fixtures";
|
||||
import type { ChartSpec } from "./spec";
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
const draw = (spec: ChartSpec) => render(<ChartCard spec={spec} callId="call-1" />).container;
|
||||
|
||||
const series = (): ChartSpec => withSpec({
|
||||
data: { columns: [{ name: "d", type: "date" }, { name: "v", type: "number" }], rows: [["2024-01-01", 1], ["2024-02-01", null], ["2024-03-01", 3], ["2024-04-01", 4]] },
|
||||
marks: [{ type: "line", x: "d", y: ["v"] }],
|
||||
});
|
||||
|
||||
describe("a chart card", () => {
|
||||
it("names itself, its source call and its content for assistive tech", () => {
|
||||
const box = draw(SALES);
|
||||
expect(box.querySelector(".call")?.getAttribute("data-call")).toBe("call-1");
|
||||
expect(box.querySelector(".hl .nm")?.textContent).toBe("图表");
|
||||
const label = box.querySelector("figure")?.getAttribute("aria-label") ?? "";
|
||||
expect(label).toContain("Sales");
|
||||
expect(label).toContain("3");
|
||||
});
|
||||
|
||||
it("draws one bar per row, each reachable by keyboard", async () => {
|
||||
const box = draw(SALES);
|
||||
const bars = box.querySelectorAll(".bar");
|
||||
expect(bars).toHaveLength(3);
|
||||
await userEvent.tab();
|
||||
await userEvent.tab();
|
||||
expect(document.activeElement?.getAttribute("aria-label")).toBe("revenue · Jan · 10");
|
||||
expect(box.querySelector(".chart-read")?.textContent).toBe("revenue · Jan · 10");
|
||||
});
|
||||
|
||||
it("never hides a focusable point from assistive tech and announces the readout", () => {
|
||||
for (const spec of [SALES, series(), withSpec({ marks: [{ type: "pie", x: "month", y: ["revenue"] }] })]) {
|
||||
const box = draw(spec);
|
||||
expect(box.querySelectorAll("[tabindex]").length).toBeGreaterThan(0);
|
||||
expect(box.querySelectorAll('[aria-hidden="true"] [tabindex], [aria-hidden="true"][tabindex]')).toHaveLength(0);
|
||||
expect(box.querySelector(".chart-read")?.getAttribute("aria-live")).toBe("polite");
|
||||
cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
it("says so when a pie has nothing to draw", () => {
|
||||
const box = draw(withSpec({ data: { ...SALES.data, rows: [["a", 0], ["b", null]] }, marks: [{ type: "pie", x: "month", y: ["revenue"] }] }));
|
||||
expect(box.querySelector(".chart-empty")?.textContent).toBe("没有可画的数据");
|
||||
});
|
||||
|
||||
it("keeps a plot at its drawn size and lets it scroll by keyboard instead of shrinking", () => {
|
||||
const box = draw(SALES);
|
||||
const mark = box.querySelector(".chart-pane")!;
|
||||
expect(mark.getAttribute("tabindex")).toBe("0");
|
||||
expect(mark.getAttribute("role")).toBe("region");
|
||||
expect(mark.getAttribute("aria-label")).toBe("图表:Sales(可横向滚动)");
|
||||
expect((box.querySelector(".chart-svg") as SVGElement).style.minWidth).toBe("640px");
|
||||
});
|
||||
|
||||
it("shows when there is more to scroll to, and from which side", () => {
|
||||
const size = (w: number, c: number) => {
|
||||
Object.defineProperty(HTMLElement.prototype, "scrollWidth", { configurable: true, get: () => w });
|
||||
Object.defineProperty(HTMLElement.prototype, "clientWidth", { configurable: true, get: () => c });
|
||||
};
|
||||
size(640, 320);
|
||||
const box = draw(SALES);
|
||||
const wrap = box.querySelector(".chart-scroll")!;
|
||||
expect(wrap.hasAttribute("data-more-right")).toBe(true);
|
||||
expect(wrap.hasAttribute("data-more-left")).toBe(false);
|
||||
const mark = box.querySelector(".chart-pane") as HTMLElement;
|
||||
mark.scrollLeft = 320;
|
||||
fireEvent.scroll(mark);
|
||||
expect(wrap.hasAttribute("data-more-left")).toBe(true);
|
||||
expect(wrap.hasAttribute("data-more-right")).toBe(false);
|
||||
cleanup();
|
||||
size(640, 640);
|
||||
expect(draw(SALES).querySelector(".chart-scroll")!.hasAttribute("data-more-right")).toBe(false);
|
||||
delete (HTMLElement.prototype as unknown as Record<string, unknown>).scrollWidth;
|
||||
delete (HTMLElement.prototype as unknown as Record<string, unknown>).clientWidth;
|
||||
});
|
||||
|
||||
it("breaks a line at a null instead of bridging it", () => {
|
||||
const box = draw(series());
|
||||
expect(box.querySelectorAll(".line path")).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("shows the data as a real table on request and hides it again", async () => {
|
||||
const box = draw(SALES);
|
||||
expect(box.querySelector("table")).toBeNull();
|
||||
const toggle = screen.getByRole("button", { name: "查看数据" });
|
||||
expect(toggle.getAttribute("aria-expanded")).toBe("false");
|
||||
await userEvent.click(toggle);
|
||||
expect(toggle.getAttribute("aria-expanded")).toBe("true");
|
||||
expect([...box.querySelectorAll("th")].map((h) => h.textContent)).toEqual(["month", "revenue"]);
|
||||
expect(box.querySelectorAll("tbody tr")).toHaveLength(3);
|
||||
await userEvent.click(screen.getByRole("button", { name: "收起数据" }));
|
||||
expect(box.querySelector("table")).toBeNull();
|
||||
});
|
||||
|
||||
it("caps the table and says how many rows it left out", async () => {
|
||||
const rows = Array.from({ length: 250 }, (_, i) => [`c${i}`, i]);
|
||||
const box = draw(withSpec({ data: { ...SALES.data, rows } }));
|
||||
await userEvent.click(screen.getByRole("button", { name: "查看数据" }));
|
||||
expect(box.querySelectorAll("tbody tr")).toHaveLength(200);
|
||||
expect(box.querySelector(".chart-more")?.textContent).toBe("另有 50 行");
|
||||
});
|
||||
|
||||
it("draws hostile strings as inert text", async () => {
|
||||
const evil = '<img src=x onerror=alert(1)><script>alert(1)</script>';
|
||||
const box = draw(withSpec({
|
||||
title: evil,
|
||||
data: { columns: [{ name: "m", type: "string" }, { name: "v", type: "number" }], rows: [[evil, 1]] },
|
||||
marks: [{ type: "bar", x: "m", y: ["v"] }],
|
||||
y_axis: { title: evil, unit: evil },
|
||||
}));
|
||||
await userEvent.click(screen.getByRole("button", { name: "查看数据" }));
|
||||
expect(box.querySelector("script, img, [onerror]")).toBeNull();
|
||||
expect(box.textContent).toContain("<script>");
|
||||
});
|
||||
|
||||
it("draws a pie's slices with their share and a donut with a hole", () => {
|
||||
const pie = (donut: boolean) => draw(withSpec({ marks: [{ type: "pie", x: "month", y: ["revenue"], donut }] }));
|
||||
const solid = pie(false);
|
||||
expect(solid.querySelectorAll(".slice")).toHaveLength(3);
|
||||
expect(solid.querySelector(".chart-key .v")?.textContent).toBe("16.7%");
|
||||
cleanup();
|
||||
expect(pie(true).querySelector(".slice")?.getAttribute("d")).not.toBe(solid.querySelector(".slice")?.getAttribute("d"));
|
||||
});
|
||||
|
||||
it("gives every series a name in the legend and a second channel past the fifth", () => {
|
||||
const groups = Array.from({ length: 7 }, (_, i) => `g${i}`);
|
||||
const rows = groups.map((g) => ["a", g, 1]);
|
||||
const box = draw(withSpec({
|
||||
data: { columns: [{ name: "x", type: "string" }, { name: "g", type: "string" }, { name: "v", type: "number" }], rows },
|
||||
marks: [{ type: "line", x: "x", y: ["v"], color: "g" }],
|
||||
}));
|
||||
expect(box.querySelectorAll(".chart-key li")).toHaveLength(7);
|
||||
expect(box.querySelectorAll(".line.sv1")).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("hatches bars from the sixth series on", () => {
|
||||
const groups = Array.from({ length: 7 }, (_, i) => `g${i}`);
|
||||
const box = draw(withSpec({
|
||||
data: { columns: [{ name: "x", type: "string" }, { name: "g", type: "string" }, { name: "v", type: "number" }], rows: groups.map((g) => ["a", g, 1]) },
|
||||
marks: [{ type: "bar", x: "x", y: ["v"], color: "g" }],
|
||||
}));
|
||||
expect(box.querySelectorAll(".bar .hatch")).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("stacks bars and keeps a spec with several marks as several plots", () => {
|
||||
const box = draw(withSpec({ marks: [{ type: "bar", x: "month", y: ["revenue"], stacked: true }, { type: "line", x: "month", y: ["revenue"] }] }));
|
||||
expect(box.querySelectorAll(".chart-mark")).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("carries no animation of its own", () => {
|
||||
const box = draw(SALES);
|
||||
expect(box.querySelector("animate, animateTransform, [style*='animation']")).toBeNull();
|
||||
});
|
||||
|
||||
it("handles an empty data set and an all-zero pie without throwing", () => {
|
||||
expect(() => draw(withSpec({ data: { ...SALES.data, rows: [] } }))).not.toThrow();
|
||||
cleanup();
|
||||
expect(() => draw(withSpec({ data: { ...SALES.data, rows: [["a", 0]] }, marks: [{ type: "pie", x: "month", y: ["revenue"] }] }))).not.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,113 @@
|
||||
import { memo, useCallback, useEffect, useId, useRef, useState, type ReactNode } from "react";
|
||||
import { t } from "../../i18n";
|
||||
import { Sym } from "../Sym";
|
||||
import { plotOf } from "./model";
|
||||
import { ChartTable } from "./ChartTable";
|
||||
import { BarPlot, LinePlot, PiePlot } from "./plots";
|
||||
import { clip } from "./format";
|
||||
import type { ChartMark, ChartSpec } from "./spec";
|
||||
|
||||
function describe(spec: ChartSpec): string {
|
||||
const kinds = spec.marks.map((m) => (m.type === "bar" ? t("柱状图") : m.type === "line" ? t("折线图") : t("饼图")));
|
||||
return t("{kind}:{title},共 {n} 行", { kind: [...new Set(kinds)].join(" · "), title: spec.title, n: spec.data.rows.length });
|
||||
}
|
||||
|
||||
function axes(spec: ChartSpec): string {
|
||||
const one = (a?: { title?: string; unit?: string }) => [a?.title, a?.unit && `(${a.unit})`].filter(Boolean).join(" ");
|
||||
return [one(spec.x_axis), one(spec.y_axis)].filter(Boolean).join(" × ");
|
||||
}
|
||||
|
||||
function Legend({ spec, mark }: { spec: ChartSpec; mark: ChartMark }) {
|
||||
if (mark.type === "pie") return null;
|
||||
const names = plotOf(spec, mark).series.map((s) => s.label);
|
||||
if (names.length < 2) return null;
|
||||
return (
|
||||
<ul className="chart-key">
|
||||
{names.map((n, i) => (
|
||||
<li key={n}>
|
||||
<i className={`cs${i % 5} sv${Math.min(Math.floor(i / 5), 2)}`} />
|
||||
<span className="nm">{clip(n, 28)}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
||||
const Mark = memo(function Mark({ spec, mark, hot, hatch }: { spec: ChartSpec; mark: ChartMark; hot: (s: string | null) => void; hatch: string }) {
|
||||
const frame = { spec, mark, hot, hatch };
|
||||
return mark.type === "bar" ? <BarPlot {...frame} /> : mark.type === "line" ? <LinePlot {...frame} /> : <PiePlot {...frame} />;
|
||||
});
|
||||
|
||||
function Scroller({ scrolls, label, children }: { scrolls: boolean; label: string; children: ReactNode }) {
|
||||
const box = useRef<HTMLDivElement>(null);
|
||||
const [more, setMore] = useState({ left: false, right: false });
|
||||
const measure = useCallback(() => {
|
||||
const el = box.current;
|
||||
if (!el) return;
|
||||
const next = { left: el.scrollLeft > 1, right: el.scrollLeft + el.clientWidth < el.scrollWidth - 1 };
|
||||
setMore((m) => (m.left === next.left && m.right === next.right ? m : next));
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
measure();
|
||||
const el = box.current;
|
||||
if (!el || !scrolls) return;
|
||||
const watch = new ResizeObserver(measure);
|
||||
watch.observe(el);
|
||||
return () => watch.disconnect();
|
||||
}, [measure, scrolls]);
|
||||
return (
|
||||
<div className="chart-scroll" data-more-left={more.left || undefined} data-more-right={more.right || undefined}>
|
||||
<div
|
||||
className="chart-pane"
|
||||
ref={box}
|
||||
onScroll={measure}
|
||||
data-action={scrolls ? "chart.scroll" : undefined}
|
||||
tabIndex={scrolls ? 0 : undefined}
|
||||
role={scrolls ? "region" : undefined}
|
||||
aria-label={scrolls ? label : undefined}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ChartCard({ spec, callId }: { spec: ChartSpec; callId?: string }) {
|
||||
const uid = useId();
|
||||
const [read, setRead] = useState<string | null>(null);
|
||||
const hatch = `${uid}-hatch`;
|
||||
return (
|
||||
<div className="call" data-k="chart" data-call={callId || undefined}>
|
||||
<div className="g">
|
||||
<Sym glyph="⌗" />
|
||||
<span className="line" />
|
||||
</div>
|
||||
<div className="c">
|
||||
<div className="hl">
|
||||
<span className="nm">{t("图表")}</span>
|
||||
<span className="arg">{spec.title}</span>
|
||||
</div>
|
||||
<figure className="chart" role="group" aria-label={describe(spec)}>
|
||||
<svg width="0" height="0" aria-hidden="true" className="chart-defs">
|
||||
<defs>
|
||||
<pattern id={hatch} width="6" height="6" patternUnits="userSpaceOnUse" patternTransform="rotate(45)">
|
||||
<line x1="0" y1="0" x2="0" y2="6" className="hatch-line" />
|
||||
</pattern>
|
||||
</defs>
|
||||
</svg>
|
||||
{spec.marks.map((m, i) => (
|
||||
<div className="chart-mark" key={i}>
|
||||
<Scroller scrolls={m.type !== "pie"} label={t("图表:{title}(可横向滚动)", { title: spec.title })}>
|
||||
<Mark spec={spec} mark={m} hot={setRead} hatch={hatch} />
|
||||
</Scroller>
|
||||
<Legend spec={spec} mark={m} />
|
||||
</div>
|
||||
))}
|
||||
{axes(spec) && <figcaption className="chart-axes">{axes(spec)}</figcaption>}
|
||||
<div className="chart-read" aria-live="polite">{read ?? " "}</div>
|
||||
</figure>
|
||||
<ChartTable spec={spec} id={`${uid}-data`} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { useState } from "react";
|
||||
import { t } from "../../i18n";
|
||||
import type { ChartSpec } from "./spec";
|
||||
import { cellText } from "./format";
|
||||
|
||||
const SHOWN = 200;
|
||||
|
||||
export function ChartTable({ spec, id }: { spec: ChartSpec; id: string }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const rows = spec.data.rows.slice(0, SHOWN);
|
||||
const rest = spec.data.rows.length - rows.length;
|
||||
return (
|
||||
<div className="chart-data">
|
||||
<button type="button" className="chart-toggle" data-action="chart.data" aria-expanded={open} aria-controls={id} onClick={() => setOpen(!open)}>
|
||||
{open ? t("收起数据") : t("查看数据")}
|
||||
</button>
|
||||
{open && (
|
||||
<div className="chart-table" id={id} tabIndex={0} role="region" aria-label={t("图表数据:{title}", { title: spec.title })}>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>{spec.data.columns.map((c) => <th key={c.name} scope="col" data-num={c.type === "number" || undefined}>{c.name}</th>)}</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((r, i) => (
|
||||
<tr key={i}>{r.map((v, c) => <td key={c} data-num={spec.data.columns[c].type === "number" || undefined}>{cellText(v)}</td>)}</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
{rest > 0 && <div className="chart-more">{t("另有 {n} 行", { n: rest })}</div>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { lazy, Suspense } from "react";
|
||||
import type { ChartSpec } from "./spec";
|
||||
|
||||
function loader() {
|
||||
return lazy(async () => {
|
||||
try {
|
||||
return { default: (await import("./ChartCard")).ChartCard };
|
||||
} catch (err) {
|
||||
Chart = loader();
|
||||
throw err;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
let Chart = loader();
|
||||
|
||||
export function LazyChart({ spec, callId }: { spec: ChartSpec; callId?: string }) {
|
||||
return (
|
||||
<Suspense fallback={<div className="chart-hold" />}>
|
||||
<Chart spec={spec} callId={callId} />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
// @vitest-environment jsdom
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import "../testkit";
|
||||
import { Transcript } from "../Transcript";
|
||||
import { fromHistory, type Item } from "../../state/session";
|
||||
import type { HistoryMessage } from "../../port/session";
|
||||
import { SALES, chartCall } from "./fixtures";
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
const noop = async () => undefined as never;
|
||||
function draw(items: Item[]) {
|
||||
return render(
|
||||
<Transcript items={items} entering={[]} onEntered={() => {}} revision={1} waiting={{}} scroll={{ current: null }} hidden={false}
|
||||
onPinned={() => {}} jump={0} focus={null} onApprove={noop} onFullAccess={noop} onPlan={noop} onAnswer={noop} onForget={noop}
|
||||
onExtInvoke={() => {}} onExtSubmit={noop} checkpoints={new Map()} onPrepareRewind={noop} onCommitRewind={noop} onUndoRewind={noop}
|
||||
onPrepareFileRevert={noop} onCommitFileRevert={noop} needsProject={false} onOpenProject={() => {}} onKeepHere={() => {}} />,
|
||||
).container;
|
||||
}
|
||||
|
||||
const turn = (tool: ReturnType<typeof chartCall>): Item[] => [
|
||||
{ t: "user", id: "u1", text: "chart it", authoredTurn: 1, msgIndex: 0 },
|
||||
{ t: "say", id: "s1", text: "Here you go.", done: true },
|
||||
{ t: "tool", id: "t1", running: false, children: [], tool },
|
||||
];
|
||||
|
||||
const history = (spec: unknown, failed = false): HistoryMessage[] => {
|
||||
const call = chartCall(spec);
|
||||
return [
|
||||
{ role: "user", content: "chart it", msgIndex: 0 },
|
||||
{ role: "assistant", content: "", toolCalls: [{ id: "c1", name: "use_capability", arguments: call.args, resolvedName: "render_chart", capabilityId: "tool:render_chart" }] },
|
||||
{ role: "tool", content: failed ? "chart.schema_invalid" : "chart_id: chart-abc", toolCallId: "c1", toolFailed: failed },
|
||||
] as HistoryMessage[];
|
||||
};
|
||||
|
||||
describe("a chart in the transcript", () => {
|
||||
it("is its own row, outside the folded execution group", async () => {
|
||||
const box = draw(turn(chartCall(SALES)));
|
||||
expect(await screen.findByRole("group", { name: /Sales/ })).not.toBeNull();
|
||||
expect(box.querySelector(".activity-group .chart")).toBeNull();
|
||||
expect(box.querySelector('[data-item="t1"] .chart')).not.toBeNull();
|
||||
});
|
||||
|
||||
it("falls back to the ordinary tool card while the call runs", () => {
|
||||
const box = draw(turn(chartCall(SALES, { output: undefined })));
|
||||
expect(box.querySelector(".chart")).toBeNull();
|
||||
expect(box.querySelector(".activity-group")).not.toBeNull();
|
||||
});
|
||||
|
||||
it("falls back to the ordinary tool card when the kernel refused the spec", () => {
|
||||
const box = draw(turn(chartCall({ ...SALES, marks: [] }, { err: "chart.schema_invalid" })));
|
||||
expect(box.querySelector(".chart")).toBeNull();
|
||||
expect(box.textContent).toContain("chart.schema_invalid");
|
||||
});
|
||||
|
||||
it("draws the same chart after a reload as it drew live", async () => {
|
||||
const live = draw(turn(chartCall(SALES)));
|
||||
await screen.findByRole("group", { name: /Sales/ });
|
||||
const liveSvg = live.querySelector(".chart-svg")?.innerHTML;
|
||||
cleanup();
|
||||
const rebuilt = fromHistory(history(SALES)).items;
|
||||
const box = draw(rebuilt);
|
||||
await screen.findByRole("group", { name: /Sales/ });
|
||||
expect(box.querySelector(".chart-svg")?.innerHTML).toBe(liveSvg);
|
||||
});
|
||||
|
||||
it("does not draw a rebuilt call the kernel had refused, or a hostile stored spec", () => {
|
||||
for (const items of [fromHistory(history(SALES, true)).items, fromHistory(history({ ...SALES, spec_version: 9 })).items]) {
|
||||
const box = draw(items);
|
||||
expect(box.querySelector(".chart")).toBeNull();
|
||||
cleanup();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,27 @@
|
||||
import type { Tool } from "../../port/wire";
|
||||
import type { ChartSpec } from "./spec";
|
||||
|
||||
export const SALES: ChartSpec = {
|
||||
spec_version: 1,
|
||||
title: "Sales",
|
||||
data: {
|
||||
columns: [{ name: "month", type: "string" }, { name: "revenue", type: "number" }],
|
||||
rows: [["Jan", 10], ["Feb", 30], ["Mar", 20]],
|
||||
},
|
||||
marks: [{ type: "bar", x: "month", y: ["revenue"] }],
|
||||
};
|
||||
|
||||
export const withSpec = (over: Record<string, unknown>) => ({ ...SALES, ...over }) as unknown as ChartSpec;
|
||||
|
||||
export function chartCall(spec: unknown, over: Partial<Tool> = {}): Tool {
|
||||
return {
|
||||
id: "call-1",
|
||||
name: "use_capability",
|
||||
args: JSON.stringify({ action: "call", capability_id: "tool:render_chart", arguments: spec }),
|
||||
resolvedName: "render_chart",
|
||||
capabilityId: "tool:render_chart",
|
||||
output: "chart_id: chart-abc\nchart \"Sales\", 3 rows, 2 columns",
|
||||
readOnly: true,
|
||||
...over,
|
||||
} as Tool;
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { count, plain } from "../../i18n/format";
|
||||
import type { Cell, ChartAxis } from "./spec";
|
||||
|
||||
export function formatValue(v: number, axis?: ChartAxis): string {
|
||||
const body = axis?.format === "integer" ? count(v) : plain(v, 2);
|
||||
if (axis?.format === "percent") return `${body}%`;
|
||||
return axis?.unit ? `${body} ${axis.unit}` : body;
|
||||
}
|
||||
|
||||
export const cellText = (c: Cell): string => (c === null ? "" : typeof c === "number" ? plain(c, 6) : c);
|
||||
|
||||
export function clip(s: string, max: number): string {
|
||||
const chars = [...s];
|
||||
return chars.length > max ? `${chars.slice(0, max - 1).join("")}…` : s;
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { logTicks, niceTicks, plotOf, slicesOf, stackOf, yScaleOf } from "./model";
|
||||
import type { ChartSpec } from "./spec";
|
||||
|
||||
const grouped: ChartSpec = {
|
||||
spec_version: 1,
|
||||
title: "t",
|
||||
data: {
|
||||
columns: [{ name: "d", type: "date" }, { name: "g", type: "string" }, { name: "v", type: "number" }],
|
||||
rows: [["2024-01-01", "a", 1], ["2024-01-01", "b", 2], ["2024-02-01", "a", null], ["2024-02-01", "b", 4]],
|
||||
},
|
||||
marks: [{ type: "line", x: "d", y: ["v"], color: "g" }],
|
||||
};
|
||||
|
||||
describe("plotOf", () => {
|
||||
it("splits one series per distinct color value and keeps first-seen order", () => {
|
||||
const p = plotOf(grouped, grouped.marks[0]);
|
||||
expect(p.categories).toEqual(["2024-01-01", "2024-02-01"]);
|
||||
expect(p.series.map((s) => s.label)).toEqual(["a", "b"]);
|
||||
expect(p.series[0].values).toEqual([1, null]);
|
||||
expect(p.series[1].values).toEqual([2, 4]);
|
||||
});
|
||||
|
||||
it("gives dates and numbers a position and strings none", () => {
|
||||
expect(plotOf(grouped, grouped.marks[0]).positions).toEqual([Date.parse("2024-01-01"), Date.parse("2024-02-01")]);
|
||||
const s: ChartSpec = { ...grouped, data: { columns: [{ name: "d", type: "string" }, { name: "v", type: "number" }], rows: [["a", 1]] }, marks: [{ type: "bar", x: "d", y: ["v"] }] };
|
||||
expect(plotOf(s, s.marks[0]).positions).toBeNull();
|
||||
});
|
||||
|
||||
it("keeps the last row of a repeated category", () => {
|
||||
const s: ChartSpec = { ...grouped, data: { columns: [{ name: "d", type: "string" }, { name: "v", type: "number" }], rows: [["a", 1], ["a", 9]] }, marks: [{ type: "bar", x: "d", y: ["v"] }] };
|
||||
expect(plotOf(s, s.marks[0]).series[0].values).toEqual([9]);
|
||||
});
|
||||
|
||||
it("names a series by column and value when several columns split", () => {
|
||||
const m = { ...grouped.marks[0], y: ["v", "v"] };
|
||||
expect(plotOf(grouped, m).series.map((s) => s.label)).toContain("v / a");
|
||||
});
|
||||
});
|
||||
|
||||
describe("slicesOf", () => {
|
||||
it("leaves out nulls and zeros", () => {
|
||||
const s: ChartSpec = { ...grouped, data: { columns: [{ name: "k", type: "string" }, { name: "v", type: "number" }], rows: [["a", 3], ["b", 0], ["c", null], ["d", 1]] }, marks: [{ type: "pie", x: "k", y: ["v"] }] };
|
||||
expect(slicesOf(s, s.marks[0])).toEqual([{ label: "a", value: 3 }, { label: "d", value: 1 }]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("scales", () => {
|
||||
it("picks round ticks that cover the data and reaches zero for bars", () => {
|
||||
const y = yScaleOf([13, 87], undefined, true);
|
||||
expect(y.ticks[0]).toBe(0);
|
||||
expect(y.ticks[y.ticks.length - 1]).toBeGreaterThanOrEqual(87);
|
||||
expect(y.at(0)).toBe(0);
|
||||
expect(y.at(y.hi)).toBe(1);
|
||||
});
|
||||
|
||||
it("does not stretch a constant series into nothing", () => {
|
||||
const y = yScaleOf([5, 5], undefined, false);
|
||||
expect(y.hi).toBeGreaterThan(y.lo);
|
||||
});
|
||||
|
||||
it("puts a log axis on powers of ten", () => {
|
||||
const y = yScaleOf([3, 4000], { scale: "log" }, true);
|
||||
expect(y.ticks).toEqual([1, 10, 100, 1000, 10000]);
|
||||
expect(y.at(10)).toBeCloseTo(0.25);
|
||||
});
|
||||
|
||||
it("survives values near the bottom of the float range on a log axis", () => {
|
||||
for (const tiny of [5e-324, 1e-320, 1e-310]) {
|
||||
const y = yScaleOf([tiny, 10], { scale: "log" }, false);
|
||||
expect(y.lo).toBeGreaterThan(0);
|
||||
expect(y.ticks.length).toBeLessThan(700);
|
||||
expect(Number.isFinite(y.at(tiny))).toBe(true);
|
||||
}
|
||||
expect(logTicks(0, 10).length).toBeLessThan(700);
|
||||
});
|
||||
|
||||
it("returns a tick for a degenerate range", () => {
|
||||
expect(niceTicks(2, 2)).toEqual([2]);
|
||||
expect(logTicks(5, 6).length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("stackOf", () => {
|
||||
it("stacks positives upward and negatives downward from zero", () => {
|
||||
const { lo, hi } = stackOf([{ label: "a", values: [2, -1] }, { label: "b", values: [3, -2] }]);
|
||||
expect([lo[1][0], hi[1][0]]).toEqual([2, 5]);
|
||||
expect([lo[1][1], hi[1][1]]).toEqual([-3, -1]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,141 @@
|
||||
import type { Cell, ChartAxis, ChartMark, ChartSpec } from "./spec";
|
||||
|
||||
export interface Series {
|
||||
label: string;
|
||||
/** One value per category; null is a gap. */
|
||||
values: (number | null)[];
|
||||
}
|
||||
|
||||
export interface Plot {
|
||||
categories: string[];
|
||||
/** Numeric or time position of each category, when the x column has one. */
|
||||
positions: number[] | null;
|
||||
series: Series[];
|
||||
}
|
||||
|
||||
const colIndex = (spec: ChartSpec, name: string) => spec.data.columns.findIndex((c) => c.name === name);
|
||||
const text = (c: Cell) => (c === null ? "" : String(c));
|
||||
|
||||
function position(spec: ChartSpec, x: number, c: Cell): number | null {
|
||||
if (c === null) return null;
|
||||
const type = spec.data.columns[x].type;
|
||||
if (type === "number") return typeof c === "number" ? c : null;
|
||||
if (type === "date") return Date.parse(String(c));
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Folds the rows into series over categories in first-seen order. A repeated
|
||||
* category/series pair keeps its last row: v1 specs carry no aggregation. */
|
||||
export function plotOf(spec: ChartSpec, mark: ChartMark): Plot {
|
||||
const x = colIndex(spec, mark.x);
|
||||
const color = mark.color ? colIndex(spec, mark.color) : -1;
|
||||
const cats = new Map<string, number>();
|
||||
const keys = new Map<string, Series>();
|
||||
const pos: number[] = [];
|
||||
let numeric = spec.data.columns[x].type !== "string";
|
||||
for (const row of spec.data.rows) {
|
||||
const cat = text(row[x]);
|
||||
if (!cats.has(cat)) {
|
||||
cats.set(cat, cats.size);
|
||||
const p = position(spec, x, row[x]);
|
||||
if (p === null || Number.isNaN(p)) numeric = false;
|
||||
else pos.push(p);
|
||||
}
|
||||
}
|
||||
const at = (cat: string) => cats.get(cat) as number;
|
||||
for (const row of spec.data.rows) {
|
||||
for (const y of mark.y) {
|
||||
const yi = colIndex(spec, y);
|
||||
const name = color >= 0 ? (mark.y.length > 1 ? `${y} / ${text(row[color])}` : text(row[color])) : y;
|
||||
let s = keys.get(name);
|
||||
if (!s) keys.set(name, (s = { label: name, values: Array<number | null>(cats.size).fill(null) }));
|
||||
const v = row[yi];
|
||||
const i = at(text(row[x]));
|
||||
while (s.values.length <= i) s.values.push(null);
|
||||
s.values[i] = typeof v === "number" ? v : null;
|
||||
}
|
||||
}
|
||||
const series = [...keys.values()];
|
||||
for (const s of series) while (s.values.length < cats.size) s.values.push(null);
|
||||
return { categories: [...cats.keys()], positions: numeric && pos.length === cats.size ? pos : null, series };
|
||||
}
|
||||
|
||||
export interface Slice { label: string; value: number }
|
||||
|
||||
/** Pie slices are the positive rows; nulls and zeros take no angle. */
|
||||
export function slicesOf(spec: ChartSpec, mark: ChartMark): Slice[] {
|
||||
const p = plotOf(spec, mark);
|
||||
const values = p.series[0]?.values ?? [];
|
||||
return p.categories.flatMap((label, i) => (values[i] && values[i]! > 0 ? [{ label, value: values[i]! }] : []));
|
||||
}
|
||||
|
||||
export function niceTicks(lo: number, hi: number, want = 5): number[] {
|
||||
if (!(hi > lo)) return [lo];
|
||||
const raw = (hi - lo) / Math.max(1, want);
|
||||
const mag = 10 ** Math.floor(Math.log10(raw));
|
||||
const step = [1, 2, 5, 10].map((m) => m * mag).find((s) => s >= raw) as number;
|
||||
const out: number[] = [];
|
||||
const first = Math.floor(lo / step + 1e-9);
|
||||
const last = Math.ceil(hi / step - 1e-9);
|
||||
for (let k = first; k <= last; k++) out.push(k * step);
|
||||
return out;
|
||||
}
|
||||
|
||||
const LOG_FLOOR = 1e-300;
|
||||
|
||||
export function logTicks(lo: number, hi: number): number[] {
|
||||
const out: number[] = [];
|
||||
const first = Math.ceil(Math.log10(Math.max(lo, LOG_FLOOR)));
|
||||
const last = Math.floor(Math.log10(Math.max(hi, LOG_FLOOR)) + 1e-9);
|
||||
for (let e = first; e <= last; e++) out.push(10 ** e);
|
||||
return out.length ? out : [lo, hi];
|
||||
}
|
||||
|
||||
export interface YScale {
|
||||
lo: number;
|
||||
hi: number;
|
||||
log: boolean;
|
||||
ticks: number[];
|
||||
at: (v: number) => number;
|
||||
}
|
||||
|
||||
/** Maps a value to 0 (bottom) .. 1 (top). Bars pass zero so their base is
|
||||
* always drawn; a log scale cannot hold zero and starts at its smallest value. */
|
||||
export function yScaleOf(values: number[], axis: ChartAxis | undefined, includeZero: boolean): YScale {
|
||||
const log = axis?.scale === "log";
|
||||
let lo = Math.min(...values);
|
||||
let hi = Math.max(...values);
|
||||
if (!Number.isFinite(lo)) { lo = 0; hi = 1; }
|
||||
if (log) {
|
||||
lo = Math.max(lo, LOG_FLOOR);
|
||||
hi = Math.max(hi, lo);
|
||||
lo = 10 ** Math.floor(Math.log10(lo));
|
||||
hi = 10 ** Math.ceil(Math.log10(hi));
|
||||
if (hi <= lo) hi = lo * 10;
|
||||
const span = Math.log10(hi / lo);
|
||||
return { lo, hi, log, ticks: logTicks(lo, hi), at: (v) => Math.log10(Math.max(v, lo) / lo) / span };
|
||||
}
|
||||
if (includeZero) { lo = Math.min(lo, 0); hi = Math.max(hi, 0); }
|
||||
if (hi === lo) hi = lo + 1;
|
||||
const ticks = niceTicks(lo, hi);
|
||||
lo = Math.min(lo, ticks[0]);
|
||||
hi = Math.max(hi, ticks[ticks.length - 1]);
|
||||
return { lo, hi, log, ticks, at: (v) => (v - lo) / (hi - lo) };
|
||||
}
|
||||
|
||||
/** Stacked bars: positives climb from zero, negatives descend from it. */
|
||||
export function stackOf(series: Series[]): { lo: number[][]; hi: number[][] } {
|
||||
const n = series[0]?.values.length ?? 0;
|
||||
const lo = series.map(() => Array<number>(n).fill(0));
|
||||
const hi = series.map(() => Array<number>(n).fill(0));
|
||||
for (let i = 0; i < n; i++) {
|
||||
let up = 0;
|
||||
let down = 0;
|
||||
series.forEach((s, k) => {
|
||||
const v = s.values[i] ?? 0;
|
||||
if (v >= 0) { lo[k][i] = up; up += v; hi[k][i] = up; }
|
||||
else { hi[k][i] = down; down += v; lo[k][i] = down; }
|
||||
});
|
||||
}
|
||||
return { lo, hi };
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
import type { ChartMark, ChartSpec } from "./spec";
|
||||
import { plotOf, slicesOf, stackOf, yScaleOf, type Plot, type YScale } from "./model";
|
||||
import { clip, formatValue } from "./format";
|
||||
import { t } from "../../i18n";
|
||||
import { count, pct } from "../../i18n/format";
|
||||
|
||||
export const W = 640;
|
||||
export const H = 280;
|
||||
const TOP = 14;
|
||||
const BOTTOM = 30;
|
||||
const RIGHT = 14;
|
||||
const FOCUSABLE_MAX = 60;
|
||||
|
||||
export type Hot = (text: string | null) => void;
|
||||
|
||||
interface Frame {
|
||||
spec: ChartSpec;
|
||||
mark: ChartMark;
|
||||
hot: Hot;
|
||||
hatch: string;
|
||||
}
|
||||
|
||||
const seriesClass = (i: number) => `cs${i % 5} sv${Math.min(Math.floor(i / 5), 2)}`;
|
||||
|
||||
function pointProps(label: string, hot: Hot, focusable: boolean) {
|
||||
return {
|
||||
tabIndex: focusable ? 0 : undefined,
|
||||
"aria-label": label,
|
||||
role: "img" as const,
|
||||
onMouseEnter: () => hot(label),
|
||||
onMouseLeave: () => hot(null),
|
||||
onFocus: () => hot(label),
|
||||
onBlur: () => hot(null),
|
||||
};
|
||||
}
|
||||
|
||||
function Axes({ y, left, spec }: { y: YScale; left: number; spec: ChartSpec }) {
|
||||
const base = H - BOTTOM;
|
||||
const at = (v: number) => base - y.at(v) * (base - TOP);
|
||||
return (
|
||||
<g className="ax" aria-hidden="true">
|
||||
{y.ticks.map((v) => (
|
||||
<g key={v}>
|
||||
<line className="grid" x1={left} x2={W - RIGHT} y1={at(v)} y2={at(v)} />
|
||||
<text className="tick" x={left - 6} y={at(v)} textAnchor="end" dominantBaseline="middle">{formatValue(v, { ...spec.y_axis, unit: undefined })}</text>
|
||||
</g>
|
||||
))}
|
||||
<line className="base" x1={left} x2={W - RIGHT} y1={base} y2={base} />
|
||||
</g>
|
||||
);
|
||||
}
|
||||
|
||||
function leftMargin(y: YScale, spec: ChartSpec): number {
|
||||
const widest = Math.max(...y.ticks.map((v) => formatValue(v, { ...spec.y_axis, unit: undefined }).length));
|
||||
return Math.min(120, 14 + widest * 6.6);
|
||||
}
|
||||
|
||||
function XLabels({ cats, xs, left }: { cats: string[]; xs: number[]; left: number }) {
|
||||
const room = (W - RIGHT - left) / Math.max(1, cats.length);
|
||||
const widest = Math.min(14, Math.max(...cats.map((c) => [...c].length)));
|
||||
const every = Math.max(1, Math.ceil((widest * 6.4 + 10) / room));
|
||||
return (
|
||||
<g className="xl" aria-hidden="true">
|
||||
{cats.map((c, i) => (i % every === 0 ? (
|
||||
<text key={i} className="tick" x={xs[i]} y={H - BOTTOM + 16} textAnchor="middle">{clip(c, 14)}</text>
|
||||
) : null))}
|
||||
</g>
|
||||
);
|
||||
}
|
||||
|
||||
export function BarPlot({ spec, mark, hot, hatch }: Frame) {
|
||||
const p = plotOf(spec, mark);
|
||||
const stack = mark.stacked ? stackOf(p.series) : null;
|
||||
const all = stack ? [...stack.lo.flat(), ...stack.hi.flat()] : p.series.flatMap((s) => s.values.filter((v): v is number => v !== null));
|
||||
const y = yScaleOf(all, spec.y_axis, true);
|
||||
const left = leftMargin(y, spec);
|
||||
const base = H - BOTTOM;
|
||||
const band = (W - RIGHT - left) / Math.max(1, p.categories.length);
|
||||
const slot = stack ? band * 0.7 : (band * 0.76) / Math.max(1, p.series.length);
|
||||
const at = (v: number) => base - y.at(v) * (base - TOP);
|
||||
const zero = y.log ? base : at(0);
|
||||
const focusable = p.categories.length * p.series.length <= FOCUSABLE_MAX;
|
||||
const xs = p.categories.map((_, i) => left + band * (i + 0.5));
|
||||
return (
|
||||
<svg viewBox={`0 0 ${W} ${H}`} className="chart-svg" style={{ minWidth: W }}>
|
||||
<Axes y={y} left={left} spec={spec} />
|
||||
{p.series.map((s, k) => s.values.map((v, i) => {
|
||||
if (v === null) return null;
|
||||
const a = stack ? at(stack.hi[k][i]) : at(v);
|
||||
const b = stack ? at(stack.lo[k][i]) : zero;
|
||||
const x = stack ? xs[i] - slot / 2 : left + band * i + band * 0.12 + slot * k;
|
||||
const label = `${s.label} · ${p.categories[i]} · ${formatValue(v, spec.y_axis)}`;
|
||||
return (
|
||||
<g key={`${k}:${i}`} className={`bar ${seriesClass(k)}`} {...pointProps(label, hot, focusable)}>
|
||||
<rect x={x} y={Math.min(a, b)} width={Math.max(1, slot - 1)} height={Math.max(1, Math.abs(b - a))} />
|
||||
{k >= 5 && <rect className="hatch" x={x} y={Math.min(a, b)} width={Math.max(1, slot - 1)} height={Math.max(1, Math.abs(b - a))} fill={`url(#${hatch})`} />}
|
||||
</g>
|
||||
);
|
||||
}))}
|
||||
<XLabels cats={p.categories} xs={xs} left={left} />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function xPositions(p: Plot, left: number): number[] {
|
||||
const right = W - RIGHT;
|
||||
if (!p.positions) return p.categories.map((_, i) => (p.categories.length === 1 ? (left + right) / 2 : left + 12 + ((right - left - 24) * i) / (p.categories.length - 1)));
|
||||
const lo = Math.min(...p.positions);
|
||||
const hi = Math.max(...p.positions);
|
||||
return p.positions.map((v) => (hi === lo ? (left + right) / 2 : left + 12 + ((v - lo) / (hi - lo)) * (right - left - 24)));
|
||||
}
|
||||
|
||||
export function LinePlot({ spec, mark, hot }: Frame) {
|
||||
const p = plotOf(spec, mark);
|
||||
const y = yScaleOf(p.series.flatMap((s) => s.values.filter((v): v is number => v !== null)), spec.y_axis, false);
|
||||
const left = leftMargin(y, spec);
|
||||
const base = H - BOTTOM;
|
||||
const at = (v: number) => base - y.at(v) * (base - TOP);
|
||||
const xs = xPositions(p, left);
|
||||
const order = p.positions ? p.categories.map((_, i) => i).sort((a, b) => p.positions![a] - p.positions![b]) : p.categories.map((_, i) => i);
|
||||
const dots = p.categories.length * p.series.length <= FOCUSABLE_MAX;
|
||||
return (
|
||||
<svg viewBox={`0 0 ${W} ${H}`} className="chart-svg" style={{ minWidth: W }}>
|
||||
<Axes y={y} left={left} spec={spec} />
|
||||
{p.series.map((s, k) => {
|
||||
const runs: string[] = [];
|
||||
let run = "";
|
||||
for (const i of order) {
|
||||
const v = s.values[i];
|
||||
if (v === null) { if (run) runs.push(run); run = ""; continue; }
|
||||
run += `${run ? "L" : "M"}${xs[i].toFixed(1)} ${at(v).toFixed(1)}`;
|
||||
}
|
||||
if (run) runs.push(run);
|
||||
return (
|
||||
<g key={k} className={`line ${seriesClass(k)}`}>
|
||||
{runs.map((d, r) => <path key={r} d={d} />)}
|
||||
{s.values.map((v, i) => {
|
||||
if (v === null) return null;
|
||||
const label = `${s.label} · ${p.categories[i]} · ${formatValue(v, spec.y_axis)}`;
|
||||
return dots
|
||||
? <circle key={i} className="pt" cx={xs[i]} cy={at(v)} r={3.4} {...pointProps(label, hot, true)} />
|
||||
: null;
|
||||
})}
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
<XLabels cats={order.map((i) => p.categories[i])} xs={order.map((i) => xs[i])} left={left} />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
const TAU = Math.PI * 2;
|
||||
|
||||
function arc(cx: number, cy: number, r: number, inner: number, a0: number, a1: number): string {
|
||||
const pt = (rad: number, a: number) => `${(cx + rad * Math.sin(a)).toFixed(2)} ${(cy - rad * Math.cos(a)).toFixed(2)}`;
|
||||
if (a1 - a0 >= TAU - 1e-6) {
|
||||
const ring = (rad: number) => `M${cx} ${cy - rad}A${rad} ${rad} 0 1 1 ${cx} ${cy + rad}A${rad} ${rad} 0 1 1 ${cx} ${cy - rad}`;
|
||||
return inner ? `${ring(r)}${ring(inner)}` : ring(r);
|
||||
}
|
||||
const big = a1 - a0 > Math.PI ? 1 : 0;
|
||||
const outer = `M${pt(r, a0)}A${r} ${r} 0 ${big} 1 ${pt(r, a1)}`;
|
||||
return inner ? `${outer}L${pt(inner, a1)}A${inner} ${inner} 0 ${big} 0 ${pt(inner, a0)}Z` : `${outer}L${cx} ${cy}Z`;
|
||||
}
|
||||
|
||||
export function PiePlot({ spec, mark, hot }: Frame) {
|
||||
const slices = slicesOf(spec, mark);
|
||||
const total = slices.reduce((n, s) => n + s.value, 0);
|
||||
if (slices.length === 0) return <div className="chart-empty">{t("没有可画的数据")}</div>;
|
||||
const r = 104;
|
||||
let angle = 0;
|
||||
return (
|
||||
<div className="chart-pie">
|
||||
<svg viewBox={`0 0 ${W / 2} ${H}`} className="chart-svg pie">
|
||||
{slices.map((s, i) => {
|
||||
const a0 = angle;
|
||||
angle += (s.value / total) * TAU;
|
||||
const label = `${s.label} · ${formatValue(s.value, spec.y_axis)} · ${pct(s.value / total, 1)}`;
|
||||
return (
|
||||
<path key={i} className={`slice ${seriesClass(i)}`} d={arc(W / 4, H / 2, r, mark.donut ? r * 0.58 : 0, a0, angle)} fillRule="evenodd" {...pointProps(label, hot, slices.length <= FOCUSABLE_MAX)} />
|
||||
);
|
||||
})}
|
||||
</svg>
|
||||
<ul className="chart-key">
|
||||
{slices.slice(0, 12).map((s, i) => (
|
||||
<li key={i}>
|
||||
<i className={seriesClass(i)} />
|
||||
<span className="nm">{clip(s.label, 24)}</span>
|
||||
<span className="v">{pct(s.value / total, 1)}</span>
|
||||
</li>
|
||||
))}
|
||||
{slices.length > 12 && <li className="more">{`+${count(slices.length - 12)}`}</li>}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { chartOfTool, readChartSpec } from "./spec";
|
||||
import { SALES, chartCall, withSpec } from "./fixtures";
|
||||
|
||||
const rows = (n: number) => Array.from({ length: n }, (_, i) => [`c${i}`, i]);
|
||||
|
||||
describe("readChartSpec", () => {
|
||||
it("accepts a spec the kernel would accept", () => {
|
||||
expect(readChartSpec(structuredClone(SALES))?.title).toBe("Sales");
|
||||
});
|
||||
|
||||
const bad: [string, unknown][] = [
|
||||
["a future version", { ...SALES, spec_version: 2 }],
|
||||
["markup-free but oversized title", withSpec({ title: "x".repeat(65) })],
|
||||
["a control character in a label", withSpec({ title: `a${String.fromCharCode(7)}b` })],
|
||||
["a bidi override in a label", withSpec({ title: `a${String.fromCharCode(0x202e)}b` })],
|
||||
["an unknown mark", withSpec({ marks: [{ type: "radar", x: "month", y: ["revenue"] }] })],
|
||||
["a mark naming a column nobody declared", withSpec({ marks: [{ type: "bar", x: "nope", y: ["revenue"] }] })],
|
||||
["a non-numeric y column", withSpec({ marks: [{ type: "bar", x: "month", y: ["month"] }] })],
|
||||
["a stacked line", withSpec({ marks: [{ type: "line", x: "month", y: ["revenue"], stacked: true }] })],
|
||||
["a pie with two series", withSpec({ marks: [{ type: "pie", x: "month", y: ["revenue", "revenue"] }] })],
|
||||
["a negative pie value", withSpec({ data: { ...SALES.data, rows: [["a", -1]] }, marks: [{ type: "pie", x: "month", y: ["revenue"] }] })],
|
||||
["zero on a log axis", withSpec({ data: { ...SALES.data, rows: [["a", 0]] }, y_axis: { scale: "log" } })],
|
||||
["duplicate column names", withSpec({ data: { columns: [{ name: "a", type: "string" }, { name: "a", type: "number" }], rows: [] } })],
|
||||
["a row of the wrong width", withSpec({ data: { ...SALES.data, rows: [["a"]] } })],
|
||||
["a string in a number column", withSpec({ data: { ...SALES.data, rows: [["a", "1"]] } })],
|
||||
["an integer past 2^53", withSpec({ data: { ...SALES.data, rows: [["a", 2 ** 63]] } })],
|
||||
["a non-finite number", withSpec({ data: { ...SALES.data, rows: [["a", Infinity]] } })],
|
||||
["a nested cell", withSpec({ data: { ...SALES.data, rows: [["a", [1]]] } })],
|
||||
["an unparseable date", withSpec({ data: { columns: [{ name: "d", type: "date" }, { name: "v", type: "number" }], rows: [["soon", 1]] }, marks: [{ type: "line", x: "d", y: ["v"] }] })],
|
||||
["more rows than the cap", withSpec({ data: { ...SALES.data, rows: rows(5001) } })],
|
||||
["more points than the cap", withSpec({ data: { ...SALES.data, rows: rows(2001) } })],
|
||||
["more series than the cap", withSpec({ data: { columns: [{ name: "x", type: "string" }, { name: "g", type: "string" }, { name: "v", type: "number" }], rows: Array.from({ length: 13 }, (_, i) => ["a", `g${i}`, i]) }, marks: [{ type: "line", x: "x", y: ["v"], color: "g" }] })],
|
||||
["no marks", withSpec({ marks: [] })],
|
||||
["a bad axis format", withSpec({ x_axis: { format: "%d" } })],
|
||||
];
|
||||
it.each(bad)("refuses %s", (_name, spec) => {
|
||||
expect(readChartSpec(spec)).toBeNull();
|
||||
});
|
||||
|
||||
it("refuses non-objects and nesting without throwing", () => {
|
||||
for (const v of [null, 1, "x", [], { spec_version: 1, data: 5 }]) expect(readChartSpec(v)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("chartOfTool", () => {
|
||||
it("reads the spec out of a use_capability call", () => {
|
||||
expect(chartOfTool(chartCall(SALES))?.title).toBe("Sales");
|
||||
});
|
||||
|
||||
it("reads a direct render_chart call too", () => {
|
||||
const tool = chartCall(SALES, { name: "render_chart", args: JSON.stringify(SALES), resolvedName: undefined });
|
||||
expect(chartOfTool(tool)?.title).toBe("Sales");
|
||||
});
|
||||
|
||||
it("draws nothing for a call that is still running", () => {
|
||||
expect(chartOfTool(chartCall(SALES, { output: undefined }))).toBeNull();
|
||||
});
|
||||
|
||||
it("draws nothing for a call the kernel refused", () => {
|
||||
expect(chartOfTool(chartCall(SALES, { err: "chart.schema_invalid", output: "chart.schema_invalid" }))).toBeNull();
|
||||
});
|
||||
|
||||
it("draws nothing for a lookup of the tool rather than a call", () => {
|
||||
for (const action of ["search", "inspect"]) {
|
||||
const tool = chartCall(SALES, { args: JSON.stringify({ action, capability_id: "tool:render_chart", arguments: SALES }) });
|
||||
expect(chartOfTool(tool), action).toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
it("draws nothing for other tools or malformed arguments", () => {
|
||||
expect(chartOfTool(chartCall(SALES, { resolvedName: "web_fetch" }))).toBeNull();
|
||||
expect(chartOfTool(chartCall(SALES, { args: "{not json" }))).toBeNull();
|
||||
expect(chartOfTool(chartCall({ ...SALES, extra: 1, marks: [] }))).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,128 @@
|
||||
import type { Tool } from "../../port/wire";
|
||||
|
||||
export const CHART_TOOL = "render_chart";
|
||||
|
||||
export const MAX_ROWS = 5000;
|
||||
export const MAX_COLUMNS = 32;
|
||||
export const MAX_SERIES = 12;
|
||||
export const MAX_POINTS = 2000;
|
||||
export const MAX_MARKS = 8;
|
||||
export const MAX_LABEL = 64;
|
||||
|
||||
export type ColumnType = "number" | "string" | "date";
|
||||
export type MarkType = "bar" | "line" | "pie";
|
||||
export type Cell = number | string | null;
|
||||
|
||||
export interface ChartColumn {
|
||||
name: string;
|
||||
type: ColumnType;
|
||||
}
|
||||
|
||||
export interface ChartMark {
|
||||
type: MarkType;
|
||||
x: string;
|
||||
y: string[];
|
||||
color?: string;
|
||||
stacked?: boolean;
|
||||
donut?: boolean;
|
||||
}
|
||||
|
||||
export interface ChartAxis {
|
||||
title?: string;
|
||||
unit?: string;
|
||||
scale?: "linear" | "log";
|
||||
format?: "integer" | "decimal" | "percent";
|
||||
}
|
||||
|
||||
export interface ChartData {
|
||||
columns: ChartColumn[];
|
||||
rows: Cell[][];
|
||||
}
|
||||
|
||||
export interface ChartSpec {
|
||||
spec_version: 1;
|
||||
title: string;
|
||||
data: ChartData;
|
||||
marks: ChartMark[];
|
||||
x_axis?: ChartAxis;
|
||||
y_axis?: ChartAxis;
|
||||
}
|
||||
|
||||
const isObject = (v: unknown): v is Record<string, unknown> => typeof v === "object" && v !== null && !Array.isArray(v);
|
||||
const CONTROL = /[\p{Cc}\p{Cf}\p{Zl}\p{Zp}]/u;
|
||||
|
||||
function label(v: unknown, required = false): v is string {
|
||||
if (v === undefined) return !required;
|
||||
return typeof v === "string" && [...v].length <= MAX_LABEL && !CONTROL.test(v);
|
||||
}
|
||||
|
||||
function axis(v: unknown): ChartAxis | undefined | false {
|
||||
if (v === undefined) return undefined;
|
||||
if (!isObject(v) || !label(v.title) || !label(v.unit)) return false;
|
||||
if (v.scale !== undefined && v.scale !== "linear" && v.scale !== "log") return false;
|
||||
if (v.format !== undefined && v.format !== "integer" && v.format !== "decimal" && v.format !== "percent") return false;
|
||||
return v as ChartAxis;
|
||||
}
|
||||
|
||||
function column(c: unknown): c is ChartColumn {
|
||||
return isObject(c) && label(c.name, true) && c.name !== "" && (c.type === "number" || c.type === "string" || c.type === "date");
|
||||
}
|
||||
|
||||
function cell(v: unknown, type: ColumnType): v is Cell {
|
||||
if (v === null) return true;
|
||||
if (type === "number") return typeof v === "number" && Number.isFinite(v) && Math.abs(v) <= 2 ** 53;
|
||||
if (typeof v !== "string" || !label(v, true)) return false;
|
||||
return type !== "date" || !Number.isNaN(Date.parse(v));
|
||||
}
|
||||
|
||||
function mark(m: unknown, cols: Map<string, ColumnType>, rows: Cell[][], yScale: string | undefined): m is ChartMark {
|
||||
if (!isObject(m) || (m.type !== "bar" && m.type !== "line" && m.type !== "pie")) return false;
|
||||
if ((m.stacked && m.type !== "bar") || (m.donut && m.type !== "pie")) return false;
|
||||
if (typeof m.x !== "string" || !cols.has(m.x)) return false;
|
||||
if (!Array.isArray(m.y) || m.y.length === 0 || m.y.some((y) => typeof y !== "string" || cols.get(y) !== "number")) return false;
|
||||
if (m.type === "pie" && (m.y.length !== 1 || m.color !== undefined || yScale === "log")) return false;
|
||||
let series = m.y.length;
|
||||
if (m.color !== undefined) {
|
||||
if (typeof m.color !== "string" || cols.get(m.color) !== "string") return false;
|
||||
const at = [...cols.keys()].indexOf(m.color);
|
||||
series *= new Set(rows.map((r) => r[at]).filter((v) => typeof v === "string")).size;
|
||||
}
|
||||
if (series > MAX_SERIES || rows.length * m.y.length > MAX_POINTS) return false;
|
||||
const ys = (m.y as string[]).map((y) => [...cols.keys()].indexOf(y));
|
||||
return rows.every((r) => ys.every((i) => {
|
||||
const v = r[i];
|
||||
return typeof v !== "number" || !((m.type === "pie" && v < 0) || (yScale === "log" && v <= 0));
|
||||
}));
|
||||
}
|
||||
|
||||
/** Re-checks a spec the way the kernel's validator does. A stored call is
|
||||
* untrusted input, so a spec that fails here is never drawn. */
|
||||
export function readChartSpec(raw: unknown): ChartSpec | null {
|
||||
if (!isObject(raw) || raw.spec_version !== 1 || !label(raw.title, true)) return null;
|
||||
const x = axis(raw.x_axis);
|
||||
const y = axis(raw.y_axis);
|
||||
if (x === false || y === false || !isObject(raw.data)) return null;
|
||||
const { columns, rows } = raw.data;
|
||||
if (!Array.isArray(columns) || columns.length === 0 || columns.length > MAX_COLUMNS || !columns.every(column)) return null;
|
||||
const types = new Map(columns.map((c) => [c.name, c.type] as const));
|
||||
if (types.size !== columns.length || !Array.isArray(rows) || rows.length > MAX_ROWS) return null;
|
||||
if (!rows.every((r) => Array.isArray(r) && r.length === columns.length && r.every((v, i) => cell(v, columns[i].type)))) return null;
|
||||
const marks = raw.marks;
|
||||
if (!Array.isArray(marks) || marks.length === 0 || marks.length > MAX_MARKS) return null;
|
||||
if (!marks.every((m) => mark(m, types, rows as Cell[][], y?.scale))) return null;
|
||||
return raw as unknown as ChartSpec;
|
||||
}
|
||||
|
||||
/** The chart a tool call carries, when it is a settled render_chart. A call
|
||||
* that failed, is still running, or only looked the tool up draws nothing. */
|
||||
export function chartOfTool(tool: Tool): ChartSpec | null {
|
||||
if (tool.err || tool.output === undefined) return null;
|
||||
if (tool.name !== CHART_TOOL && tool.resolvedName !== CHART_TOOL) return null;
|
||||
try {
|
||||
const args = JSON.parse(tool.args ?? "");
|
||||
if (tool.name === CHART_TOOL) return readChartSpec(args);
|
||||
return isObject(args) && args.action === "call" ? readChartSpec(args.arguments) : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { Item } from "../state/session";
|
||||
import { chartOfTool } from "./chart/spec";
|
||||
|
||||
type Row = { item: Item; activity?: Item[] } | { activity: Item[] };
|
||||
|
||||
@@ -23,7 +24,7 @@ export function transcriptRows(all: Item[]): Row[] {
|
||||
while (end < items.length && items[end].t !== "user") end++;
|
||||
const turn: Row[] = [];
|
||||
for (const item of items.slice(at, end)) {
|
||||
const work = item.t === "tool" || item.t === "reads";
|
||||
const work = (item.t === "tool" && !chartOfTool(item.tool)) || item.t === "reads";
|
||||
const last = turn[turn.length - 1];
|
||||
if (work && last && "activity" in last && !("item" in last)) last.activity.push(item);
|
||||
else turn.push(work ? { activity: [item] } : { item });
|
||||
|
||||
@@ -25,6 +25,7 @@ const (
|
||||
tsProviderFile = "desktop/frontend-next/src/port/provider.ts"
|
||||
tsFeedbackFile = "desktop/frontend-next/src/port/feedback.ts"
|
||||
tsWorkspaceFile = "desktop/frontend-next/src/port/workspace.ts"
|
||||
tsChartFile = "desktop/frontend-next/src/ui/chart/spec.ts"
|
||||
)
|
||||
|
||||
// mirroredWireTypes are the Go types the desktop keeps a second, hand-written
|
||||
@@ -145,6 +146,13 @@ var mirroredWireTypes = []wireMirror{
|
||||
{"internal/session/control/commit.go", "CommitRequest", tsWorkspaceFile, "CommitRequest"},
|
||||
{"internal/session/control/commit.go", "CommitResult", tsWorkspaceFile, "CommitResult"},
|
||||
{"internal/platform/gitcommit/gitcommit.go", "File", tsWorkspaceFile, "CommitFile"},
|
||||
// The chart spec a stored render_chart call carries. The page re-validates it
|
||||
// on load, so a field it cannot read is one the card silently drops.
|
||||
{"internal/contract/chartspec/spec.go", "Spec", tsChartFile, "ChartSpec"},
|
||||
{"internal/contract/chartspec/spec.go", "Data", tsChartFile, "ChartData"},
|
||||
{"internal/contract/chartspec/spec.go", "Column", tsChartFile, "ChartColumn"},
|
||||
{"internal/contract/chartspec/spec.go", "Mark", tsChartFile, "ChartMark"},
|
||||
{"internal/contract/chartspec/spec.go", "Axis", tsChartFile, "ChartAxis"},
|
||||
}
|
||||
|
||||
type wireMirror struct {
|
||||
|
||||
Reference in new issue
Block a user