Skip to content

Commit 411cdd8

Browse files
committed
feat(docs-ai): add react props type table tool rendering
1 parent bd4a98b commit 411cdd8

File tree

3 files changed

+291
-3
lines changed

3 files changed

+291
-3
lines changed

docs/components/ai-panel/tool-result-renderer.tsx

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import { ComponentPreview } from "@/components/component-preview";
44
import Link from "next/link";
55
import { DynamicCodeBlock } from "fumadocs-ui/components/dynamic-codeblock";
6+
import { TypeTable } from "fumadocs-ui/components/type-table";
67
import { Tab, Tabs } from "fumadocs-ui/components/tabs";
78
import { m } from "motion/react";
89
import type { ReactNode } from "react";
@@ -21,6 +22,14 @@ interface RelatedLink {
2122
href: string;
2223
}
2324

25+
interface ReactTypeTableRow {
26+
name: string;
27+
type: string;
28+
required: boolean;
29+
description: string;
30+
defaultValue: string | null;
31+
}
32+
2433
const INSTALL_COMMANDS = [
2534
{ manager: "npm", commandPrefix: "npx" },
2635
{ manager: "yarn", commandPrefix: "yarn dlx" },
@@ -95,6 +104,43 @@ function getToolOutputCode(output: unknown): { code: string; language: string }
95104
};
96105
}
97106

