Skip to content

Commit 3e12f68

Browse files
committed
feat(docs-ai): render assistant markdown in chat panel
1 parent b7403a9 commit 3e12f68

File tree

2 files changed

+246
-5
lines changed

2 files changed

+246
-5
lines changed
Lines changed: 243 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,243 @@
1+
"use client";
2+
3+
import Link from "next/link";
4+
import { DynamicCodeBlock } from "fumadocs-ui/components/dynamic-codeblock";
5+
import type {
6+
Blockquote,
7+
Code,
8+
Content,
9+
Emphasis,
10+
Heading,
11+
HTML,
12+
InlineCode,
13+
Link as MdLink,
14+
List,
15+
ListItem,
16+
Paragraph,
17+
Root,
18+
Strong,
19+
Table,
20+
TableCell,
21+
TableRow,
22+
Text,
23+
ThematicBreak,
24+
} from "mdast";
25+
import { useMemo, type ReactNode } from "react";
26+
import remarkGfm from "remark-gfm";
27+
import remarkParse from "remark-parse";
28+
import { unified } from "unified";
29+
30+
const markdownProcessor = unified().use(remarkParse).use(remarkGfm);
31+
32+
function toInternalSeedHref(url: string): string | null {
33+
try {
34+
const parsed = new URL(url);
35+
const isSeedDomain =
36+
parsed.hostname === "seed-design.io" || parsed.hostname === "www.seed-design.io";
37+
if (!isSeedDomain) return null;
38+
39+
return `${parsed.pathname}${parsed.search}${parsed.hash}`;
40+
} catch {
41+
return null;
42+
}
43+
}
44+
45+
function renderInlineNode(node: Content, key: string): ReactNode {
46+
switch (node.type) {
47+
case "text":
48+
return <span key={key}>{(node as Text).value}</span>;
49+
case "strong":
50+
return (
51+
<strong key={key}>
52+
{(node as Strong).children.map((child, i) => renderInlineNode(child, `${key}-${i}`))}
53+
</strong>
54+
);
55+
case "emphasis":
56+
return (
57+
<em key={key}>
58+
{(node as Emphasis).children.map((child, i) => renderInlineNode(child, `${key}-${i}`))}
59+
</em>
60+
);
61+
case "inlineCode":
62+
return <code key={key}>{(node as InlineCode).value}</code>;
63+
case "link": {
64+
const linkNode = node as MdLink;
65+
const internalHref = toInternalSeedHref(linkNode.url);
66+
const children = linkNode.children.map((child, i) => renderInlineNode(child, `${key}-${i}`));
67+
68+
if (internalHref) {
69+
return (
70+
<Link key={key} href={internalHref} className="text-fd-primary hover:underline break-all">
71+
{children}
72+
</Link>
73+
);
74+
}
75+
76+
return (
77+
<a
78+
key={key}
79+
href={linkNode.url}
80+
target="_blank"
81+
rel="noreferrer"
82+
className="text-fd-primary hover:underline break-all"
83+
>
84+
{children}
85+
</a>
86+
);
87+
}
88+
case "break":
89+
return <br key={key} />;
90+
default:
91+
return null;
92+
}
93+
}
94+
95+
function renderTableCell(cell: TableCell, key: string, align?: "left" | "right" | "center" | null) {
96+
const className =
97+
align === "right" ? "text-right" : align === "center" ? "text-center" : "text-left";
98+
99+
return (
100+
<td key={key} className={`px-2 py-1 align-top ${className}`}>
101+
{cell.children.map((child, i) => renderInlineNode(child, `${key}-${i}`))}
102+
</td>
103+
);
104+
}
105+
106+
function renderBlockNode(node: Content, key: string): ReactNode {
107+
switch (node.type) {
108+
case "heading": {
109+
const headingNode = node as Heading;
110+
const content = headingNode.children.map((child, i) =>
111+
renderInlineNode(child, `${key}-${i}`),
112+
);
113+
114+
if (headingNode.depth <= 2) return <h2 key={key}>{content}</h2>;
115+
if (headingNode.depth === 3) return <h3 key={key}>{content}</h3>;
116+
if (headingNode.depth === 4) return <h4 key={key}>{content}</h4>;
117+
return <h5 key={key}>{content}</h5>;
118+
}
119+
120+
case "paragraph": {
121+
const paragraphNode = node as Paragraph;
122+
return (
123+
<p key={key}>
124+
{paragraphNode.children.map((child, i) => renderInlineNode(child, `${key}-${i}`))}
125+
</p>
126+
);
127+
}
128+
129+
case "list": {
130+
const listNode = node as List;
131+
const Element = listNode.ordered ? "ol" : "ul";
132+
return (
133+
<Element key={key}>
134+
{listNode.children.map((child, i) => renderBlockNode(child, `${key}-${i}`))}
135+
</Element>
136+
);
137+
}
138+
139+
case "listItem": {
140+
const listItem = node as ListItem;
141+
return (
142+
<li key={key}>
143+
{listItem.children.map((child, i) => renderBlockNode(child, `${key}-${i}`))}
144+
</li>
145+
);
146+
}
147+
148+
case "blockquote": {
149+
const blockquote = node as Blockquote;
150+
return (
151+
<blockquote key={key}>
152+
{blockquote.children.map((child, i) => renderBlockNode(child, `${key}-${i}`))}
153+
</blockquote>
154+
);
155+
}
156+
157+
case "thematicBreak":
158+
return <hr key={key} />;
159+
160+
case "code": {
161+
const codeNode = node as Code;
162+
return (
163+
<div key={key} className="my-2">
164+
<DynamicCodeBlock lang={codeNode.lang ?? "tsx"} code={codeNode.value} />
165+
</div>
166+
);
167+
}
168+
169+
case "table": {
170+
const tableNode = node as Table;
171+
const [head, ...body] = tableNode.children as TableRow[];
172+
return (
173+
<div key={key} className="my-2 overflow-x-auto">
174+
<table className="w-full border-collapse text-sm">
175+
{head ? (
176+
<thead>
177+
<tr>
178+
{head.children.map((cell, i) => (
179+
<th
180+
key={`${key}-head-${i}`}
181+
className={`px-2 py-1 border-b border-fd-border text-left ${
182+
tableNode.align?.[i] === "right"
183+
? "text-right"
184+
: tableNode.align?.[i] === "center"
185+
? "text-center"
186+
: "text-left"
187+
}`}
188+
>
189+
{cell.children.map((child, j) =>
190+
renderInlineNode(child, `${key}-head-${i}-${j}`),
191+
)}
192+
</th>
193+
))}
194+
</tr>
195+
</thead>
196+
) : null}
197+
<tbody>
198+
{body.map((row, rowIndex) => (
199+
<tr key={`${key}-row-${rowIndex}`}>
200+
{row.children.map((cell, cellIndex) =>
201+
renderTableCell(
202+
cell,
203+
`${key}-row-${rowIndex}-cell-${cellIndex}`,
204+
tableNode.align?.[cellIndex],
205+
),
206+
)}
207+
</tr>
208+
))}
209+
</tbody>
210+
</table>
211+
</div>
212+
);
213+
}
214+
215+
case "html":
216+
return <p key={key}>{(node as HTML).value}</p>;
217+
218+
default:
219+
return null;
220+
}
221+
}
222+
223+
function parseMarkdown(markdown: string): Root | null {
224+
try {
225+
return markdownProcessor.parse(markdown) as Root;
226+
} catch {
227+
return null;
228+
}
229+
}
230+
231+
export function ChatMarkdown({ markdown }: { markdown: string }) {
232+
const root = useMemo(() => parseMarkdown(markdown), [markdown]);
233+
234+
if (!root) {
235+
return <div className="whitespace-pre-wrap break-words">{markdown}</div>;
236+
}
237+
238+
return (
239+
<div className="prose prose-sm dark:prose-invert max-w-none [&>*:first-child]:mt-0 [&>*:last-child]:mb-0">
240+
{root.children.map((node, i) => renderBlockNode(node, `chat-md-${i}`))}
241+
</div>
242+
);
243+
}

docs/components/ai-panel/chat-message.tsx

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import { DynamicCodeBlock } from "fumadocs-ui/components/dynamic-codeblock";
1010
import { AnimatePresence, m } from "motion/react";
1111
import { ActionButton } from "seed-design/ui/action-button";
1212
import { useEffect, useState } from "react";
13+
import { ChatMarkdown } from "./chat-markdown";
1314
import { parseMarkdownCodeBlocks } from "./parse-markdown-code-blocks";
1415
import { ToolResultRenderer } from "./tool-result-renderer";
1516

@@ -729,11 +730,8 @@ export function ChatMessage({ message }: { message: UIMessage }) {
729730
}
730731

731732
return (
732-
<div
733-
key={`segment-text-${segmentIndex}`}
734-
className="text-sm whitespace-pre-wrap break-words prose prose-sm dark:prose-invert max-w-none [&>*:first-child]:mt-0 [&>*:last-child]:mb-0"
735-
>
736-
{derivedText}
733+
<div key={`segment-text-${segmentIndex}`} className="text-sm break-words">
734+
<ChatMarkdown markdown={derivedText} />
737735
</div>
738736
);
739737
}

0 commit comments

Comments
 (0)