diff --git a/web/src/components/StructuredReasoning.test.tsx b/web/src/components/StructuredReasoning.test.tsx new file mode 100644 index 0000000000..d741655719 --- /dev/null +++ b/web/src/components/StructuredReasoning.test.tsx @@ -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( + , + ); + + 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( + prose tool_call{"ok":true}'} />, + ); + + expect(html).toContain("prose"); + expect(html).toContain("Action"); + expect(html).toContain("tool_call"); + expect(html).toContain("Result"); + expect(html).toContain("{"ok":true}"); + }); +}); diff --git a/web/src/components/StructuredReasoning.tsx b/web/src/components/StructuredReasoning.tsx new file mode 100644 index 0000000000..3ba6feea18 --- /dev/null +++ b/web/src/components/StructuredReasoning.tsx @@ -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 ( +
+ {segments.map((segment, index) => ( + + ))} +
+ ); +} + +function ReasoningSegment({ + segment, + highlightTerms, +}: { + segment: ReasoningMarkupSegment; + highlightTerms?: string[]; +}) { + if (segment.type === "prose") { + return ( + + ); + } + + const label = segment.type === "action" ? "Action" : "Result"; + + return ( +
+
+ {label} +
+
+        {segment.content}
+      
+
+ ); +} diff --git a/web/src/lib/reasoning-markup.test.ts b/web/src/lib/reasoning-markup.test.ts new file mode 100644 index 0000000000..70083f8399 --- /dev/null +++ b/web/src/lib/reasoning-markup.test.ts @@ -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("plain prose")).toEqual([ + { type: "prose", content: "plain prose" }, + ]); + }); + + it("separates prose from action and result segments", () => { + expect( + parseReasoningMarkup( + 'prose tool_call{"ok":true}', + ), + ).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
and 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", + "keep this literal", + "
example
", + "```", + ].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 unfinished"; + + expect(hasReasoningMarkup(content)).toBe(false); + expect(parseReasoningMarkup(content)).toEqual([ + { type: "prose", content }, + ]); + }); +}); diff --git a/web/src/lib/reasoning-markup.ts b/web/src/lib/reasoning-markup.ts new file mode 100644 index 0000000000..14e9c42626 --- /dev/null +++ b/web/src/lib/reasoning-markup.ts @@ -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([ + "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("`, "gi"); + tagRe.lastIndex = from; + let depth = 1; + let match: RegExpExecArray | null; + + while ((match = tagRe.exec(content)) !== null) { + if (match[0].startsWith(" { + 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; +} diff --git a/web/src/pages/SessionsPage.tsx b/web/src/pages/SessionsPage.tsx index 0779086211..957e02cd9e 100644 --- a/web/src/pages/SessionsPage.tsx +++ b/web/src/pages/SessionsPage.tsx @@ -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({
{msg.content}
+ ) : hasReasoningMarkup(msg.content) ? ( + ) : ( ))}