Fix reasoning markup rendering in dashboard

This commit is contained in:
giggling-ginger
2026-07-07 07:47:27 +00:00
committed by Austin Pickett
parent 1ab7983641
commit cdda67f83f
5 changed files with 323 additions and 0 deletions

View File

@@ -0,0 +1,27 @@
import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, it } from "vitest";
import { StructuredReasoning } from "./StructuredReasoning";
describe("StructuredReasoning", () => {
it("renders thinking prose without literal wrapper tags", () => {
const html = renderToStaticMarkup(
<StructuredReasoning content="<thinking>plain prose</thinking>" />,
);
expect(html).toContain("plain prose");
expect(html).not.toContain("&lt;thinking");
expect(html).not.toContain("&lt;/thinking");
});
it("renders action and result as labeled code blocks", () => {
const html = renderToStaticMarkup(
<StructuredReasoning content={'<thinking>prose <action>tool_call</action><result>{"ok":true}</result></thinking>'} />,
);
expect(html).toContain("prose");
expect(html).toContain("Action");
expect(html).toContain("tool_call");
expect(html).toContain("Result");
expect(html).toContain("{&quot;ok&quot;:true}");
});
});

View File

@@ -0,0 +1,58 @@
import { useMemo } from "react";
import { Markdown } from "@/components/Markdown";
import {
parseReasoningMarkup,
type ReasoningMarkupSegment,
} from "@/lib/reasoning-markup";
export function StructuredReasoning({
content,
highlightTerms,
}: {
content: string;
highlightTerms?: string[];
}) {
const segments = useMemo(() => parseReasoningMarkup(content), [content]);
return (
<div className="space-y-2">
{segments.map((segment, index) => (
<ReasoningSegment
key={index}
segment={segment}
highlightTerms={highlightTerms}
/>
))}
</div>
);
}
function ReasoningSegment({
segment,
highlightTerms,
}: {
segment: ReasoningMarkupSegment;
highlightTerms?: string[];
}) {
if (segment.type === "prose") {
return (
<Markdown
content={segment.content.trim()}
highlightTerms={highlightTerms}
/>
);
}
const label = segment.type === "action" ? "Action" : "Result";
return (
<div className="border border-border bg-secondary/40">
<div className="border-b border-border px-3 py-1 text-[11px] font-semibold uppercase tracking-wide text-text-tertiary">
{label}
</div>
<pre className="px-3 py-2.5 text-xs font-mono leading-relaxed overflow-x-auto whitespace-pre-wrap text-foreground">
<code>{segment.content}</code>
</pre>
</div>
);
}

View File

@@ -0,0 +1,54 @@
import { describe, expect, it } from "vitest";
import { hasReasoningMarkup, parseReasoningMarkup } from "./reasoning-markup";
describe("reasoning markup parsing", () => {
it("unwraps thinking prose", () => {
expect(parseReasoningMarkup("<thinking>plain prose</thinking>")).toEqual([
{ type: "prose", content: "plain prose" },
]);
});
it("separates prose from action and result segments", () => {
expect(
parseReasoningMarkup(
'<thinking>prose <action>tool_call</action><result>{"ok":true}</result></thinking>',
),
).toEqual([
{ type: "prose", content: "prose " },
{ type: "action", content: "tool_call" },
{ type: "result", content: '{"ok":true}' },
]);
});
it("does not treat unrelated angle-bracket text as reasoning markup", () => {
const content = "Compare <div> and <custom-tag> in prose.";
expect(hasReasoningMarkup(content)).toBe(false);
expect(parseReasoningMarkup(content)).toEqual([
{ type: "prose", content },
]);
});
it("preserves fenced XML and HTML as ordinary markdown content", () => {
const content = [
"```xml",
"<thinking>keep this literal</thinking>",
"<div>example</div>",
"```",
].join("\n");
expect(hasReasoningMarkup(content)).toBe(false);
expect(parseReasoningMarkup(content)).toEqual([
{ type: "prose", content },
]);
});
it("leaves non-matching known tags as plain text", () => {
const content = "before <thinking>unfinished";
expect(hasReasoningMarkup(content)).toBe(false);
expect(parseReasoningMarkup(content)).toEqual([
{ type: "prose", content },
]);
});
});

View File

