Skip to content

Commit ecfd65d

Browse files
committed
feat(docs-ai): add richer tool rendering and related links tool
1 parent 67964da commit ecfd65d

File tree

4 files changed

+462
-45
lines changed

4 files changed

+462
-45
lines changed

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

Lines changed: 148 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -1,92 +1,198 @@
11
"use client";
22

33
import { ComponentPreview } from "@/components/component-preview";
4+
import Link from "next/link";
45
import { DynamicCodeBlock } from "fumadocs-ui/components/dynamic-codeblock";
5-
import { Suspense } from "react";
6+
import { Tab, Tabs } from "fumadocs-ui/components/tabs";
7+
import { ProgressCircle } from "seed-design/ui/progress-circle";
68

79
interface ToolResultRendererProps {
810
toolName: string;
911
input: Record<string, unknown>;
1012
state: string;
13+
output?: unknown;
1114
}
1215

13-
export function ToolResultRenderer({ toolName, input, state }: ToolResultRendererProps) {
16+
interface RelatedLink {
17+
title: string;
18+
url: string;
19+
href: string;
20+
}
21+
22+
const INSTALL_COMMANDS = [
23+
{ manager: "npm", commandPrefix: "npx" },
24+
{ manager: "yarn", commandPrefix: "yarn dlx" },
25+
{ manager: "pnpm", commandPrefix: "pnpm dlx" },
26+
{ manager: "bun", commandPrefix: "bunx" },
27+
] as const;
28+
29+
function ToolLoading({ label }: { label: string }) {
30+
return (
31+
<div className="my-1 flex items-center gap-2 text-xs text-fd-muted-foreground">
32+
<ProgressCircle size="24" value={undefined} />
33+
{label}
34+
</div>
35+
);
36+
}
37+
38+
function getRelatedLinks(output: unknown): RelatedLink[] {
39+
if (!output || typeof output !== "object") return [];
40+
41+
const links = (output as { links?: unknown }).links;
42+
if (!Array.isArray(links)) return [];
43+
44+
return links
45+
.map((link) => {
46+
if (!link || typeof link !== "object") return null;
47+
const title = (link as { title?: unknown }).title;
48+
const url = (link as { url?: unknown }).url;
49+
if (typeof title !== "string" || typeof url !== "string") return null;
50+
try {
51+
const parsed = new URL(url);
52+
const isSeedDomain =
53+
parsed.hostname === "seed-design.io" || parsed.hostname === "www.seed-design.io";
54+
55+
if (!isSeedDomain) {
56+
return null;
57+
}
58+
59+
return {
60+
title,
61+
url,
62+
href: `${parsed.pathname}${parsed.search}${parsed.hash}`,
63+
};
64+
} catch {
65+
return null;
66+
}
67+
})
68+
.filter((link): link is RelatedLink => link !== null);
69+
}
70+
71+
function getToolOutputCode(output: unknown): { code: string; language: string } | null {
72+
if (!output || typeof output !== "object") return null;
73+
74+
const code = (output as { code?: unknown }).code;
75+
if (typeof code !== "string") return null;
76+
77+
const language = (output as { language?: unknown }).language;
78+
return {
79+
code,
80+
language: typeof language === "string" ? language : "tsx",
81+
};
82+
}
83+
84+
export function ToolResultRenderer({ toolName, input, state, output }: ToolResultRendererProps) {
1485
// 아직 입력이 완전하지 않으면 로딩 표시
1586
if (state === "input-streaming") {
16-
return (
17-
<div className="my-1 flex items-center gap-2 text-xs text-fd-muted-foreground">
18-
<span className="inline-block size-3 rounded-full border-2 border-fd-muted-foreground border-t-transparent animate-spin" />
19-
처리 중...
20-
</div>
21-
);
87+
return <ToolLoading label="처리 중..." />;
2288
}
2389

2490
switch (toolName) {
25-
case "showComponentExample":
91+
case "showComponentExample": {
2692
if (typeof input.name !== "string") {
27-
return <div className="my-1 text-xs text-fd-muted-foreground">잘못된 미리보기 입력입니다.</div>;
93+
return (
94+
<div className="my-1 text-xs text-fd-muted-foreground">잘못된 미리보기 입력입니다.</div>
95+
);
2896
}
97+
98+
const outputCode = getToolOutputCode(output);
99+
const inlineCode = typeof input.code === "string" ? input.code : null;
100+
const code = outputCode?.code ?? inlineCode;
101+
const language = outputCode?.language ?? "tsx";
102+
const isCodeLoading = state === "input-available" && !code;
103+
29104
return (
30-
<div className="my-2 rounded-lg border border-fd-border overflow-hidden">
31-
<div className="px-3 py-1.5 bg-fd-muted text-xs font-medium text-fd-muted-foreground border-b border-fd-border">
32-
컴포넌트 미리보기
33-
</div>
34-
<Suspense
35-
fallback={
36-
<div className="flex items-center justify-center p-8 text-sm text-fd-muted-foreground">
37-
로딩 중...
105+
<div className="my-2">
106+
<Tabs items={["미리보기", "코드"]}>
107+
<Tab value="미리보기">
108+
<div className="flex min-h-80">
109+
<ComponentPreview name={input.name} />
38110
</div>
39-
}
40-
>
41-
<div className="min-h-32 p-4">
42-
<ComponentPreview name={input.name} />
43-
</div>
44-
</Suspense>
111+
</Tab>
112+
<Tab value="코드">
113+
{code && <DynamicCodeBlock lang={language} code={code} />}
114+
{isCodeLoading && <ToolLoading label="예시 코드를 불러오는 중..." />}
115+
{!code && !isCodeLoading && (
116+
<div className="text-xs text-fd-muted-foreground">예시 코드를 찾지 못했어요.</div>
117+
)}
118+
</Tab>
119+
</Tabs>
45120
</div>
46121
);
122+
}
47123

48124
case "showInstallation": {
49125
if (typeof input.name !== "string") {
50126
return <div className="my-1 text-xs text-fd-muted-foreground">잘못된 설치 입력입니다.</div>;
51127
}
52128
const componentName = input.name;
129+
53130
return (
54-
<div className="my-2 rounded-lg border border-fd-border overflow-hidden">
55-
<div className="px-3 py-1.5 bg-fd-muted text-xs font-medium text-fd-muted-foreground border-b border-fd-border">
56-
설치 방법: {componentName}
57-
</div>
58-
<div className="p-3 text-sm">
59-
<DynamicCodeBlock
60-
lang="bash"
61-
code={`npx @seed-design/cli@latest add ${componentName}`}
62-
/>
63-
</div>
131+
<div className="my-2">
132+
<Tabs items={INSTALL_COMMANDS.map(({ manager }) => manager)}>
133+
{INSTALL_COMMANDS.map(({ manager, commandPrefix }) => (
134+
<Tab key={manager} value={manager}>
135+
<DynamicCodeBlock
136+
lang="bash"
137+
code={`${commandPrefix} @seed-design/cli@latest add ${componentName}`}
138+
/>
139+
</Tab>
140+
))}
141+
</Tabs>
64142
</div>
65143
);
66144
}
67145

68146
case "showCodeBlock":
69147
if (typeof input.code !== "string") {
70-
return <div className="my-1 text-xs text-fd-muted-foreground">코드 블록 입력이 올바르지 않습니다.</div>;
148+
return (
149+
<div className="my-1 text-xs text-fd-muted-foreground">
150+
코드 블록 입력이 올바르지 않습니다.
151+
</div>
152+
);
71153
}
72154
return (
73155
<div className="my-2">
74156
{typeof input.title === "string" && (
75157
<div className="text-xs font-medium text-fd-muted-foreground mb-1">{input.title}</div>
76158
)}
77-
<DynamicCodeBlock lang={typeof input.language === "string" ? input.language : "tsx"} code={input.code} />
159+
<DynamicCodeBlock
160+
lang={typeof input.language === "string" ? input.language : "tsx"}
161+
code={input.code}
162+
/>
78163
</div>
79164
);
80165

166+
case "findRelatedLinks": {
167+
if (state === "input-available") {
168+
return <ToolLoading label="관련 링크 찾는 중..." />;
169+
}
170+
171+
const links = getRelatedLinks(output);
172+
if (links.length === 0) {
173+
return null;
174+
}
175+
176+
return (
177+
<ul className="my-2 list-disc pl-5 text-sm space-y-2">
178+
{links.map((link) => (
179+
<li key={link.url}>
180+
<div className="space-y-0.5">
181+
<Link href={link.href} className="text-fd-primary hover:underline break-all">
182+
{link.title}
183+
</Link>
184+
<div className="text-xs text-fd-muted-foreground break-all">{link.url}</div>
185+
</div>
186+
</li>
187+
))}
188+
</ul>
189+
);
190+
}
191+
81192
default:
82193
// MCP 서버사이드 도구: 실행 중이면 로딩 표시
83194
if (state === "input-available") {
84-
return (
85-
<div className="my-1 flex items-center gap-2 text-xs text-fd-muted-foreground">
86-
<span className="inline-block size-3 rounded-full border-2 border-fd-muted-foreground border-t-transparent animate-spin" />
87-
문서 검색 중...
88-
</div>
89-
);
195+
return <ToolLoading label="문서 검색 중..." />;
90196
}
91197
return null;
92198
}

0 commit comments

Comments
 (0)