File size: 9,539 Bytes
da5cba1
 
 
 
 
 
 
 
 
 
 
 
 
6abb7be
 
 
 
 
da5cba1
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6abb7be
da5cba1
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
// Show any JSON value well: images and audio carried as base64 or URLs, chat transcripts, tables for lists of
// records, long text as text, nested objects as key/value rows that fold. Used for environment observations, Task
// API tasks and the rows of custom environments. Everything is escaped; media only from data: or https: URLs.
import { esc, fmt, md } from "./util.js";

const IMG_KEY = /(image|img|png|jpe?g|frame|screenshot|pixels|photo|thumbnail|render)/i;
const AUDIO_KEY = /(audio|wav|speech|sound|waveform)/i;
const B64 = /^[A-Za-z0-9+/=\s]+$/;
const IMG_URL = /^https:\/\/[^\s"'<>]+\.(png|jpe?g|gif|webp|svg|avif)(\?[^\s"'<>]*)?$/i;
const AUDIO_URL = /^https:\/\/[^\s"'<>]+\.(wav|mp3|ogg|flac|m4a)(\?[^\s"'<>]*)?$/i;

function mediaOf(key, v, ctx) {
  if (typeof v !== "string" || v.length < 16) return null;
  if (key === "media_url" && /^https:\/\/[^\s"'<>]+$/.test(v)) {
    if (/^audio\/[\w.+-]+$/.test(ctx.mime || "")) return `<audio class="rv-audio" aria-label="Task audio" controls preload="none" src="${esc(v)}"></audio>`;
    if (/^image\/(png|jpeg|gif|webp|avif)$/.test(ctx.mime || "")) return `<img class="rv-img" src="${esc(v)}" alt="Task image" loading="lazy">`;
    if (/^video\/(mp4|webm|ogg)$/.test(ctx.mime || "")) return `<video class="rv-video" aria-label="Task video" controls preload="none" src="${esc(v)}"></video>`;
  }
  if (v.startsWith("data:image/") && /^data:image\/(png|jpe?g|gif|webp);base64,/.test(v)) return `<img class="rv-img" src="${esc(v)}" alt="${esc(key)}">`;
  if (v.startsWith("data:audio/") && /^data:audio\/[\w.+-]+;base64,/.test(v)) return `<audio class="rv-audio" controls src="${esc(v)}"></audio>`;
  if (IMG_URL.test(v)) return `<img class="rv-img" src="${esc(v)}" alt="${esc(key)}" loading="lazy">`;
  if (AUDIO_URL.test(v)) return `<audio class="rv-audio" controls preload="none" src="${esc(v)}"></audio>`;
  if (v.length > 200 && B64.test(v.slice(0, 400))) {
    if (IMG_KEY.test(key)) {
      const fmtName = String(ctx.image_format || ctx.format || "png").toLowerCase().replace("jpg", "jpeg");
      return `<img class="rv-img" src="data:image/${/^(png|jpeg|gif|webp)$/.test(fmtName) ? fmtName : "png"};base64,${esc(v.replace(/\s/g, ""))}" alt="${esc(key)}">`;
    }
    if (AUDIO_KEY.test(key)) return `<audio class="rv-audio" controls src="data:audio/${esc(ctx.mime?.split("/")[1] || "wav")};base64,${esc(v.replace(/\s/g, ""))}"></audio>`;
    return `<span class="rv-faint">base64, ${fmt.format(Math.round(v.length * 0.75 / 1024))} KB</span>`;
  }
  return null;
}

const isMessage = (x) => x && typeof x === "object" && !Array.isArray(x) && typeof x.role === "string" && ("content" in x || "tool_calls" in x);
const scalar = (x) => x == null || ["string", "number", "boolean"].includes(typeof x);

function text(s, key) {
  if (s === "") return `<span class="rv-faint">empty</span>`;
  if (s.length > 160 || s.includes("\n")) {
    const looksMd = /(^|\n)(#{1,4} |[-*] |\d+\. |```)/.test(s);
    return looksMd && s.length < 40_000 ? `<div class="rv-text prose">${md(s)}</div>` : `<pre class="rv-pre">${esc(s.length > 60_000 ? s.slice(0, 60_000) + "\n… (cut)" : s)}</pre>`;
  }
  return `<span class="rv-str">${esc(s)}</span>`;
}

// a long message shows its first lines and a "Show all" toggle (a checkbox: no script needed in a rendered string)
let clipN = 0;
const clip = (html, len) => (len < 1500 ? html : `<div class="rv-clip"><input type="checkbox" class="rv-tog" id="rv-c${++clipN}" aria-label="Show all"><div class="rv-clip-b">${html}</div>
  <label for="rv-c${clipN}" class="rv-more"><span class="o">Show all</span><span class="c">Show less</span></label></div>`);
const lenOf = (c) => (typeof c === "string" ? c.length : JSON.stringify(c ?? "").length);

function chat(msgs, depth) {
  return `<div class="rv-chat">${msgs.slice(0, 200).map((m) => {
    const body = typeof m.content === "string" ? text(m.content, "content")
      : Array.isArray(m.content) ? m.content.map((p) => (p?.type === "text" ? text(p.text || "", "text") : value(p, "part", depth + 1))).join("")
      : m.content == null ? "" : value(m.content, "content", depth + 1);
    const calls = Array.isArray(m.tool_calls) ? m.tool_calls.map((c) => `<div class="rv-call"><b>${esc(c.function?.name || c.name || "tool")}</b> <code>${esc(typeof c.function?.arguments === "string" ? c.function.arguments.slice(0, 2000) : JSON.stringify(c.arguments ?? c.function?.arguments ?? {}).slice(0, 2000))}</code></div>`).join("") : "";
    return `<div class="rv-msg ${esc(m.role)}"><span class="rv-role">${esc(m.role)}${m.name ? ` · ${esc(m.name)}` : ""}</span>${clip(body, lenOf(m.content))}${calls}</div>`;
  }).join("")}${msgs.length > 200 ? `<p class="rv-faint">and ${fmt.format(msgs.length - 200)} more messages</p>` : ""}</div>`;
}

function table(rows, depth) {
  const keys = [...new Set(rows.slice(0, 50).flatMap((r) => Object.keys(r)))].slice(0, 12);
  return `<div class="rv-table"><table><thead><tr>${keys.map((k) => `<th>${esc(k)}</th>`).join("")}</tr></thead><tbody>${rows.slice(0, 100).map((r) =>
    `<tr>${keys.map((k) => `<td>${scalar(r[k]) ? cell(r[k]) : value(r[k], k, depth + 1)}</td>`).join("")}</tr>`).join("")}</tbody></table>
    ${rows.length > 100 ? `<p class="rv-faint">${fmt.format(rows.length - 100)} more rows</p>` : ""}</div>`;
}
const cell = (x) => (x == null ? `<span class="rv-faint">–</span>` : typeof x === "string" ? `<span title="${esc(x)}">${esc(x.length > 140 ? x.slice(0, 140) + "…" : x)}</span>` : `<code>${esc(String(x))}</code>`);

export function value(v, key = "", depth = 0, ctx = {}) {
  if (depth > 7) return `<code class="rv-faint">…</code>`;
  if (v == null) return `<span class="rv-null">null</span>`;
  if (typeof v === "boolean") return `<span class="rv-bool ${v}">${v}</span>`;
  if (typeof v === "number") return `<span class="rv-num">${esc(Number.isInteger(v) ? fmt.format(v) : String(+v.toFixed(6)))}</span>`;
  if (typeof v === "string") return mediaOf(key, v, ctx) || text(v, key);
  if (Array.isArray(v)) {
    if (!v.length) return `<span class="rv-faint">empty list</span>`;
    if (v.every(isMessage)) return chat(v, depth);
    if (v.every(scalar) && v.length <= 60 && v.every((x) => String(x).length < 60)) return `<span class="rv-list">${v.map((x) => `<span>${esc(String(x))}</span>`).join("")}</span>`;
    if (v.length > 1 && v.every((x) => x && typeof x === "object" && !Array.isArray(x)) && v.slice(0, 20).every((x) => Object.values(x).filter((y) => !scalar(y)).length <= 1)) return table(v, depth);
    return `<ol class="rv-ol">${v.slice(0, 100).map((x, i) => `<li>${value(x, key, depth + 1, ctx)}</li>`).join("")}${v.length > 100 ? `<li class="rv-faint">${fmt.format(v.length - 100)} more</li>` : ""}</ol>`;
  }
  if (typeof v === "object") return object(v, depth);
  return esc(String(v));
}

// key/value rows; a nested object folds when it's big
export function object(o, depth = 0, { order = [], hide = [] } = {}) {
  const keys = Object.keys(o).filter((k) => !hide.includes(k));
  const pri = (k) => (order.includes(k) ? order.indexOf(k) : order.length);
  keys.sort((a, b) => pri(a) - pri(b));   // stable: the rest keep the server's order
  if (!keys.length) return `<span class="rv-faint">empty</span>`;
  return `<dl class="rv-kv">${keys.map((k) => {
    const x = o[k];
    const big = x && typeof x === "object" && (Array.isArray(x) ? x.length > 8 : Object.keys(x).length > 6) && depth > 0;
    const inner = value(x, k, depth + 1, o);
    return `<dt title="${esc(k)}">${esc(k)}</dt><dd>${big ? `<details><summary>${Array.isArray(x) ? `${fmt.format(x.length)} items` : `${fmt.format(Object.keys(x).length)} fields`}</summary>${inner}</details>` : inner}</dd>`;
  }).join("")}</dl>`;
}

// An observation from reset/step: reward and done up front, the rest below
export function observation(data) {
  const obs = data && typeof data === "object" && "observation" in data ? data.observation : data;
  const reward = data?.reward ?? obs?.reward;
  const done = data?.done ?? obs?.done;
  const head = `<div class="rv-obs-h">${reward != null ? `<span class="rv-reward ${Number(reward) > 0 ? "pos" : Number(reward) < 0 ? "neg" : ""}">reward <b>${esc(typeof reward === "number" ? String(+reward.toFixed(4)) : String(reward))}</b></span>` : ""}
    ${done != null ? `<span class="rv-done ${done ? "yes" : ""}">${done ? "done" : "not done"}</span>` : ""}</div>`;
  const rest = obs && typeof obs === "object" && !Array.isArray(obs) ? object(obs, 0, { order: ["prompt", "feedback", "message", "text"], hide: ["reward", "done"] }) : value(obs, "observation");
  return head + rest;
}

// An MCP tool result: its content parts (text, images), or its raw value
export function toolResult(r) {
  if (r && Array.isArray(r.content)) {
    const parts = r.content.map((c) => (c?.type === "text" ? (() => { try { const j = JSON.parse(c.text); return typeof j === "object" && j ? value(j) : text(c.text, "text"); } catch { return text(c.text || "", "text"); } })()
      : c?.type === "image" && /^image\/(png|jpeg|gif|webp)$/.test(c.mimeType || c.mime_type) ? `<img class="rv-img" src="data:${esc(c.mimeType || c.mime_type)};base64,${esc(c.data)}" alt="">`
      : c?.type === "audio" ? `<audio class="rv-audio" controls src="data:${esc(c.mimeType || "audio/wav")};base64,${esc(c.data)}"></audio>`
      : value(c))).join("");
    return `${r.isError || r.is_error ? `<div class="rv-obs-h"><span class="rv-done err">error</span></div>` : ""}${parts || `<span class="rv-faint">no content</span>`}`;
  }
  return value(r);
}