// Rows of RL training data (NeMo Gym, Verifiers, verl / SkyRL; app/envs/convo.py reads them): the conversation as the
// policy gets it, and the tools it may call. Text is shown whole and escaped; long parts (a system prompt, reasoning, a
// tool's output) fold under a line that says how long they are, so nothing scrolls inside the page.
// transcript {turns: [{kind: message|reasoning|call|output|other, role, text, name?, args?, output?, images?}],
// next?: {label, text}, note?}
// tools {tools: [{name, kind, description, built_in, params: [{name, type, required, description, enum?,
// default?, params?}]}], open?}
// fields {rows: [{key, text, code}]}: a row's other fields, label over value; long values fold
import { esc, fmt, md } from "../util.js";
import { icon } from "../icons.js";
export function render(kind, data) {
return ({ transcript, tools, fields }[kind] || (() => ""))(data || {});
}
const LONG = 1400, TALL = 18; // past this many characters or lines, a system prompt, reasoning or tool output folds
const long = (s) => s.length > LONG || (s.match(/\n/g) || []).length > TALL;
const chars = (s) => `${fmt.format(s.length)} characters`;
const ROLE = { system: "System", developer: "Developer", user: "User", assistant: "Assistant", tool: "Tool", other: "Other" };
const text = (s, cls = "") => (s ? `
${esc(s)}
` : `empty
`);
const images = (list) => (list || []).map((u) => `
`).join("");
const peek = (s) => esc(s.replace(/\s+/g, " ").trim().slice(0, 140));
function fold(label, meta, inner, open = false) {
return `${icon("chevronRight", 13, "chev")}${label}${meta}
${inner}
`;
}
// ── the conversation ─────────────────────────────────────────────────────────
function part(t) {
if (t.kind === "reasoning") return fold("Reasoning", t.text ? `${chars(t.text)} · ${peek(t.text)}` : "empty", text(t.text));
if (t.kind === "call") {
const args = t.args && t.args !== "{}" ? `${esc(t.args)}` : `no arguments
`;
const out = "output" in t ? (long(t.output || "")
? fold("Output", `${chars(t.output)} · ${peek(t.output)}`, `${esc(t.output)}${images(t.images)}`)
: `Output
${t.output ? `${esc(t.output)}` : `empty
`}${images(t.images)}`) : "";
return `${icon("wrench", 12)}Calls ${esc(t.name)}
${args}${out}
`;
}
if (t.kind === "output") {
const body = `${esc(t.text)}${images(t.images)}`;
return long(t.text || "") ? fold("Output", `${chars(t.text)} · ${peek(t.text)}`, body) : body;
}
if (t.kind === "other") return `${esc(t.text)}`;
if (t.role === "system" || t.role === "developer") {
return t.text && long(t.text) ? fold(`${ROLE[t.role]} prompt`, chars(t.text), text(t.text)) + images(t.images) : text(t.text) + images(t.images);
}
// the assistant writes Markdown; what it's given is shown as it is
if (t.role === "assistant" && t.text) return `${md(t.text)}
${images(t.images)}`;
return text(t.text) + images(t.images);
}
const KEEP = 4; // a long conversation shows its opening and its last turns; the middle folds
function turnHtml(g) {
return `${esc(ROLE[g.side] || g.side)}${g.name ? ` ${esc(g.name)}` : ""}
${g.items.map(part).join("")}
`;
}
function tally(groups) {
const items = groups.flatMap((g) => g.items);
const n = (f) => items.filter(f).length;
return [[n((t) => t.kind === "message" && t.role === "user"), "user message"], [n((t) => t.kind === "message" && t.role === "assistant"), "assistant message"],
[n((t) => t.kind === "call"), "tool call"]].filter(([k]) => k).map(([k, w]) => `${fmt.format(k)} ${w}${k === 1 ? "" : "s"}`).join(", ");
}
function transcript(d) {
const ts = d.turns || [];
// consecutive turns of one side (the assistant's reasoning, words and tool calls) are one block
const groups = [];
ts.forEach((t) => {
const side = t.kind === "output" ? "tool" : t.kind === "other" ? "other" : t.role;
const g = groups[groups.length - 1];
if (g && g.side === side && side === "assistant") g.items.push(t);
else groups.push({ side, name: t.name && t.kind === "message" ? t.name : "", items: [t] });
});
const n = (k, role) => ts.filter((t) => t.kind === k && (!role || t.role === role)).length;
const counts = [[n("message", "user"), "user message"], [n("message", "assistant"), "assistant message"], [n("call"), "tool call"], [n("reasoning"), "reasoning step"]]
.filter(([c]) => c).map(([c, w]) => `${fmt.format(c)} ${w}${c === 1 ? "" : "s"}`).join(" · ");
// the opening (system prompt and the first request) and the last few turns stay open; a long middle folds
const firstUser = groups.findIndex((g) => g.side === "user");
const head = firstUser < 0 ? 1 : firstUser + 1;
const tailFrom = Math.max(head, groups.length - KEEP);
const middle = groups.slice(head, tailFrom);
const body = middle.length >= 3
? [...groups.slice(0, head).map(turnHtml), `${icon("chevronRight", 13, "chev")}${fmt.format(middle.length)} earlier turns${tally(middle)}
${middle.map(turnHtml).join("")}
`, ...groups.slice(tailFrom).map(turnHtml)]
: groups.map(turnHtml);
return `${d.note ? `${esc(d.note)}
` : ""}
${counts && ts.length > 2 ? `${counts}
` : ""}
${body.join("")}
${d.next ? `${esc(d.next.label || "Next")}
${esc(d.next.text || "")}
` : ""}
`;
}
// ── the tools ────────────────────────────────────────────────────────────────
const sentence = (s) => { const m = String(s || "").replace(/\s+/g, " ").match(/^.{0,160}?[.!?](\s|$)/); return m ? m[0].trim() : String(s || "").replace(/\s+/g, " ").slice(0, 160); };
function params(list) {
return `${list.map((p) => `${esc(p.name)}${p.required ? `required` : ""}
- ${p.type ? `${esc(p.type)}` : ""}${p.description ? ` ${esc(p.description)}` : ""}
${p.enum ? `
One of ${p.enum.map((x) => `${esc(x)}`).join(" ")}
` : ""}
${p.default != null ? `Default ${esc(p.default)}
` : ""}
${p.params?.length ? params(p.params) : ""} `).join("")}
`;
}
function tools(d) {
const list = d.tools || [];
if (!list.length) return `No tools: the policy answers in words only.
`;
const open = d.open ?? list.length <= 3;
return ``;
}
// ── a row's other fields ─────────────────────────────────────────────────────
function fields(d) {
const rows = d.rows || [];
if (!rows.length) return "";
const val = (r) => {
const body = r.code ? `${esc(r.text)}` : text(r.text);
if (long(r.text)) return fold(r.code ? "Show" : "Show all", `${chars(r.text)} · ${peek(r.text)}`, body);
return r.code && !r.text.includes("\n") ? `${esc(r.text)}` : body;
};
return `${rows.map((r) => `${esc(r.key)}- ${val(r)}
`).join("")}
`;
}