Skip to content

Commit b7403a9

Browse files
committed
fix(docs-ai): derive structured ui from markdown fallback responses
1 parent 3cc2e71 commit b7403a9

File tree

2 files changed

+295
-1
lines changed

2 files changed

+295
-1
lines changed

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

Lines changed: 289 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,19 @@ interface ToolRenderContext {
2929
relatedLinkUrls: string[];
3030
}
3131

32+
interface DerivedRelatedLink {
33+
title: string;
34+
url: string;
35+
}
36+
37+
interface DerivedPropsRow {
38+
name: string;
39+
type: string;
40+
required: boolean;
41+
description: string;
42+
defaultValue: string | null;
43+
}
44+
3245
function getRecord(value: unknown): Record<string, unknown> {
3346
if (!value || typeof value !== "object") return {};
3447
return value as Record<string, unknown>;
@@ -97,6 +110,172 @@ function getReactTypeTableRowsFromOutput(output: unknown): Array<{
97110
);
98111
}
99112

113+
function normalizeSeedDocsUrl(rawUrl: string): string | null {
114+
try {
115+
const parsed = new URL(rawUrl);
116+
const isSeedDomain =
117+
parsed.hostname === "seed-design.io" || parsed.hostname === "www.seed-design.io";
118+
119+
if (!isSeedDomain) return null;
120+
return `https://seed-design.io${parsed.pathname}${parsed.search}${parsed.hash}`;
121+
} catch {
122+
return null;
123+
}
124+
}
125+
126+
function extractRelatedLinksFromText(text: string): { text: string; links: DerivedRelatedLink[] } {
127+
if (!text.includes("http")) {
128+
return { text, links: [] };
129+
}
130+
131+
const links: DerivedRelatedLink[] = [];
132+
const lines = text.split("\n");
133+
const keptLines: string[] = [];
134+
const markdownLinkRegex = /\[([^\]]+)\]\((https?:\/\/[^)]+)\)/g;
135+
136+
for (const line of lines) {
137+
const markdownMatches = [...line.matchAll(markdownLinkRegex)];
138+
if (markdownMatches.length > 0) {
139+
let lineWithoutLinks = line;
140+
let allSeedLinks = true;
141+
142+
for (const match of markdownMatches) {
143+
const title = match[1]?.trim();
144+
const normalizedUrl = normalizeSeedDocsUrl(match[2] ?? "");
145+
146+
if (!title || !normalizedUrl) {
147+
allSeedLinks = false;
148+
continue;
149+
}
150+
151+
links.push({ title, url: normalizedUrl });
152+
lineWithoutLinks = lineWithoutLinks.replace(match[0], "");
153+
}
154+
155+
if (allSeedLinks && lineWithoutLinks.replace(/^[\s*\-0-9.]+/, "").trim().length === 0) {
156+
continue;
157+
}
158+
159+
keptLines.push(lineWithoutLinks);
160+
continue;
161+
}
162+
163+
const rawUrlMatch = line.match(/https?:\/\/seed-design\.io[^\s)\]]*/i);
164+
if (rawUrlMatch) {
165+
const normalizedUrl = normalizeSeedDocsUrl(rawUrlMatch[0]);
166+
if (normalizedUrl) {
167+
const prefix = line.slice(0, rawUrlMatch.index ?? 0).trim();
168+
const title = prefix.replace(/^[-*]\s*/, "").trim() || normalizedUrl;
169+
links.push({ title, url: normalizedUrl });
170+
171+
if (line.replace(rawUrlMatch[0], "").trim().length === 0) {
172+
continue;
173+
}
174+
}
175+
}
176+
177+
keptLines.push(line);
178+
}
179+
180+
const deduped = Array.from(
181+
links.reduce((map, link) => {
182+
if (!map.has(link.url)) {
183+
map.set(link.url, link);
184+
}
185+
return map;
186+
}, new Map<string, DerivedRelatedLink>()),
187+
).map(([, link]) => link);
188+
189+
return {
190+
text: keptLines.join("\n"),
191+
links: deduped,
192+
};
193+
}
194+
195+
function parsePropNames(raw: string): string[] {
196+
const normalized = raw
197+
.replaceAll("`", "")
198+
.replaceAll("**", "")
199+
.replaceAll("'", "")
200+
.replaceAll('"', "")
201+
.trim();
202+
203+
return normalized
204+
.split("/")
205+
.flatMap((token) => token.split(","))
206+
.map((token) => token.trim())
207+
.filter(Boolean);
208+
}
209+
210+
function extractPropsTableRowsFromText(text: string): { text: string; rows: DerivedPropsRow[] } {
211+
const propsSectionRegex =
212+
/(?:^|\n)#{1,6}\s*(?:\s*)?props[^\n]*\n([\s\S]*?)(?=\n#{1,6}\s|\n---|\n$|$)/i;
213+
const match = text.match(propsSectionRegex);
214+
if (!match || !match[1]) {
215+
return { text, rows: [] };
216+
}
217+
218+
const sectionBody = match[1];
219+
const rows: DerivedPropsRow[] = [];
220+
221+
for (const line of sectionBody.split("\n")) {
222+
const bulletMatch = line.match(/^\s*[-*]\s*(.+?)\s*:\s*(.+)\s*$/);
223+
if (!bulletMatch) continue;
224+
225+
const names = parsePropNames(bulletMatch[1]);
226+
const description = bulletMatch[2].trim();
227+
if (!description || names.length === 0) continue;
228+
229+
for (const name of names) {
230+
rows.push({
231+
name,
232+
type: "-",
233+
required: false,
234+
description,
235+
defaultValue: null,
236+
});
237+
}
238+
}
239+
240+
if (rows.length === 0) {
241+
return { text, rows: [] };
242+
}
243+
244+
return {
245+
text: text.replace(propsSectionRegex, "\n"),
246+
rows,
247+
};
248+
}
249+
250+
function extractInstallTargetFromCode(code: string, language: string): string | null {
251+
if (!/(bash|sh|zsh|shell|console)/i.test(language) && !code.includes("@seed-design/cli@latest")) {
252+
return null;
253+
}
254+
255+
const match = code.match(/@seed-design\/cli@latest\s+add\s+([^\s]+)/i);
256+
if (!match?.[1]) return null;
257+
return match[1].trim();
258+
}
259+
260+
function extractComponentPreviewNameFromCode(code: string, language: string): string | null {
261+
if (!/(tsx|jsx|typescript|javascript|ts|js)/i.test(language)) {
262+
return null;
263+
}
264+
265+
const match = code.match(/from\s+["']seed-design\/ui\/([a-z0-9-]+)["']/i);
266+
if (!match?.[1]) return null;
267+
268+
return `react/${match[1]}/preview`;
269+
}
270+
271+
function isRelatedLinksToolPart(part: UIMessage["parts"][number]): boolean {
272+
if (part.type === "dynamic-tool") {
273+
return part.toolName === "findRelatedLinks";
274+
}
275+
276+
return typeof part.type === "string" && part.type === "tool-findRelatedLinks";
277+
}
278+
100279
function getToolCopyText(toolName: string, input: unknown, output: unknown): string[] {
101280
const lines: string[] = [];
102281
const safeInput = getRecord(input);
@@ -325,6 +504,7 @@ function sanitizeTextForTools(text: string, toolContext: ToolRenderContext): str
325504
}
326505

327506
sanitized = sanitized
507+
.replace(/^#{1,6}\s*/gm, "")
328508
.split("\n")
329509
.map((line) => line.replace(/\s+$/g, ""))
330510
.join("\n")
@@ -420,6 +600,16 @@ export function ChatMessage({ message }: { message: UIMessage }) {
420600
}
421601
};
422602

603+
const orderedParts = isUser
604+
? message.parts
605+
: [
606+
...message.parts.filter((part) => !isRelatedLinksToolPart(part)),
607+
...message.parts.filter((part) => isRelatedLinksToolPart(part)),
608+
];
609+
610+
const derivedRelatedLinks: DerivedRelatedLink[] = [];
611+
const derivedPropsRows: DerivedPropsRow[] = [];
612+
423613
return (
424614
<div className={`flex ${isUser ? "justify-end" : "justify-start"}`}>
425615
<div className={isUser ? "max-w-[90%]" : "min-w-[85%] max-w-[90%]"}>
@@ -430,7 +620,7 @@ export function ChatMessage({ message }: { message: UIMessage }) {
430620
: ""
431621
}`}
432622
>
433-
{message.parts.map((part, i) => {
623+
{orderedParts.map((part, i) => {
434624
if (part.type === "text" && part.text) {
435625
const segments = !isUser
436626
? parseMarkdownCodeBlocks(part.text)
@@ -449,6 +639,43 @@ export function ChatMessage({ message }: { message: UIMessage }) {
449639
return null;
450640
}
451641

642+
if (!isUser) {
643+
const installTarget = !toolContext.hasInstallation
644+
? extractInstallTargetFromCode(segment.code, segment.language)
645+
: null;
646+
647+
if (installTarget) {
648+
return (
649+
<ToolResultRenderer
650+
key={`derived-install-${i}-${segmentIndex}`}
651+
toolName="showInstallation"
652+
input={{ name: installTarget }}
653+
state="output-available"
654+
/>
655+
);
656+
}
657+
658+
const previewName =
659+
!toolContext.hasComponentExample && !toolContext.hasCodeTool
660+
? extractComponentPreviewNameFromCode(segment.code, segment.language)
661+
: null;
662+
663+
if (previewName) {
664+
return (
665+
<ToolResultRenderer
666+
key={`derived-preview-${i}-${segmentIndex}`}
667+
toolName="showComponentExample"
668+
input={{
669+
name: previewName,
670+
code: segment.code,
671+
language: segment.language,
672+
}}
673+
state="output-available"
674+
/>
675+
);
676+
}
677+
}
678+
452679
return (
453680
<div key={`segment-code-${segmentIndex}`} className="my-2">
454681
<DynamicCodeBlock lang={segment.language} code={segment.code} />
@@ -468,6 +695,49 @@ export function ChatMessage({ message }: { message: UIMessage }) {
468695
return null;
469696
}
470697

698+
if (!isUser) {
699+
let derivedText = visibleText;
700+
701+
if (!toolContext.hasRelatedLinks) {
702+
const relatedExtracted = extractRelatedLinksFromText(derivedText);
703+
if (relatedExtracted.links.length > 0) {
704+
for (const link of relatedExtracted.links) {
705+
if (
706+
!derivedRelatedLinks.some((existing) => existing.url === link.url)
707+
) {
708+
derivedRelatedLinks.push(link);
709+
}
710+
}
711+
}
712+
derivedText = relatedExtracted.text;
713+
}
714+
715+
if (!toolContext.hasReactTypeTable) {
716+
const propsExtracted = extractPropsTableRowsFromText(derivedText);
717+
if (propsExtracted.rows.length > 0) {
718+
for (const row of propsExtracted.rows) {
719+
if (!derivedPropsRows.some((existing) => existing.name === row.name)) {
720+
derivedPropsRows.push(row);
721+
}
722+
}
723+
}
724+
derivedText = propsExtracted.text;
725+
}
726+
727+
if (!derivedText.trim()) {
728+
return null;
729+
}
730+
731+
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}
737+
</div>
738+
);
739+
}
740+
471741
return (
472742
<div
473743
key={`segment-text-${segmentIndex}`}
@@ -521,6 +791,24 @@ export function ChatMessage({ message }: { message: UIMessage }) {
521791

522792
return null;
523793
})}
794+
795+
{!isUser && !toolContext.hasReactTypeTable && derivedPropsRows.length > 0 && (
796+
<ToolResultRenderer
797+
toolName="showReactTypeTable"
798+
input={{}}
799+
state="output-available"
800+
output={{ rows: derivedPropsRows }}
801+
/>
802+
)}
803+
804+
{!isUser && !toolContext.hasRelatedLinks && derivedRelatedLinks.length > 0 && (
805+
<ToolResultRenderer
806+
toolName="findRelatedLinks"
807+
input={{}}
808+
state="output-available"
809+
output={{ links: derivedRelatedLinks }}
810+
/>
811+
)}
524812
</div>
525813

526814
{!isUser && (

docs/lib/ai/system-prompt.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,12 @@ SEED Design is the design system for Karrot (당근), a Korean secondhand market
2727
- Prefer structured, tool-first responses:
2828
1) call tools for preview/install/code/props/related-links
2929
2) then provide only a short connective explanation
30+
- For component guides, use this order:
31+
1) showComponentExample
32+
2) showInstallation
33+
3) showReactTypeTable
34+
4) findRelatedLinks (must appear at the end)
35+
- Avoid markdown-formatted section blocks for installation/example/props/related-links when corresponding tools are available
3036
- Do not leave placeholder headings or empty sections such as "### 설치", "### 사용 예시", "관련 링크" when a tool already rendered that section
3137
- Respond in the same language as the user's message (default: Korean)
3238
- Be concise but thorough

0 commit comments

Comments
 (0)