Skip to content

Commit fe67b01

Browse files
committed
fix(docs): harden ai panel request and tool validation
1 parent 323da7f commit fe67b01

File tree

5 files changed

+68
-15
lines changed

5 files changed

+68
-15
lines changed

docs/app/api/chat/route.ts

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { stepCountIs, streamText } from "ai";
33
import { systemPrompt } from "@/lib/ai/system-prompt";
44
import { clientTools } from "@/lib/ai/tools";
55
import { getMCPTools } from "@/lib/ai/mcp-client";
6+
import { z } from "zod";
67

78
const llmRouter = createOpenAI({
89
baseURL: process.env.LLM_ROUTER_URL,
@@ -14,8 +15,32 @@ const llmRouter = createOpenAI({
1415
name: "llm-router",
1516
});
1617

18+
const chatRequestSchema = z.object({
19+
messages: z
20+
.array(
21+
z
22+
.object({
23+
role: z.string().min(1),
24+
})
25+
.passthrough(),
26+
)
27+
.min(1),
28+
});
29+
1730
export async function POST(req: Request) {
18-
const { messages } = await req.json();
31+
let body: unknown;
32+
try {
33+
body = await req.json();
34+
} catch {
35+
return Response.json({ error: "Invalid JSON" }, { status: 400 });
36+
}
37+
38+
const parsed = chatRequestSchema.safeParse(body);
39+
if (!parsed.success) {
40+
return Response.json({ error: "Invalid request body" }, { status: 400 });
41+
}
42+
43+
const messages = parsed.data.messages as Parameters<typeof streamText>[0]["messages"];
1944

2045
const mcpTools = await getMCPTools();
2146

docs/components/ai-panel/ai-panel-provider.tsx

Lines changed: 16 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -18,19 +18,27 @@ export function AIPanelProvider({ children }: { children: ReactNode }) {
1818
const [hydrated, setHydrated] = useState(false);
1919

2020
useEffect(() => {
21-
const stored = localStorage.getItem(STORAGE_KEY);
22-
if (stored !== null) {
23-
setIsOpen(stored === "true");
24-
} else {
25-
// 모바일에서는 기본 닫힘
21+
try {
22+
const stored = window.localStorage.getItem(STORAGE_KEY);
23+
if (stored !== null) {
24+
setIsOpen(stored === "true");
25+
} else {
26+
// 모바일에서는 기본 닫힘
27+
setIsOpen(window.innerWidth >= 768);
28+
}
29+
} catch {
2630
setIsOpen(window.innerWidth >= 768);
31+
} finally {
32+
setHydrated(true);
2733
}
28-
setHydrated(true);
2934
}, []);
3035

3136
useEffect(() => {
32-
if (hydrated) {
33-
localStorage.setItem(STORAGE_KEY, String(isOpen));
37+
if (!hydrated) return;
38+
try {
39+
window.localStorage.setItem(STORAGE_KEY, String(isOpen));
40+
} catch {
41+
// ignore storage write errors
3442
}
3543
}, [isOpen, hydrated]);
3644

docs/components/ai-panel/ai-panel-toggle.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@ export function AIPanelToggle() {
1111
type="button"
1212
onClick={toggle}
1313
className="seed-ai-panel-toggle inline-flex items-center gap-1.5 rounded-lg px-2.5 py-1.5 text-sm text-fd-muted-foreground hover:text-fd-foreground hover:bg-fd-accent transition-colors"
14+
aria-label={isOpen ? "AI 패널 닫기" : "AI 패널 열기"}
15+
aria-pressed={isOpen}
1416
title={isOpen ? "AI 패널 닫기" : "AI 패널 열기"}
1517
>
1618
<IconSparkle2 width={16} height={16} />

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

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,9 @@ export function ToolResultRenderer({ toolName, input, state }: ToolResultRendere
2323

2424
switch (toolName) {
2525
case "showComponentExample":
26+
if (typeof input.name !== "string") {
27+
return <div className="my-1 text-xs text-fd-muted-foreground">잘못된 미리보기 입력입니다.</div>;
28+
}
2629
return (
2730
<div className="my-2 rounded-lg border border-fd-border overflow-hidden">
2831
<div className="px-3 py-1.5 bg-fd-muted text-xs font-medium text-fd-muted-foreground border-b border-fd-border">
@@ -36,14 +39,17 @@ export function ToolResultRenderer({ toolName, input, state }: ToolResultRendere
3639
}
3740
>
3841
<div className="min-h-32 p-4">
39-
<ComponentPreview name={input.name as string} />
42+
<ComponentPreview name={input.name} />
4043
</div>
4144
</Suspense>
4245
</div>
4346
);
4447

4548
case "showInstallation": {
46-
const componentName = input.name as string;
49+
if (typeof input.name !== "string") {
50+
return <div className="my-1 text-xs text-fd-muted-foreground">잘못된 설치 입력입니다.</div>;
51+
}
52+
const componentName = input.name;
4753
return (
4854
<div className="my-2 rounded-lg border border-fd-border overflow-hidden">
4955
<div className="px-3 py-1.5 bg-fd-muted text-xs font-medium text-fd-muted-foreground border-b border-fd-border">
@@ -60,15 +66,15 @@ export function ToolResultRenderer({ toolName, input, state }: ToolResultRendere
6066
}
6167

6268
case "showCodeBlock":
69+
if (typeof input.code !== "string") {
70+
return <div className="my-1 text-xs text-fd-muted-foreground">코드 블록 입력이 올바르지 않습니다.</div>;
71+
}
6372
return (
6473
<div className="my-2">
6574
{typeof input.title === "string" && (
6675
<div className="text-xs font-medium text-fd-muted-foreground mb-1">{input.title}</div>
6776
)}
68-
<DynamicCodeBlock
69-
lang={(input.language as string) || "tsx"}
70-
code={input.code as string}
71-
/>
77+
<DynamicCodeBlock lang={typeof input.language === "string" ? input.language : "tsx"} code={input.code} />
7278
</div>
7379
);
7480

docs/lib/ai/tools.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,12 @@ export const clientTools = {
1111
inputSchema: z.object({
1212
name: z
1313
.string()
14+
.min(3, "Component example path is too short")
15+
.max(120, "Component example path is too long")
16+
.regex(
17+
/^(react|lynx|breeze)\/[a-z0-9]+(?:-[a-z0-9]+)*\/preview$/,
18+
"Expected '<platform>/<component>/preview' format",
19+
)
1420
.describe(
1521
'Component example path, e.g., "react/action-button/preview", "react/checkbox/preview"',
1622
),
@@ -22,6 +28,12 @@ export const clientTools = {
2228
inputSchema: z.object({
2329
name: z
2430
.string()
31+
.min(1, "Component name is required")
32+
.max(64, "Component name is too long")
33+
.regex(
34+
/^[a-z0-9]+(?:-[a-z0-9]+)*$/,
35+
"Expected kebab-case component name (lowercase letters, numbers, hyphen)",
36+
)
2537
.describe('Component name in kebab-case, e.g., "action-button", "checkbox", "tabs"'),
2638
}),
2739
}),

0 commit comments

Comments
 (0)