@@ -0,0 +1,177 @@
export type ReasoningMarkupSegment =
| { type: "prose"; content: string }
| { type: "action"; content: string }
| { type: "result"; content: string };
type KnownTag = "thinking" | "reflection" | "action" | "result";
const KNOWN_TAGS = new Set<KnownTag>([
"thinking",
"reflection",
"action",
"result",
]);
const FENCE_RE = /^```/;
const TAG_RE = /<\/?(thinking|reflection|action|result)>/gi;
export function hasReasoningMarkup(content: string): boolean {
return splitFenceAware(content).some((chunk) => {
if (chunk.kind === "code") return false;
return findFirstCompleteKnownTag(chunk.content) !== null;
});
}
export function parseReasoningMarkup(content: string): ReasoningMarkupSegment[] {
return mergeAdjacentProse(
splitFenceAware(content).flatMap((chunk) =>
chunk.kind === "code"
? [{ type: "prose" as const, content: chunk.content }]
: parseKnownTags(chunk.content),
),
);
}
function parseKnownTags(content: string): ReasoningMarkupSegment[] {
const segments: ReasoningMarkupSegment[] = [];
let outputCursor = 0;
let searchCursor = 0;
for (;;) {
const opening = findNextOpeningTag(content, searchCursor);
if (!opening) break;
const closing = findMatchingCloseTag(content, opening.tag, opening.end);
if (!closing) {
searchCursor = opening.end;
continue;
}
if (opening.start > outputCursor) {
segments.push({
type: "prose",
content: content.slice(outputCursor, opening.start),
});
}
const inner = content.slice(opening.end, closing.start);
if (opening.tag === "action" || opening.tag === "result") {
segments.push({ type: opening.tag, content: inner.trim() });
} else {
segments.push(...parseKnownTags(inner));
}
outputCursor = closing.end;
searchCursor = closing.end;
}
if (outputCursor < content.length) {
segments.push({ type: "prose", content: content.slice(outputCursor) });
}
return segments.filter((segment) => segment.content.length > 0);
}
function findFirstCompleteKnownTag(content: string): KnownTag | null {
let cursor = 0;
for (;;) {
const opening = findNextOpeningTag(content, cursor);
if (!opening) return null;
if (findMatchingCloseTag(content, opening.tag, opening.end)) {
return opening.tag;
}
cursor = opening.end;
}
}
function findNextOpeningTag(
content: string,
from: number,
): { tag: KnownTag; start: number; end: number } | null {
TAG_RE.lastIndex = from;
let match: RegExpExecArray | null;
while ((match = TAG_RE.exec(content)) !== null) {
const raw = match[0];
const tag = match[1].toLowerCase() as KnownTag;
if (!raw.startsWith("</") && KNOWN_TAGS.has(tag)) {
return { tag, start: match.index, end: TAG_RE.lastIndex };
}
}
return null;
}
function findMatchingCloseTag(
content: string,
tag: KnownTag,
from: number,
): { start: number; end: number } | null {
const tagRe = new RegExp(`</?${tag}>`, "gi");
tagRe.lastIndex = from;
let depth = 1;
let match: RegExpExecArray | null;
while ((match = tagRe.exec(content)) !== null) {
if (match[0].startsWith("</")) {
depth -= 1;
if (depth === 0) {
return { start: match.index, end: tagRe.lastIndex };
}
} else {
depth += 1;
}
}
return null;
}
function splitFenceAware(
content: string,
): Array<{ kind: "prose" | "code"; content: string }> {
const chunks: Array<{ kind: "prose" | "code"; content: string }> = [];
const lines = content.split("\n");
let buffer: string[] = [];
let inCode = false;
for (const line of lines) {
if (FENCE_RE.test(line)) {
if (!inCode && buffer.length > 0) {
chunks.push({ kind: "prose", content: buffer.join("\n") });
buffer = [];
}
buffer.push(line);
if (inCode) {
chunks.push({ kind: "code", content: buffer.join("\n") });
buffer = [];
}
inCode = !inCode;
continue;
}
buffer.push(line);
}
if (buffer.length > 0) {
chunks.push({
kind: inCode ? "code" : "prose",
content: buffer.join("\n"),
});
}
return chunks;
}
function mergeAdjacentProse(
segments: ReasoningMarkupSegment[],
): ReasoningMarkupSegment[] {
const merged: ReasoningMarkupSegment[] = [];
for (const segment of segments) {
const prev = merged.at(-1);
if (prev?.type === "prose" && segment.type === "prose") {
prev.content += `\n${segment.content}`;
} else {
merged.push({ ...segment });
}
}
return merged;
}

View File

@@ -48,7 +48,9 @@ import type {
} from "@/lib/api";
import { timeAgo } from "@/lib/utils";
import { Markdown } from "@/components/Markdown";
import { StructuredReasoning } from "@/components/StructuredReasoning";
import { PlatformsCard } from "@/components/PlatformsCard";
import { hasReasoningMarkup } from "@/lib/reasoning-markup";
import { Toast } from "@nous-research/ui/ui/components/toast";
import { Button } from "@nous-research/ui/ui/components/button";
import { Checkbox } from "@nous-research/ui/ui/components/checkbox";
@@ -417,6 +419,11 @@ function MessageBubble({
<div className="text-sm text-foreground whitespace-pre-wrap leading-relaxed">
{msg.content}
</div>
) : hasReasoningMarkup(msg.content) ? (
<StructuredReasoning
content={msg.content}
highlightTerms={highlightTerms}
/>
) : (
<Markdown content={msg.content} highlightTerms={highlightTerms} />
))}