Spaces:
Sleeping
Sleeping
File size: 9,565 Bytes
3c5f882 e0198de 3c5f882 e0198de 3c5f882 e0198de 3c5f882 e0198de 3c5f882 935d7f2 322b380 935d7f2 322b380 935d7f2 3c5f882 6551324 f040ba9 3c5f882 935d7f2 3c5f882 935d7f2 322b380 935d7f2 322b380 935d7f2 322b380 935d7f2 322b380 935d7f2 322b380 935d7f2 322b380 935d7f2 d572f7e 935d7f2 da54044 935d7f2 3c5f882 e0198de 3c5f882 e0198de 3c5f882 e0198de 935d7f2 f672bae 935d7f2 11f916c 2bc94be 11f916c 935d7f2 7d07766 e384183 7d07766 e384183 7d07766 e384183 7d07766 e384183 7d07766 935d7f2 e384183 935d7f2 da54044 | 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 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 | 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>
);
}
|