forked from enderofwings/NexusOS
209 lines
6.0 KiB
React
209 lines
6.0 KiB
React
import { useState } from "react";
|
|
|
|
// Parse content into an array of {type, value, lang, streaming} blocks.
|
|
// Handles:
|
|
// - ```lang\ncode``` (well-formed)
|
|
// - ```code``` (no language, no newline — model formatting bug)
|
|
// - ```lang\ncode (unclosed — streaming in progress)
|
|
function parseBlocks(content) {
|
|
const blocks = [];
|
|
let i = 0;
|
|
|
|
while (i < content.length) {
|
|
const fenceStart = content.indexOf("```", i);
|
|
|
|
if (fenceStart === -1) {
|
|
if (i < content.length) blocks.push({ type: "text", value: content.slice(i) });
|
|
break;
|
|
}
|
|
|
|
if (fenceStart > i) blocks.push({ type: "text", value: content.slice(i, fenceStart) });
|
|
|
|
let j = fenceStart + 3;
|
|
let lang = "";
|
|
|
|
// Language specifier is valid only when word-chars are followed by a newline.
|
|
// If there's no newline (e.g. ```pythonprint(...)) treat everything as code.
|
|
const langMatch = content.slice(j).match(/^(\w+)(\r?\n)/);
|
|
if (langMatch) {
|
|
lang = langMatch[1];
|
|
j += langMatch[0].length;
|
|
} else if (/^\r?\n/.test(content.slice(j))) {
|
|
j += content[j] === "\r" ? 2 : 1;
|
|
}
|
|
// else: no newline — leave j as-is, lang stays ""
|
|
|
|
const closeIdx = content.indexOf("```", j);
|
|
if (closeIdx === -1) {
|
|
blocks.push({ type: "code", lang, value: content.slice(j), streaming: true });
|
|
i = content.length;
|
|
} else {
|
|
blocks.push({ type: "code", lang, value: content.slice(j, closeIdx).replace(/\n$/, ""), streaming: false });
|
|
i = closeIdx + 3;
|
|
}
|
|
}
|
|
|
|
return blocks;
|
|
}
|
|
|
|
export function Markdown({ content }) {
|
|
if (!content) return null;
|
|
const blocks = parseBlocks(content);
|
|
return (
|
|
<div style={{ lineHeight: "1.6" }}>
|
|
{blocks.map((block, i) =>
|
|
block.type === "code"
|
|
? <CodeBlock key={i} lang={block.lang} value={block.value} streaming={block.streaming} />
|
|
: <TextBlock key={i} text={block.value} />
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function CodeBlock({ lang, value, streaming }) {
|
|
const [copied, setCopied] = useState(false);
|
|
|
|
const copy = () => {
|
|
navigator.clipboard.writeText(value.trimEnd()).then(() => {
|
|
setCopied(true);
|
|
setTimeout(() => setCopied(false), 1500);
|
|
});
|
|
};
|
|
|
|
return (
|
|
<div style={{
|
|
background: "#0d0d0d",
|
|
border: "1px solid #2a2a2a",
|
|
borderRadius: "6px",
|
|
margin: "0.5rem 0",
|
|
overflow: "hidden",
|
|
}}>
|
|
<div style={{
|
|
display: "flex",
|
|
justifyContent: "space-between",
|
|
alignItems: "center",
|
|
padding: "0.3rem 0.75rem",
|
|
background: "#161616",
|
|
borderBottom: "1px solid #2a2a2a",
|
|
}}>
|
|
<span style={{ fontSize: "0.75rem", color: "#666", fontFamily: "monospace" }}>
|
|
{lang || "code"}
|
|
{streaming && <span style={{ color: "#444", marginLeft: "0.5rem" }}>●</span>}
|
|
</span>
|
|
{!streaming && (
|
|
<button onClick={copy} style={{
|
|
background: "transparent",
|
|
border: "none",
|
|
color: copied ? "#4caf50" : "#555",
|
|
cursor: "pointer",
|
|
fontSize: "0.75rem",
|
|
padding: "0.1rem 0.3rem",
|
|
}}>
|
|
{copied ? "Copied!" : "Copy"}
|
|
</button>
|
|
)}
|
|
</div>
|
|
<pre style={{
|
|
padding: "0.75rem 1rem",
|
|
overflowX: "auto",
|
|
fontSize: "0.85rem",
|
|
lineHeight: "1.5",
|
|
margin: 0,
|
|
fontFamily: "monospace",
|
|
}}>
|
|
<code>{value.trimEnd()}</code>
|
|
</pre>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function TextBlock({ text }) {
|
|
const lines = text.split("\n");
|
|
const elements = [];
|
|
let i = 0;
|
|
|
|
while (i < lines.length) {
|
|
const line = lines[i];
|
|
|
|
const hMatch = line.match(/^(#{1,3})\s+(.+)/);
|
|
if (hMatch) {
|
|
const level = hMatch[1].length;
|
|
const sizes = { 1: "1.2rem", 2: "1.05rem", 3: "0.95rem" };
|
|
elements.push(
|
|
<div key={i} style={{ fontWeight: "700", fontSize: sizes[level], margin: "0.6rem 0 0.2rem", color: "#fff" }}>
|
|
{inlineMarkdown(hMatch[2])}
|
|
</div>
|
|
);
|
|
i++; continue;
|
|
}
|
|
|
|
if (/^[-*]\s+/.test(line)) {
|
|
const items = [];
|
|
while (i < lines.length && /^[-*]\s+/.test(lines[i])) {
|
|
items.push(<li key={i}>{inlineMarkdown(lines[i].replace(/^[-*]\s+/, ""))}</li>);
|
|
i++;
|
|
}
|
|
elements.push(<ul key={`ul-${i}`} style={{ paddingLeft: "1.25rem", margin: "0.25rem 0" }}>{items}</ul>);
|
|
continue;
|
|
}
|
|
|
|
if (/^\d+\.\s+/.test(line)) {
|
|
const items = [];
|
|
while (i < lines.length && /^\d+\.\s+/.test(lines[i])) {
|
|
items.push(<li key={i}>{inlineMarkdown(lines[i].replace(/^\d+\.\s+/, ""))}</li>);
|
|
i++;
|
|
}
|
|
elements.push(<ol key={`ol-${i}`} style={{ paddingLeft: "1.25rem", margin: "0.25rem 0" }}>{items}</ol>);
|
|
continue;
|
|
}
|
|
|
|
if (/^---+$/.test(line.trim())) {
|
|
elements.push(<hr key={i} style={{ border: "none", borderTop: "1px solid #333", margin: "0.5rem 0" }} />);
|
|
i++; continue;
|
|
}
|
|
|
|
if (line.trim() === "") {
|
|
elements.push(<div key={i} style={{ height: "0.4rem" }} />);
|
|
i++; continue;
|
|
}
|
|
|
|
elements.push(<div key={i}>{inlineMarkdown(line)}</div>);
|
|
i++;
|
|
}
|
|
|
|
return <>{elements}</>;
|
|
}
|
|
|
|
function inlineMarkdown(text) {
|
|
const parts = [];
|
|
const re = /(\*\*(.+?)\*\*|\*(.+?)\*|`([^`]+)`)/g;
|
|
let last = 0;
|
|
let m;
|
|
|
|
while ((m = re.exec(text)) !== null) {
|
|
if (m.index > last) parts.push(text.slice(last, m.index));
|
|
if (m[2] !== undefined) {
|
|
parts.push(<strong key={m.index}>{m[2]}</strong>);
|
|
} else if (m[3] !== undefined) {
|
|
parts.push(<em key={m.index}>{m[3]}</em>);
|
|
} else if (m[4] !== undefined) {
|
|
parts.push(
|
|
<code key={m.index} style={{
|
|
background: "#1e1e1e",
|
|
border: "1px solid #333",
|
|
borderRadius: "3px",
|
|
padding: "0.1rem 0.35rem",
|
|
fontSize: "0.85em",
|
|
fontFamily: "monospace",
|
|
}}>
|
|
{m[4]}
|
|
</code>
|
|
);
|
|
}
|
|
last = m.index + m[0].length;
|
|
}
|
|
|
|
if (last < text.length) parts.push(text.slice(last));
|
|
return parts.length === 1 && typeof parts[0] === "string" ? parts[0] : parts;
|
|
}
|