Skip to content

Commit ecafdef

Browse files
committed
feat: ai assistant based on vercel/ai
1 parent c6c07b0 commit ecafdef

File tree

9 files changed

+2439
-27
lines changed

9 files changed

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

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)