Skip to content

Commit 9363181

Browse files
authored
Merge pull request #12 from database-playground/pan93412/dbp-82-在-frontend-使用-vercelai-實作初級問答型-ai
DBP-82: 在 frontend 使用 Vercel AI 實作適性化指導 AI
2 parents c6c07b0 + f5333e4 commit 9363181

File tree

9 files changed

+2569
-27
lines changed

9 files changed

+2569
-27
lines changed
Lines changed: 348 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,348 @@
1+
"use client";
2+
3+
import { Button } from "@/components/ui/button";
4+
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
5+
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle, SheetTrigger } from "@/components/ui/sheet";
6+
import { useChat } from "@ai-sdk/react";
7+
import { DefaultChatTransport } from "ai";
8+
import { Bot, ChevronDown, ChevronUp, Loader2, Send, Sparkles, User } from "lucide-react";
9+
import { useEffect, useRef, useState } from "react";
10+
import { Streamdown } from "streamdown";
11+
12+
export interface AIAssistantProps {
13+
questionId: string;
14+
}
15+
16+
export function AIAssistant({ questionId }: AIAssistantProps) {
17+
const [open, setOpen] = useState(false);
18+
const [input, setInput] = useState("");
19+
const [reasoningOpenMap, setReasoningOpenMap] = useState<Record<string, boolean>>({});
20+
const messagesEndRef = useRef<HTMLDivElement>(null);
21+
22+
const toggleReasoning = (messageId: string) => {
23+
setReasoningOpenMap((prev) => ({
24+
...prev,
25+
[messageId]: !prev[messageId],
26+
}));
27+
};
28+
29+
const { messages, sendMessage, status } = useChat({
30+
transport: new DefaultChatTransport({
31+
api: "/api/chat",
32+
body: {
33+
questionId,
34+
},
35+
credentials: "include",
36+
}),
37+
});
38+
39+
const isLoading = status === "submitted" || status === "streaming";
40+
41+
const scrollToBottom = () => {
42+
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
43+
};
44+
45+
useEffect(() => {
46+
scrollToBottom();
47+
}, [messages]);
48+
49+
const handleSubmit = async (e: React.FormEvent) => {
50+
e.preventDefault();
51+
if (!input.trim() || isLoading) return;
52+
53+
const userMessage = input.trim();
54+
setInput("");
55+
56+
await sendMessage({
57+
role: "user",
58+
parts: [{ type: "text", text: userMessage }],
59+
});
60+
};
61+
62+
const quickActions = [
63+
{
64+
label: "分析我的答案",
65+
prompt: "請幫我分析我提交的答案,告訴我哪裡有問題",
66+
},
67+
{
68+
label: "比較答案差異",
69+
prompt: "請比較我的答案和正確答案,告訴我有什麼不同",
70+
},
71+
{
72+
label: "給我提示",
73+
prompt: "請給我一些提示,幫助我改進我的 SQL 查詢",
74+
},
75+
{
76+
label: "解釋題目",
77+
prompt: "請解釋這個題目在問什麼,我應該注意什麼",
78+
},
79+
];
80+
81+
return (
82+
<Sheet open={open} onOpenChange={setOpen}>
83+
<SheetTrigger asChild>
84+
<Button
85+
variant="outline"
86+
size="icon"
87+
className={`
88+
fixed right-6 bottom-6 z-50 h-14 w-14 rounded-full shadow-lg
89+
transition-all
90+
hover:scale-110
91+
`}
92+
>
93+
<Sparkles className="h-6 w-6" />
94+
<span className="sr-only">開啟 AI 助理</span>
95+
</Button>
96+
</SheetTrigger>
97+
98+
<SheetContent
99+
className={`
100+
flex w-full flex-col gap-0 p-0
101+
sm:max-w-md
102+
`}
103+
>
104+
<SheetHeader className="border-b p-4">
105+
<SheetTitle className="flex items-center gap-2">
106+
<Bot className="h-5 w-5" />
107+
SQL 學習助理
108+
</SheetTitle>
109+
<SheetDescription>
110+
我可以幫助你理解題目、分析答案錯誤、提供學習建議
111+
</SheetDescription>
112+
</SheetHeader>
113+
114+
{/* Messages Area */}
115+
<div className="flex-1 space-y-4 overflow-y-auto p-4">
116+
{messages.length === 0 && (
117+
<div className="space-y-4">
118+
<div className="rounded-lg border bg-muted/50 p-4">
119+
<p className="text-sm text-muted-foreground">
120+
👋 你好!我是你的 SQL 學習助理。你可以問我關於這個題目的任何問題,或者使用下面的快速操作。
121+
</p>
122+
</div>
123+
124+
<div className="space-y-2">
125+
<p className="text-xs font-medium text-muted-foreground">
126+
快速操作
127+
</p>
128+
<div className="grid grid-cols-1 gap-2">
129+
{quickActions.map((action) => (
130+
<Button
131+
key={action.label}
132+
variant="outline"
133+
size="sm"
134+
className="h-auto justify-start text-left"
135+
onClick={() => {
136+
sendMessage({
137+
role: "user",
138+
parts: [{ type: "text", text: action.prompt }],
139+
});
140+
}}
141+
disabled={isLoading}
142+
>
143+
{action.label}
144+
</Button>
145+
))}
146+
</div>
147+
</div>
148+
</div>
149+
)}
150+
151+
{messages.map((message) => (
152+
<div
153+
key={message.id}
154+
className={`
155+
flex gap-3
156+
${message.role === "user" ? "justify-end" : "justify-start"}
157+
`}
158+
>
159+
{message.role === "assistant" && (
160+
<div
161+
className={`
162+
flex h-8 w-8 shrink-0 items-center justify-center
163+
rounded-full bg-primary
164+
`}
165+
>
166+
<Bot className="h-4 w-4 text-primary-foreground" />
167+
</div>
168+
)}
169+
170+
<div
171+
className={`
172+
max-w-[80%] space-y-2 rounded-lg px-4 py-2
173+
${
174+
message.role === "user"
175+
? "bg-primary text-primary-foreground"
176+
: "bg-muted"
177+
}
178+
`}
179+
>
180+
{message.parts.map((part, index) => {
181+
if (part.type === "text") {
182+
return (
183+
<div
184+
key={`${message.id}-text-${index}`}
185+
className="text-sm leading-relaxed whitespace-pre-wrap"
186+
>
187+
<Streamdown>
188+
{part.text}
189+
</Streamdown>
190+
</div>
191+
);
192+
}
193+
194+
if (part.type === "reasoning") {
195+
return (
196+
<Collapsible
197+
key={`${message.id}-reasoning-${index}`}
198+
open={reasoningOpenMap[`${message.id}-${index}`]}
199+
onOpenChange={() => toggleReasoning(`${message.id}-${index}`)}
200+
className="border-l-2 border-primary/30 pl-3"
201+
>
202+
<CollapsibleTrigger asChild>
203+
<Button
204+
variant="ghost"
205+
size="sm"
206+
className={`
207+
flex w-full items-center justify-between p-0
208+
text-xs
209+
hover:bg-transparent
210+
`}
211+
>
212+
<span
213+
className={`
214+
flex items-center gap-1.5 text-muted-foreground
215+
`}
216+
>
217+
<Sparkles className="h-3 w-3" />
218+
思考過程
219+
</span>
220+
{reasoningOpenMap[`${message.id}-${index}`]
221+
? (
222+
<ChevronUp
223+
className={`h-3 w-3 text-muted-foreground`}
224+
/>
225+
)
226+
: (
227+
<ChevronDown
228+
className={`h-3 w-3 text-muted-foreground`}
229+
/>
230+
)}
231+
</Button>
232+
</CollapsibleTrigger>
233+
<CollapsibleContent className="mt-2">
234+
<div
235+
className={`
236+
text-xs leading-relaxed whitespace-pre-wrap
237+
text-muted-foreground
238+
`}
239+
>
240+
<Streamdown>
241+
{part.text}
242+
</Streamdown>
243+
</div>
244+
</CollapsibleContent>
245+
</Collapsible>
246+
);
247+
}
248+
249+
if (part.type.startsWith("tool-")) {
250+
return (
251+
<div
252+
key={`${message.id}-tool-${index}`}
253+
className="text-xs text-muted-foreground"
254+
>
255+
🔧 已執行: {getToolName(part.type.replace("tool-", ""))}
256+
</div>
257+
);
258+
}
259+
260+
return null;
261+
})}
262+
</div>
263+
264+
{message.role === "user" && (
265+
<div
266+
className={`
267+
flex h-8 w-8 shrink-0 items-center justify-center
268+
rounded-full bg-secondary
269+
`}
270+
>
271+
<User className="h-4 w-4" />
272+
</div>
273+
)}
274+
</div>
275+
))}
276+
277+
{isLoading && messages[messages.length - 1]?.role === "user" && (
278+
<div className="flex gap-3">
279+
<div
280+
className={`
281+
flex h-8 w-8 shrink-0 items-center justify-center rounded-full
282+
bg-primary
283+
`}
284+
>
285+
<Bot className="h-4 w-4 text-primary-foreground" />
286+
</div>
287+
<div
288+
className={`
289+
flex items-center gap-2 rounded-lg bg-muted px-4 py-2
290+
`}
291+
>
292+
<Loader2 className="h-4 w-4 animate-spin" />
293+
<span className="text-sm text-muted-foreground">思考中…</span>
294+
</div>
295+
</div>
296+
)}
297+
298+
<div ref={messagesEndRef} />
299+
</div>
300+
301+
{/* Input Area */}
302+
<form
303+
onSubmit={handleSubmit}
304+
className="border-t bg-background p-4"
305+
>
306+
<div className="flex gap-2">
307+
<input
308+
type="text"
309+
value={input}
310+
onChange={(e) => setInput(e.target.value)}
311+
placeholder="輸入你的問題……"
312+
className={`
313+
flex-1 rounded-lg border bg-background px-4 py-2 text-sm
314+
transition-colors
315+
placeholder:text-muted-foreground
316+
focus:ring-2 focus:ring-ring focus:outline-hidden
317+
disabled:cursor-not-allowed disabled:opacity-50
318+
`}
319+
disabled={isLoading}
320+
/>
321+
<Button
322+
type="submit"
323+
size="icon"
324+
disabled={!input.trim() || isLoading}
325+
>
326+
{isLoading ? <Loader2 className="h-4 w-4 animate-spin" /> : (
327+
<Send
328+
className={`h-4 w-4`}
329+
/>
330+
)}
331+
<span className="sr-only">送出訊息</span>
332+
</Button>
333+
</div>
334+
</form>
335+
</SheetContent>
336+
</Sheet>
337+
);
338+
}
339+
340+
function getToolName(toolName: string): string {
341+
const toolNames: Record<string, string> = {
342+
getQuestionSchema: "確認題目 schema",
343+
getMyAnswer: "確認使用者提交的答案",
344+
getCorrectAnswer: "確認正確答案",
345+
};
346+
347+
return toolNames[toolName] || toolName;
348+
}

app/(app)/challenges/[id]/page.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import type { Metadata } from "next";
22
import { Suspense } from "react";
3+
import { AIAssistant } from "./_components/ai-assistant";
34
import Header from "./_components/header";
45
import HeaderSkeleton from "./_components/header/skeleton";
56
import PracticeIDE from "./_components/ide";
@@ -24,6 +25,8 @@ export default async function ChallengePage({
2425
<Suspense>
2526
<PracticeIDE id={id} />
2627
</Suspense>
28+
29+
<AIAssistant questionId={id} />
2730
</div>
2831
);
2932
}

0 commit comments

Comments
 (0)