124 lines
4.3 KiB
TypeScript
124 lines
4.3 KiB
TypeScript
import { cn } from '../../lib/cn';
|
|
|
|
export interface ReasoningParts {
|
|
/** Lead-in prose before the first enumerated point. */
|
|
intro: string;
|
|
/** The enumerated reasoning points. */
|
|
steps: string[];
|
|
}
|
|
|
|
/**
|
|
* Parse an AI reasoning blob into a lead-in + enumerated steps.
|
|
* Handles the common "(1) … (2) …" / "1) … 2) …" inline enumeration and
|
|
* "-"/"•"-prefixed bullet lines. Falls back to plain prose (no steps) for
|
|
* anything else, so short / unstructured reasoning still renders cleanly.
|
|
*/
|
|
export function parseReasoning(raw: string): ReasoningParts {
|
|
const text = (raw ?? '').trim();
|
|
if (!text) return { intro: '', steps: [] };
|
|
|
|
// Inline numbered enumeration — "(1) …" or "1) …". Only digit-in-paren forms,
|
|
// so "(18-65 band)", "(WARM segment)", "60/100" never false-match.
|
|
const markers = [...text.matchAll(/(?:^|\s)\(?(\d{1,2})\)\s+/g)];
|
|
if (markers.length >= 2) {
|
|
const intro = text.slice(0, markers[0].index).trim();
|
|
const steps: string[] = [];
|
|
for (let i = 0; i < markers.length; i++) {
|
|
const start = markers[i].index! + markers[i][0].length;
|
|
const end = i + 1 < markers.length ? markers[i + 1].index! : text.length;
|
|
const seg = text.slice(start, end).trim().replace(/[,;]\s*$/, '');
|
|
if (seg) steps.push(seg);
|
|
}
|
|
return { intro, steps };
|
|
}
|
|
|
|
// Newline bullets — "- ", "• ", "* ".
|
|
const lines = text.split(/\n+/).map((l) => l.trim()).filter(Boolean);
|
|
const isBullet = (l: string) => /^[-•*]\s+/.test(l);
|
|
if (lines.filter(isBullet).length >= 2) {
|
|
const introLines: string[] = [];
|
|
let i = 0;
|
|
while (i < lines.length && !isBullet(lines[i])) introLines.push(lines[i++]);
|
|
const steps = lines.slice(i).filter(isBullet).map((l) => l.replace(/^[-•*]\s+/, ''));
|
|
return { intro: introLines.join(' '), steps };
|
|
}
|
|
|
|
return { intro: text, steps: [] };
|
|
}
|
|
|
|
/**
|
|
* Split a prose blob into sentence-sized facts for scannable rendering.
|
|
* Only breaks on terminal punctuation that follows a word/paren/quote and
|
|
* precedes a capital — so decimals (₹2.76), ranges (18-65), and multiples
|
|
* (10-20x) never split mid-fact. Newlines are flattened first.
|
|
*/
|
|
function splitSentences(raw: string): string[] {
|
|
return (raw ?? '')
|
|
.replace(/\s*\n+\s*/g, ' ')
|
|
.trim()
|
|
.split(/(?<=[a-zA-Z)\]%"'])[.!?]+\s+(?=[A-Z"'(])/)
|
|
.map((s) => s.trim())
|
|
.filter(Boolean);
|
|
}
|
|
|
|
export interface ReasoningBodyProps {
|
|
text: string;
|
|
/** Accent color for the step badges (matches the decision's autonomy state). */
|
|
accent?: string;
|
|
accentSoft?: string;
|
|
className?: string;
|
|
}
|
|
|
|
/** Renders an AI reasoning blob as a lead-in paragraph + numbered step list. */
|
|
export function ReasoningBody({
|
|
text,
|
|
accent = 'var(--status-autonomous)',
|
|
accentSoft = 'var(--status-autonomous-soft)',
|
|
className,
|
|
}: ReasoningBodyProps) {
|
|
const { intro, steps } = parseReasoning(text);
|
|
|
|
// No enumeration — split the prose into sentence-sized facts and render them
|
|
// as a scannable list, so a dense wall of reasoning reads point-by-point.
|
|
if (steps.length === 0) {
|
|
const facts = splitSentences(text);
|
|
if (facts.length < 2) {
|
|
return (
|
|
<p className={cn('m-0 text-sm leading-relaxed text-body', className)}>{text.trim()}</p>
|
|
);
|
|
}
|
|
return (
|
|
<ul className={cn('m-0 p-0 list-none flex flex-col gap-2 text-sm leading-snug text-body', className)}>
|
|
{facts.map((f, i) => (
|
|
<li key={i} className="flex gap-2.5">
|
|
<span
|
|
className="mt-[7px] shrink-0 w-1.5 h-1.5 rounded-full"
|
|
style={{ background: accent }}
|
|
/>
|
|
<span className="flex-1">{f}</span>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className={cn('text-sm leading-relaxed text-body', className)}>
|
|
{intro && <p className="m-0 mb-3">{intro}</p>}
|
|
<ol className="m-0 p-0 list-none flex flex-col gap-2.5">
|
|
{steps.map((s, i) => (
|
|
<li key={i} className="flex gap-2.5">
|
|
<span
|
|
className="shrink-0 mt-px w-5 h-5 rounded-full text-[11px] font-bold flex items-center justify-center nums"
|
|
style={{ background: accentSoft, color: accent }}
|
|
>
|
|
{i + 1}
|
|
</span>
|
|
<span className="flex-1 leading-snug">{s}</span>
|
|
</li>
|
|
))}
|
|
</ol>
|
|
</div>
|
|
);
|
|
}
|