mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
feat(insights): render plain lists and bold in the AI prose surfaces
The prose renderer knew only blank-line paragraphs, so a reply that enumerated options read as dash-prefixed text lines and a bold marker surfaced as literal asterisks. ProseBlocks now groups consecutive "- " lines into a real list and renders **bold** spans as strong text — pure string splitting over the trusted string, fail-closed on anything unclosed or unknown, still no markdown library. The Coach streaming renderer settles completed paragraphs through the same block renderer.
This commit is contained in:
1 parent
6360ba19ee
commit
a778a0b2f7
3 files changed
+146
-16
No files matched your search
@@ -84,6 +84,32 @@ describe("ProseBlocks", () => {
|
||||
expect(html).toContain("metric:WEIGHT");
|
||||
expect(html).not.toContain('data-slot="inline-learn-link"');
|
||||
});
|
||||
|
||||
it("groups consecutive '- ' lines into a real <ul> and strips the markers", () => {
|
||||
const html = render(
|
||||
<ProseBlocks
|
||||
text={
|
||||
"Two options stand out:\n- walk after lunch\n- an earlier bedtime\n\nBoth are low effort."
|
||||
}
|
||||
/>,
|
||||
);
|
||||
expect((html.match(/<ul\b/g) ?? []).length).toBe(1);
|
||||
expect((html.match(/<li\b/g) ?? []).length).toBe(2);
|
||||
expect(html).toContain("walk after lunch");
|
||||
expect(html).not.toContain("- walk");
|
||||
// The intro line and the trailing paragraph stay real <p> blocks.
|
||||
expect((html.match(/<p\b/g) ?? []).length).toBe(2);
|
||||
});
|
||||
|
||||
it("renders a **bold** span as <strong> and leaves unclosed markers literal", () => {
|
||||
const html = render(
|
||||
<ProseBlocks
|
||||
text={"The **key takeaway** stands.\n\nA stray ** stays literal."}
|
||||
/>,
|
||||
);
|
||||
expect(html).toContain("<strong>key takeaway</strong>");
|
||||
expect(html).toContain("A stray ** stays literal.");
|
||||
});
|
||||
});
|
||||
|
||||
describe("StreamedProse", () => {
|
||||
|
||||
@@ -6,7 +6,7 @@ import { cn } from "@/lib/utils";
|
||||
import { stripChartTokens } from "@/lib/insights/chart-tokens";
|
||||
import {
|
||||
ProseBlocks,
|
||||
ParagraphText,
|
||||
ProseBlock,
|
||||
splitParagraphs,
|
||||
PROSE_PARAGRAPH_CLASS,
|
||||
} from "@/components/insights/prose-blocks";
|
||||
@@ -96,13 +96,11 @@ export function StreamedProse({ content, streaming }: StreamedProseProps) {
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Completed paragraphs settle immediately — plain text + linkifier,
|
||||
identical to the non-streaming render so nothing reflows when the
|
||||
turn finishes. */}
|
||||
{/* Completed paragraphs settle immediately — plain text + linkifier
|
||||
(and real list markup for `- ` runs), identical to the
|
||||
non-streaming render so nothing reflows when the turn finishes. */}
|
||||
{settled.map((p, i) => (
|
||||
<p key={i} className={cn(PROSE_PARAGRAPH_CLASS)}>
|
||||
<ParagraphText text={p} strip />
|
||||
</p>
|
||||
<ProseBlock key={i} text={p} strip />
|
||||
))}
|
||||
{/* The growing tail paragraph: word-by-word fade. No linkify here —
|
||||
a half-arrived URL must not flicker into a link mid-stream; it
|
||||
|
||||
@@ -102,13 +102,55 @@ function linkifyLearnLinks(line: string): ReactNode {
|
||||
return <>{nodes}</>;
|
||||
}
|
||||
|
||||
/**
|
||||
* `**bold**` span matcher. The ONLY inline emphasis the formatting contract
|
||||
* allows the models; anything else (headings, underscores, backticks) stays
|
||||
* literal text. No `*` or `\n` inside the span, so an unclosed `**` or a
|
||||
* stray asterisk falls through verbatim — fail-closed, never swallowed.
|
||||
*/
|
||||
const BOLD_SPAN_RE = /\*\*([^*\n]+)\*\*/g;
|
||||
|
||||
/**
|
||||
* Render one line's inline content: closed-set `**bold**` spans become
|
||||
* `<strong>`, and each plain/bold fragment runs through the catalog-
|
||||
* whitelisted Learn linkifier. Pure `matchAll` + slicing over the trusted
|
||||
* string — no parser, no markup passthrough.
|
||||
*/
|
||||
function renderInline(line: string, linkify: boolean): ReactNode {
|
||||
const linkified = (s: string) => (linkify ? linkifyLearnLinks(s) : s);
|
||||
if (!line.includes("**")) return linkified(line);
|
||||
|
||||
const nodes: ReactNode[] = [];
|
||||
let lastIndex = 0;
|
||||
let key = 0;
|
||||
for (const match of line.matchAll(BOLD_SPAN_RE)) {
|
||||
const start = match.index ?? 0;
|
||||
if (start > lastIndex)
|
||||
nodes.push(
|
||||
<Fragment key={`t-${key++}`}>
|
||||
{linkified(line.slice(lastIndex, start))}
|
||||
</Fragment>,
|
||||
);
|
||||
nodes.push(<strong key={`b-${key++}`}>{linkified(match[1])}</strong>);
|
||||
lastIndex = start + match[0].length;
|
||||
}
|
||||
if (nodes.length === 0) return linkified(line);
|
||||
if (lastIndex < line.length)
|
||||
nodes.push(
|
||||
<Fragment key={`t-${key++}`}>
|
||||
{linkified(line.slice(lastIndex))}
|
||||
</Fragment>,
|
||||
);
|
||||
return <>{nodes}</>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render one paragraph's inner text: each single `\n` becomes a `<br/>`, each
|
||||
* line is (optionally) chart-token-stripped, then run through the
|
||||
* catalog-whitelisted Learn linkifier. Stripping per LINE preserves the
|
||||
* `\n` breaks that a whole-string strip would collapse. Exported so the
|
||||
* Coach streaming renderer can settle completed paragraphs with the same
|
||||
* markup the non-streaming path produces.
|
||||
* line is (optionally) chart-token-stripped, then run through the closed-set
|
||||
* inline renderer (`**bold**` + the catalog-whitelisted Learn linkifier).
|
||||
* Stripping per LINE preserves the `\n` breaks that a whole-string strip
|
||||
* would collapse. Exported so the Coach streaming renderer can settle
|
||||
* completed paragraphs with the same markup the non-streaming path produces.
|
||||
*/
|
||||
export function ParagraphText({
|
||||
text,
|
||||
@@ -127,7 +169,7 @@ export function ParagraphText({
|
||||
return (
|
||||
<Fragment key={i}>
|
||||
{i > 0 && <br />}
|
||||
{linkify ? linkifyLearnLinks(cleaned) : cleaned}
|
||||
{renderInline(cleaned, linkify)}
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
@@ -135,6 +177,66 @@ export function ParagraphText({
|
||||
);
|
||||
}
|
||||
|
||||
/** `- ` / `– ` / `• ` list-item marker (leading whitespace tolerated). */
|
||||
const BULLET_LINE_RE = /^\s*[-–•]\s+/;
|
||||
|
||||
/**
|
||||
* Render ONE blank-line-delimited block: consecutive `- ` lines group into a
|
||||
* real `<ul>` (marker stripped, one `<li>` per line), the remaining line runs
|
||||
* render as `<p>` with `<br/>` joins — so a model reply that enumerates
|
||||
* ("- option one\n- option two") reads as a list instead of dash-prefixed
|
||||
* text lines. Same pure line-splitting as everything else here: no parser,
|
||||
* no markup passthrough. Exported for the Coach streaming renderer.
|
||||
*/
|
||||
export function ProseBlock({
|
||||
text,
|
||||
strip = false,
|
||||
linkify = true,
|
||||
paragraphClassName,
|
||||
}: {
|
||||
text: string;
|
||||
strip?: boolean;
|
||||
linkify?: boolean;
|
||||
paragraphClassName?: string;
|
||||
}) {
|
||||
const blockClass = cn(paragraphClassName ?? PROSE_PARAGRAPH_CLASS);
|
||||
const lines = text.split("\n");
|
||||
const runs: Array<{ bullets: boolean; lines: string[] }> = [];
|
||||
for (const line of lines) {
|
||||
const bullets = BULLET_LINE_RE.test(line);
|
||||
const last = runs[runs.length - 1];
|
||||
if (last && last.bullets === bullets) last.lines.push(line);
|
||||
else runs.push({ bullets, lines: [line] });
|
||||
}
|
||||
return (
|
||||
<>
|
||||
{runs.map((run, i) =>
|
||||
run.bullets ? (
|
||||
<ul key={i} className={cn(blockClass, "list-disc space-y-1 pl-5")}>
|
||||
{run.lines.map((line, j) => (
|
||||
<li key={j}>
|
||||
<ParagraphText
|
||||
text={line.replace(BULLET_LINE_RE, "")}
|
||||
strip={strip}
|
||||
linkify={linkify}
|
||||
/>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<p key={i} className={blockClass}>
|
||||
<ParagraphText
|
||||
text={run.lines.join("\n")}
|
||||
strip={strip}
|
||||
linkify={linkify}
|
||||
/>
|
||||
</p>
|
||||
),
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export interface ProseBlocksProps {
|
||||
/** Trusted-as-text prose (model output already verified server-side). */
|
||||
text: string;
|
||||
@@ -166,9 +268,13 @@ export function ProseBlocks({
|
||||
return (
|
||||
<>
|
||||
{paras.map((p, i) => (
|
||||
<p key={i} className={cn(paragraphClassName ?? PROSE_PARAGRAPH_CLASS)}>
|
||||
<ParagraphText text={p} strip={strip} linkify={linkify} />
|
||||
</p>
|
||||
<ProseBlock
|
||||
key={i}
|
||||
text={p}
|
||||
strip={strip}
|
||||
linkify={linkify}
|
||||
paragraphClassName={paragraphClassName}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
|
||||
Reference in new issue
Block a user