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>
  );
}