107+
function getReactTypeTableRows(output: unknown): ReactTypeTableRow[] {
108+
if (!output || typeof output !== "object") return [];
109+
110+
const rows = (output as { rows?: unknown }).rows;
111+
if (!Array.isArray(rows)) return [];
112+
113+
return rows
114+
.map((row) => {
115+
if (!row || typeof row !== "object") return null;
116+
117+
const safeRow = row as {
118+
name?: unknown;
119+
type?: unknown;
120+
required?: unknown;
121+
description?: unknown;
122+
defaultValue?: unknown;
123+
};
124+
125+
if (
126+
typeof safeRow.name !== "string" ||
127+
typeof safeRow.type !== "string" ||
128+
typeof safeRow.required !== "boolean"
129+
) {
130+
return null;
131+
}
132+
133+
return {
134+
name: safeRow.name,
135+
type: safeRow.type,
136+
required: safeRow.required,
137+
description: typeof safeRow.description === "string" ? safeRow.description : "",
138+
defaultValue: typeof safeRow.defaultValue === "string" ? safeRow.defaultValue : null,
139+
};
140+
})
141+
.filter((row): row is ReactTypeTableRow => row !== null);
142+
}
143+
98144
export function ToolResultRenderer({ toolName, input, state, output }: ToolResultRendererProps) {
99145
// 아직 입력이 완전하지 않으면 로딩 표시
100146
if (state === "input-streaming") {
@@ -195,6 +241,54 @@ export function ToolResultRenderer({ toolName, input, state, output }: ToolResul
195241
</ToolFadeIn>
196242
);
197243

244+
case "showReactTypeTable": {
245+
if (state === "input-available") {
246+
return (
247+
<ToolFadeIn>
248+
<ToolLoading label="Props 타입 테이블 생성 중..." />
249+
</ToolFadeIn>
250+
);
251+
}
252+
253+
const rows = getReactTypeTableRows(output);
254+
if (rows.length === 0) {
255+
const error =
256+
output &&
257+
typeof output === "object" &&
258+
typeof (output as { error?: unknown }).error === "string"
259+
? ((output as { error: string }).error ?? "")
260+
: "";
261+
262+
return (
263+
<ToolFadeIn>
264+
<div className="my-1 text-xs text-fd-muted-foreground">
265+
{error || "Props 타입 테이블을 찾지 못했어요."}
266+
</div>
267+
</ToolFadeIn>
268+
);
269+
}
270+
271+
const type = Object.fromEntries(
272+
rows.map((row) => [
273+
row.name,
274+
{
275+
type: row.type,
276+
required: row.required,
277+
...(row.description ? { description: row.description } : {}),
278+
...(row.defaultValue ? { default: row.defaultValue } : {}),
279+
},
280+
]),
281+
);
282+
283+
return (
284+
<ToolFadeIn>
285+
<div className="my-2">
286+
<TypeTable type={type} />
287+
</div>
288+
</ToolFadeIn>
289+
);
290+
}
291+
198292
case "findRelatedLinks": {
199293
if (state === "input-available") {
200294
return (

docs/lib/ai/system-prompt.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,16 +12,22 @@ SEED Design is the design system for Karrot (당근), a Korean secondhand market
1212
- showComponentExample: Display an interactive component preview and example code in the chat
1313
- showInstallation: Show CLI installation command for a component
1414
- showCodeBlock: Display a syntax-highlighted code snippet
15+
- showReactTypeTable: Render React props/type table from source (e.g., ActionButtonProps)
1516
- findRelatedLinks: Find related documentation links from https://seed-design.io/sitemap.xml (include both docs and react when relevant)
1617
1718
## Guidelines
1819
- Always search documentation before answering technical questions
1920
- Use showComponentExample when users ask to see how a component looks
2021
- Use showInstallation when users ask how to install or set up a component
2122
- Use showCodeBlock for code snippets and usage examples instead of writing raw fenced code blocks in plain text
23+
- Use showReactTypeTable when users ask for props/types/interfaces of a React component
2224
- Before finalizing a technical answer, call findRelatedLinks with the user's query and attach relevant links when available
2325
- If a tool already rendered preview/install/code/related-links, do not repeat the same content in plain text
24-
- Keep plain text complementary to tool output only (short context, no duplicated commands/snippets/links)
26+
- Keep plain text complementary to tool output only (short context, no duplicated commands/snippets/links/props lists)
27+
- Prefer structured, tool-first responses:
28+
1) call tools for preview/install/code/props/related-links
29+
2) then provide only a short connective explanation
30+
- Do not leave placeholder headings or empty sections such as "### 설치", "### 사용 예시", "관련 링크" when a tool already rendered that section
2531
- Respond in the same language as the user's message (default: Korean)
2632
- Be concise but thorough
2733
- When referencing components, use their official names (e.g., ActionButton, Checkbox, Tabs)

docs/lib/ai/tools.ts

Lines changed: 190 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,44 @@ import { tool } from "ai";
22
import fs from "node:fs/promises";
33
import path from "node:path";
44
import { z } from "zod";
5+
import { typeTableGenerator } from "../../components/type-table/generator";
6+
import { getReactTypeTableOutput } from "../../components/type-table/get-react-type-table";
57
import { findRelatedLinks as searchRelatedLinks } from "./sitemap-links";
68

79
const SITEMAP_URL = "https://seed-design.io/sitemap.xml";
810

9-
async function findExamplesDir(): Promise<string | null> {
11+
async function findDocsRoot(): Promise<string | null> {
1012
const cwd = process.cwd();
11-
const candidates = [path.join(cwd, "examples"), path.join(cwd, "docs", "examples")];
13+
const candidates = [path.join(cwd, "docs"), cwd];
14+
15+
for (const candidate of candidates) {
16+
try {
17+
const [hasRegistry, hasComponents] = await Promise.all([
18+
fs
19+
.stat(path.join(candidate, "registry"))
20+
.then((stat) => stat.isDirectory())
21+
.catch(() => false),
22+
fs
23+
.stat(path.join(candidate, "components"))
24+
.then((stat) => stat.isDirectory())
25+
.catch(() => false),
26+
]);
27+
28+
if (hasRegistry && hasComponents) {
29+
return candidate;
30+
}
31+
} catch {
32+
// ignore and try next candidate
33+
}
34+
}
35+
36+
return null;
37+
}
38+
39+
async function findExamplesDir(): Promise<string | null> {
40+
const docsRoot = await findDocsRoot();
41+
if (!docsRoot) return null;
42+
const candidates = [path.join(docsRoot, "examples")];
1243

1344
for (const candidate of candidates) {
1445
try {
@@ -40,6 +71,123 @@ async function loadExampleCode(name: string): Promise<string | null> {
4071
}
4172
}
4273

74+
function toPascalCase(kebabCase: string): string {
75+
return kebabCase
76+
.split("-")
77+
.filter(Boolean)
78+
.map((segment) => segment.charAt(0).toUpperCase() + segment.slice(1))
79+
.join("");
80+
}
81+
82+
function resolveDocsRelativePath(docsRoot: string, docsRelativePath: string): string | null {
83+
const normalizedPath = path.normalize(docsRelativePath);
84+
const withoutDotPrefix = normalizedPath.startsWith("./")
85+
? normalizedPath.slice(2)
86+
: normalizedPath;
87+
88+
if (withoutDotPrefix.startsWith("..")) return null;
89+
90+
const resolvedPath = path.resolve(docsRoot, withoutDotPrefix);
91+
const rootPath = path.resolve(docsRoot);
92+
if (!resolvedPath.startsWith(`${rootPath}${path.sep}`)) return null;
93+
94+
return resolvedPath;
95+
}
96+
97+
async function loadReactTypeTable(input: {
98+
component?: string;
99+
path?: string;
100+
name?: string;
101+
}): Promise<{
102+
shown: boolean;
103+
typeName: string;
104+
sourcePath: string;
105+
rows: Array<{
106+
name: string;
107+
type: string;
108+
required: boolean;
109+
description: string;
110+
defaultValue: string | null;
111+
}>;
112+
error?: string;
113+
}> {
114+
const docsRoot = await findDocsRoot();
115+
if (!docsRoot) {
116+
return {
117+
shown: false,
118+
typeName: input.name ?? "",
119+
sourcePath: input.path ?? "",
120+
rows: [],
121+
error: "docs 루트를 찾지 못했어요.",
122+
};
123+
}
124+
125+
const componentName = input.component?.trim();
126+
const sourcePath =
127+
input.path?.trim() ||
128+
(componentName ? `./registry/ui/${componentName}.tsx` : "./registry/ui/action-button.tsx");
129+
const typeName =
130+
input.name?.trim() || (componentName ? `${toPascalCase(componentName)}Props` : "");
131+
132+
if (!typeName) {
133+
return {
134+
shown: false,
135+
typeName: "",
136+
sourcePath,
137+
rows: [],
138+
error: "타입 이름(name)이 비어 있습니다.",
139+
};
140+
}
141+
142+
const resolvedPath = resolveDocsRelativePath(docsRoot, sourcePath);
143+
if (!resolvedPath) {
144+
return {
145+
shown: false,
146+
typeName,
147+
sourcePath,
148+
rows: [],
149+
error: "유효하지 않은 타입 경로입니다.",
150+
};
151+
}
152+
153+
try {
154+
const output = await getReactTypeTableOutput({
155+
generator: typeTableGenerator,
156+
path: resolvedPath,
157+
name: typeName,
158+
});
159+
160+
const table = output.find((item) => item.name === typeName) ?? output[0];
161+
const rows =
162+
table?.entries.map((entry) => ({
163+
name: entry.name,
164+
type: entry.type,
165+
required: entry.required,
166+
description: entry.description,
167+
defaultValue:
168+
entry.tags.find((tag) => tag.name === "default" || tag.name === "defaultValue")?.text ??
169+
null,
170+
})) ?? [];
171+
172+
return {
173+
shown: rows.length > 0,
174+
typeName: table?.name ?? typeName,
175+
sourcePath,
176+
rows,
177+
...(rows.length === 0 ? { error: "타입 테이블 항목을 찾지 못했어요." } : {}),
178+
};
179+
} catch (error) {
180+
const message = error instanceof Error ? error.message : "타입 테이블 로딩에 실패했습니다.";
181+
return {
182+
shown: false,
183+
typeName,
184+
sourcePath,
185+
rows: [],
186+
error: message,
187+
};
188+
}
189+
}
190+
43191
/**
44192
* 채팅 UI 렌더링용 도구.
45193
* 서버에서도 execute를 제공해 tool result가 누락되지 않도록 한다.
@@ -101,6 +249,46 @@ export const clientTools = {
101249
}),
102250
}),
103251

252+
showReactTypeTable: tool({
253+
description:
254+
"Show React props type table. Use when users ask for props/types of a React component. Prefer component input like 'action-button'.",
255+
inputSchema: z
256+
.object({
257+
component: z
258+
.string()
259+
.min(1)
260+
.max(64)
261+
.regex(
262+
/^[a-z0-9]+(?:-[a-z0-9]+)*$/,
263+
"Expected kebab-case component name (lowercase letters, numbers, hyphen)",
264+
)
265+
.optional()
266+
.describe("React component name in kebab-case, e.g., action-button"),
267+
path: z
268+
.string()
269+
.min(1)
270+
.max(200)
271+
.optional()
272+
.describe("Path to source file from docs root, e.g., ./registry/ui/action-button.tsx"),
273+
name: z
274+
.string()
275+
.min(1)
276+
.max(120)
277+
.optional()
278+
.describe("Type name to extract, e.g., ActionButtonProps"),
279+
})
280+
.refine((value) => Boolean(value.component || value.path), {
281+
message: "Either component or path is required",
282+
}),
283+
execute: async ({ component, path: sourcePath, name }) => {
284+
return await loadReactTypeTable({
285+
component,
286+
path: sourcePath,
287+
name,
288+
});
289+
},
290+
}),
291+
104292
findRelatedLinks: tool({
105293
description:
106294
"Find related documentation URLs from the SEED Design sitemap. Use this before final response and attach related links when available. Prefer a mix of docs and react links when relevant.",

0 commit comments

Comments
 (0)