Fix reasoning markup rendering in dashboard
This commit is contained in:
committed by
Austin Pickett
parent
1ab7983641
commit
cdda67f83f
27
web/src/components/StructuredReasoning.test.tsx
Normal file
27
web/src/components/StructuredReasoning.test.tsx
Normal 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("<thinking");
|
||||
expect(html).not.toContain("</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("{"ok":true}");
|
||||
});
|
||||
});
|
||||
58
web/src/components/StructuredReasoning.tsx
Normal file
58
web/src/components/StructuredReasoning.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
54
web/src/lib/reasoning-markup.test.ts
Normal file
54
web/src/lib/reasoning-markup.test.ts
Normal 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 },
|
||||
]);
|
||||
});
|
||||
});
|
||||
177
web/src/lib/reasoning-markup.ts
Normal file
177
web/src/lib/reasoning-markup.ts
Normal 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;
|
||||
}
|
||||
@@ -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} />
|
||||
))}
|
||||
|
||||
Reference in New Issue
Block a user