ape-dev
Improve streaming markdown rendering
e0198de
Raw
History Blame Contribute Delete
9.57 kB
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import remarkMath from "remark-math";
import rehypeKatex from "rehype-katex";
import "katex/dist/katex.min.css";
import { repairMarkdownTables, splitStreaming } from "../utils/streamRender.js";
// Full CommonMark + GFM (tables, strikethrough, task lists, autolinks) via
// react-markdown — replaces the old hand-rolled regex renderer, so any
// standard markdown the model emits renders correctly without per-syntax
// patches. Links open in a new tab.
const MD_PLUGINS = [remarkGfm, [remarkMath, { singleDollarTextMath: false }]];
const REHYPE_PLUGINS = [[rehypeKatex, { strict: false, throwOnError: false }]];
const MD_COMPONENTS = {
a: (props) => <a {...props} target="_blank" rel="noopener noreferrer" />,
table: ({ node, ...props }) => (
<div className="md-table-scroll">
<table {...props} />
</div>
),
pre: ({ node, ...props }) => <pre className="md-code-block" {...props} />,
};
function Markdown({ children }) {
return (
<ReactMarkdown
remarkPlugins={MD_PLUGINS}
rehypePlugins={REHYPE_PLUGINS}
components={MD_COMPONENTS}
>
{repairMarkdownTables(children || "")}
</ReactMarkdown>
);
}
/**
* One message — Claude Desktop style.
*
* user message: right-aligned, subtle warm-gray bubble
* assistant message: full-width prose, no bubble (just clean markdown)
*
* Assistant messages carry response_id + rendered_format. The feedback row
* (👍/👎) appears only on the most recent assistant message and routes to
* Path B with that exact response_id.
*/
export default function Message({ message, isLastAssistant, showMeta, onFeedback, onRegenerate, ratedSignal }) {
const isUser = message.role === "user";
const isPlaceholder = !!message._placeholder;
const thumbsLocked = !!ratedSignal;
// User messages: plain text in the bubble (React escapes automatically).
// Assistant messages: full markdown via react-markdown.
//
// While streaming we render LINE BY LINE: every complete line is safe to
// markdown-render, while the partially-typed last line is held back and
// sanitized (see splitStreaming). That keeps raw syntax off the screen
// without freezing a whole table as plain text until it finishes.
return (
<div className={`msg ${isUser ? "user" : "assistant"}`}>
<div className="msg-content">
{isUser
? message.content
: isPlaceholder
? <StreamingContent content={message.content} />
: <Markdown>{message.content}</Markdown>}
</div>
{!isUser && showMeta && message.meta && (
<div className="meta-strip">{renderMetaChips(message)}</div>
)}
{!isUser && isLastAssistant && !isPlaceholder && message.response_id && (
<div className="feedback-row">
<button
className={`fb-btn${ratedSignal === "thumbs_up" ? " active" : ""}`}
onClick={() => onFeedback(message.response_id, "thumbs_up")}
disabled={thumbsLocked}
title={thumbsLocked ? "Already rated" : "Good response"}
aria-label="Thumbs up"
aria-pressed={ratedSignal === "thumbs_up"}
>
<ThumbUpIcon />
</button>
<button
className={`fb-btn${ratedSignal === "thumbs_down" ? " active" : ""}`}
onClick={() => onFeedback(message.response_id, "thumbs_down")}
disabled={thumbsLocked}
title={thumbsLocked ? "Already rated" : "Bad response"}
aria-label="Thumbs down"
aria-pressed={ratedSignal === "thumbs_down"}
>
<ThumbDownIcon />
</button>
<button
className="fb-btn"
onClick={() => navigator.clipboard?.writeText(message.content || "")}
title="Copy response"
aria-label="Copy"
>
<CopyIcon />
</button>
<button
className="fb-btn"
onClick={() => onRegenerate?.(message.response_id)}
title="Regenerate this response"
aria-label="Regenerate"
>
<RegenIcon />
</button>
</div>
)}
</div>
);
}
/**
* The in-flight assistant bubble. splitStreaming decides which prefix of the
* buffer is safe to parse (buffering half-open tables/fences) and sanitizes
* the still-typing line; this component just renders those two pieces.
*/
function StreamingContent({ content }) {
const { thinking, committed, tail, liveCode } = splitStreaming(content);
if (thinking) {
return (
<div className="placeholder-text">
<span className="spinner" />Thinking…
</div>
);
}
return (
<>
{committed ? <Markdown>{committed}</Markdown> : null}
{liveCode ? <LiveCodeBlock language={liveCode.language} code={liveCode.code} /> : null}
{tail ? <div className="stream-tail">{tail}</div> : null}
</>
);
}
function LiveCodeBlock({ language, code }) {
const label = language || "code";
return (
<div className="stream-code-block">
<div className="stream-code-header">
<span>{label}</span>
<span>Streaming</span>
</div>
<pre><code>{code || " "}</code></pre>
</div>
);
}
// ---------- Chip rendering ----------
function renderMetaChips(msg) {
const meta = msg.meta || {};
const chips = [];
// Topic is disabled in the bandit key (collapsed to "_all") — don't show it.
if (meta.topic && meta.topic !== "_all") chips.push(chip("topic: " + meta.topic));
if (meta.intent) chips.push(chip("intent: " + meta.intent));
if (meta.selected_strategy) chips.push(chip("strategy: " + meta.selected_strategy));
if (msg.rendered_format) chips.push(chip("rendered: " + msg.rendered_format));
// Selection score of the chosen strategy — computed LIVE server-side from
// the current cell state (matches the Bandit State tab), not a cached
// snapshot. Round-robin cold-start picks have no meaningful score.
// `meta.ucb_at_selection` is the historical fallback for older messages.
const selMethod = msg.selection_method || meta.selection_method;
const liveScore = msg.live_selection_score != null
? msg.live_selection_score
: meta.ucb_at_selection;
if (selMethod === "round_robin") {
chips.push(chip("pick: round-robin"));
} else if (liveScore != null) {
chips.push(chip(`selection score: ${Number(liveScore).toFixed(2)}`));
}
// Applied reward verdict — joined from ape_turn_record by the messages
// API. Shows BOTH reward axes of the two-axis model:
// format — what the bandit consumed (explicit ±2 / inferred ±1)
// content — recorded evidence about the answer's substance
if (msg.reward_status === "APPLIED") {
const hasFormat = msg.normalized_reward != null;
const hasContent = msg.content_reward != null;
if (msg.applied_signal && msg.applied_signal !== "no_signal") {
chips.push(chip(`signal: ${msg.applied_signal}`));
}
if (hasContent) {
const c = Number(msg.content_reward);
chips.push(chip(
`content: ${c > 0 ? "+" : ""}${c} (${tierLabel(msg.content_category)})`,
c > 0 ? "pos" : "neg",
));
}
if (hasFormat) {
const f = Number(msg.normalized_reward);
chips.push(chip(
`format: ${f > 0 ? "+" : ""}${f} (${tierLabel(msg.reward_category)})`,
f > 0 ? "pos" : "neg",
));
}
if (!hasFormat && !hasContent && msg.applied_signal && msg.applied_signal !== "no_signal") {
chips.push(chip("no reward (axis not recorded)"));
}
} else if (msg.reward_status === "PENDING" && msg.response_id) {
chips.push(chip("reward: pending"));
}
return <>{chips}</>;
}
function chip(text, klass = "") {
return <span key={text} className={`chip ${klass}`}>{text}</span>;
}
// "explicit_positive" -> "explicit", "inferred_negative" -> "inferred"
function tierLabel(category) {
if (!category) return "?";
return String(category).split("_")[0];
}
// ---------- Icons ----------
function ThumbUpIcon() {
return (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor"
strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d="M7 10v12M15 5.88 14 10h5.83a2 2 0 0 1 1.92 2.56l-2.33 8A2 2 0 0 1 17.5 22H7a2 2 0 0 1-2-2V10a2 2 0 0 1 2-2h2.76a2 2 0 0 0 1.79-1.11L15 2"/>
</svg>
);
}
function ThumbDownIcon() {
return (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor"
strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d="M17 14V2M9 18.12 10 14H4.17a2 2 0 0 1-1.92-2.56l2.33-8A2 2 0 0 1 6.5 2H17a2 2 0 0 1 2 2v10a2 2 0 0 1-2 2h-2.76a2 2 0 0 0-1.79 1.11L9 22"/>
</svg>
);
}
function CopyIcon() {
return (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor"
strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<rect x="9" y="9" width="13" height="13" rx="2" ry="2"/>
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/>
</svg>
);
}
function RegenIcon() {
return (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor"
strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d="M3 12a9 9 0 1 0 3-6.7"/>
<path d="M3 4v5h5"/>
</svg>
);
}