import React from 'react'; /** Recursively concatenate the text content of a React.ReactNode tree. * Used by the markdown renderers (e.g. `
` extracting code) and
* by consumers that need a plain-string label out of link children. */
export function extractTextFromChildren(children: React.ReactNode): string {
if (typeof children === 'string') return children;
if (typeof children === 'number') return String(children);
if (React.isValidElement(children)) {
const props = children.props as { children?: React.ReactNode };
return extractTextFromChildren(props.children);
}
if (Array.isArray(children)) {
return children.map(extractTextFromChildren).join('');
}
return '';
}
/**
* Auto-detect whether `text` should bypass ReactMarkdown and render as
* a flat ``. Used as the *fallback* when the
* caller doesn't pass `plainText` explicitly to `MarkdownMessage`.
*
* The signal we trust: short, single-paragraph, no markdown markers.
* Anything longer / multi-paragraph / structurally suggestive falls
* through to the markdown pipeline — false negatives there are cheap
* (markdown renders prose correctly), false positives in the prose
* branch surface as escaped `*` / `#` / etc. so we err markdown-ward.
*
* Thresholds were picked from chat-bubble UX research (see ChatGPT,
* Claude.ai, WhatsApp): roughly "would a person have written this in
* one keystroke without thinking about formatting?". If you find them
* too tight or too loose, tune here — every consumer goes through
* `MarkdownMessage` so the change is universal.
*/
export function looksLikePlainProse(text: string): boolean {
const trimmed = text.trim();
// Empty / whitespace-only — render as plain (cheap path, nothing to parse).
if (trimmed.length === 0) return true;
// Long enough that it's likely a structured assistant reply.
if (trimmed.length > 500) return false;
// Paragraph break → almost certainly markdown territory.
if (/\n\s*\n/.test(trimmed)) return false;
// Many single-line breaks → probably a list or stanza.
const newlineCount = (trimmed.match(/\n/g) || []).length;
if (newlineCount > 4) return false;
// Any markdown marker → defer to ReactMarkdown.
if (hasMarkdownSyntax(trimmed)) return false;
return true;
}
/** Affordance test: does this string look like markdown? Used to skip
* the (heavier) ReactMarkdown pipeline when the content is pure
* prose. NOT a validator — false negatives are fine; false positives
* cost a render but render correctly. */
export function hasMarkdownSyntax(text: string): boolean {
// Newlines after trim → render as markdown so paragraphs work.
if (text.trim().includes('\n')) return true;
// Inline HTML tags (`
`, ``, ``, …) — common in OpenAPI
// descriptions. Without this branch the fast path would escape them
// and the user sees literal angle brackets.
if (/<\/?[a-zA-Z][a-zA-Z0-9-]*(\s[^>]*)?\/?>/.test(text)) return true;
const patterns = [
/^#{1,6}\s/m, // Headers
/\*\*[^*]+\*\*/, // Bold
/\*[^*]+\*/, // Italic
/__[^_]+__/, // Bold (underscore)
/_[^_]+_/, // Italic (underscore)
/\[.+\]\(.+\)/, // Links
/!\[.*\]\(.+\)/, // Images
/```[\s\S]*```/, // Code blocks
/`[^`]+`/, // Inline code
/^\s*[-*+]\s/m, // Unordered lists
/^\s*\d+\.\s/m, // Ordered lists
/^\s*>/m, // Blockquotes
/\|.+\|/, // Tables
/^---+$/m, // Horizontal rules
/~~[^~]+~~/, // Strikethrough
];
return patterns.some((p) => p.test(text));
}