Skip to content

Commit 4e8fb53

Browse files
committed
main 최신화
2 parents 02d5d1f + 0944b01 commit 4e8fb53

19 files changed

Lines changed: 807 additions & 271 deletions

File tree

apps/extension/src/pages/create-bookmark.tsx

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -198,9 +198,11 @@ const CreateBookmark = () => {
198198
<p className="text-xs font-semibold">Nebula AI</p>
199199
</button>
200200
</header>
201-
<section>
202-
<Graph2D graphData={graphData} />
203-
</section>
201+
{id && (
202+
<section>
203+
<Graph2D graphData={graphData} />
204+
</section>
205+
)}
204206
<CardWrapper
205207
thumbnailUrl={bookmark.thumbnailUrl}
206208
siteUrl={bookmark.siteUrl}

apps/www/package.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,11 @@
1010
},
1111
"dependencies": {
1212
"3d-force-graph": "^1.77.0",
13+
"@tailwindcss/typography": "^0.5.16",
1314
"@tanstack/react-query": "^5.59.20",
15+
"@types/dompurify": "^3.2.0",
16+
"dompurify": "^3.2.6",
17+
"marked": "^15.0.12",
1418
"next": "15.0.3",
1519
"react": "19.0.0-rc-66855b96-20241106",
1620
"react-d3-tree": "^3.6.6",

apps/www/src/app/api/chat/route.ts

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
import { NextRequest, NextResponse } from "next/server";
2+
3+
const baseUrl = process.env.AI_API_URL;
4+
5+
export async function POST(req: NextRequest) {
6+
try {
7+
const body = await req.json();
8+
const { userId, message, session_id } = body;
9+
10+
const response = await fetch(`${baseUrl}/chat/stream`, {
11+
method: "POST",
12+
headers: {
13+
"Content-Type": "application/json",
14+
},
15+
body: JSON.stringify({
16+
userId,
17+
message,
18+
session_id,
19+
}),
20+
});
21+
22+
if (!response.ok) {
23+
throw new Error(`HTTP error! status: ${response.status}`);
24+
}
25+
26+
// 스트림 응답을 그대로 전달
27+
const stream = new ReadableStream({
28+
async start(controller) {
29+
const reader = response.body?.getReader();
30+
if (!reader) {
31+
controller.close();
32+
return;
33+
}
34+
35+
try {
36+
while (true) {
37+
const { done, value } = await reader.read();
38+
if (done) break;
39+
controller.enqueue(value);
40+
}
41+
} catch (error) {
42+
controller.error(error);
43+
} finally {
44+
controller.close();
45+
}
46+
},
47+
});
48+
49+
return new Response(stream, {
50+
headers: {
51+
"Content-Type": "text/plain; charset=utf-8",
52+
"Cache-Control": "no-cache",
53+
Connection: "keep-alive",
54+
},
55+
});
56+
} catch (error: unknown) {
57+
return NextResponse.json(
58+
{
59+
code: "BAD_REQUEST",
60+
message: `Error: ${(error as Error).message}`,
61+
data: null,
62+
},
63+
{ status: 400 }
64+
);
65+
}
66+
}
Lines changed: 275 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,275 @@
1+
"use client";
2+
3+
import { memo, useCallback, useEffect, useRef, useState } from "react";
4+
5+
import Icon from "@/components/common/icon";
6+
import { useUserInfo } from "@/hooks/use-user-info";
7+
import { useCreateChatSession } from "@/lib/tanstack/mutation/chat";
8+
import { useGetChatMessages } from "@/lib/tanstack/query/chat";
9+
import { cn } from "@repo/ui";
10+
11+
import DOMPurify from "dompurify";
12+
import { marked } from "marked";
13+
14+
interface Message {
15+
role: "user" | "assistant";
16+
content: string;
17+
}
18+
19+
interface ChatProps {
20+
sessionId: string;
21+
onSessionCreated: (newSessionId: string) => void;
22+
}
23+
24+
const AssistantMessage = memo(({ content }: { content: string }) => {
25+
const sanitizedHtml = DOMPurify.sanitize(marked.parse(content) as string);
26+
return (
27+
<div
28+
className="prose prose-sm max-w-none"
29+
dangerouslySetInnerHTML={{
30+
__html: sanitizedHtml,
31+
}}
32+
/>
33+
);
34+
});
35+
AssistantMessage.displayName = "AssistantMessage";
36+
37+
export default function Chat({ sessionId, onSessionCreated }: ChatProps) {
38+
const { userInfo, isLoading: isUserLoading } = useUserInfo();
39+
const { data: chatHistory, isLoading: isHistoryLoading } = useGetChatMessages(sessionId);
40+
const { mutateAsync: createChatSession } = useCreateChatSession();
41+
const [messages, setMessages] = useState<Message[]>([]);
42+
const [streamingContent, setStreamingContent] = useState<string | null>(null);
43+
const [isLoading, setIsLoading] = useState(false);
44+
const messagesEndRef = useRef<HTMLDivElement>(null);
45+
const formRef = useRef<HTMLFormElement>(null);
46+
47+
useEffect(() => {
48+
setMessages([]);
49+
setStreamingContent(null);
50+
}, [sessionId]);
51+
52+
useEffect(() => {
53+
if (chatHistory?.result && chatHistory.result.length > 0 && messages.length === 0) {
54+
setMessages(chatHistory.result[0].messages);
55+
}
56+
}, [chatHistory, messages.length]);
57+
58+
const scrollToBottom = () => {
59+
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
60+
};
61+
62+
useEffect(() => {
63+
scrollToBottom();
64+
}, [messages, streamingContent, isLoading]);
65+
66+
const onStreamResponse = useCallback(async (response: Response) => {
67+
if (!response.body) return;
68+
69+
const reader = response.body.getReader();
70+
const decoder = new TextDecoder("utf-8");
71+
let botResponse = "";
72+
setStreamingContent("");
73+
74+
while (true) {
75+
const { done, value } = await reader.read();
76+
if (done) {
77+
setMessages((prev) => [...prev, { role: "assistant", content: botResponse }]);
78+
setStreamingContent(null);
79+
break;
80+
}
81+
82+
const chunk = decoder.decode(value, { stream: true });
83+
for (const line of chunk.split("\n").filter(Boolean)) {
84+
if (line.trim().startsWith("data: ")) {
85+
try {
86+
const data = line.split("data: ")[1];
87+
if (data) {
88+
const parsed = JSON.parse(data);
89+
if (parsed.type === "chunk" && parsed.data) {
90+
botResponse += parsed.data;
91+
setStreamingContent(botResponse);
92+
}
93+
}
94+
} catch (error) {
95+
console.error("JSON parsing error:", error, "line:", line);
96+
}
97+
}
98+
}
99+
}
100+
}, []);
101+
102+
const onSubmitQuestion = useCallback(
103+
async (e: React.FormEvent<HTMLFormElement>) => {
104+
e.preventDefault();
105+
const formData = new FormData(e.target as HTMLFormElement);
106+
const question = formData.get("question") as string;
107+
if (question.trim() === "" || isLoading || isUserLoading || !userInfo) return;
108+
109+
e.currentTarget.reset();
110+
(e.currentTarget.querySelector("textarea") as HTMLTextAreaElement).style.height = "auto";
111+
112+
setMessages((prev) => [...prev, { role: "user", content: question }]);
113+
setIsLoading(true);
114+
115+
try {
116+
const response = await fetch("/api/chat", {
117+
method: "POST",
118+
headers: { "Content-Type": "application/json" },
119+
body: JSON.stringify({ userId: userInfo.id, message: question, session_id: sessionId }),
120+
});
121+
await onStreamResponse(response);
122+
} catch (error) {
123+
console.error("Failed to send message:", error);
124+
} finally {
125+
setIsLoading(false);
126+
}
127+
},
128+
[isLoading, isUserLoading, userInfo, sessionId, onStreamResponse]
129+
);
130+
131+
const onCreateSessionAndSendMessage = useCallback(
132+
async (e: React.FormEvent<HTMLFormElement>) => {
133+
e.preventDefault();
134+
const formData = new FormData(e.target as HTMLFormElement);
135+
const title = formData.get("title") as string;
136+
const question = formData.get("question") as string;
137+
if (!title.trim() || !question.trim() || isLoading || isUserLoading || !userInfo) return;
138+
139+
setIsLoading(true);
140+
try {
141+
const newSession = await createChatSession({ title });
142+
const newSessionId = newSession.result.sessionId;
143+
onSessionCreated(newSessionId);
144+
setMessages([{ role: "user", content: question }]);
145+
146+
const response = await fetch("/api/chat", {
147+
method: "POST",
148+
headers: { "Content-Type": "application/json" },
149+
body: JSON.stringify({
150+
userId: userInfo.id,
151+
message: question,
152+
session_id: newSessionId,
153+
}),
154+
});
155+
await onStreamResponse(response);
156+
} catch (error) {
157+
console.error("Failed to create chat session or send message:", error);
158+
} finally {
159+
setIsLoading(false);
160+
}
161+
},
162+
[isLoading, isUserLoading, userInfo, createChatSession, onSessionCreated, onStreamResponse]
163+
);
164+
165+
if (sessionId === "") {
166+
return (
167+
<div className="flex w-80 max-w-lg flex-col justify-between rounded-lg border bg-white p-4">
168+
<h3 className="mb-4 text-lg font-semibold">새로운 대화 시작하기</h3>
169+
<form onSubmit={onCreateSessionAndSendMessage} className="flex flex-col gap-4">
170+
<input
171+
name="title"
172+
placeholder="대화 주제를 입력하세요."
173+
className="rounded-md border p-2 outline-none"
174+
disabled={isUserLoading}
175+
required
176+
/>
177+
<textarea
178+
name="question"
179+
placeholder="첫 번째 질문을 입력하세요."
180+
className="rounded-md border p-2 outline-none"
181+
rows={5}
182+
disabled={isUserLoading}
183+
required
184+
/>
185+
<button
186+
type="submit"
187+
className="flex items-center justify-center rounded-md bg-black p-2 text-white disabled:bg-gray-400"
188+
disabled={isUserLoading || isLoading}
189+
>
190+
{isLoading ? "생각 중..." : "질문하고 대화 시작"}
191+
</button>
192+
</form>
193+
</div>
194+
);
195+
}
196+
197+
if (isHistoryLoading) {
198+
return (
199+
<div className="flex h-full w-80 max-w-lg flex-col items-center justify-center rounded-lg border bg-white">
200+
<p className="animate-pulse">대화 기록을 불러오는 중...</p>
201+
</div>
202+
);
203+
}
204+
205+
return (
206+
<div className="flex h-full w-80 max-w-lg flex-col justify-between rounded-lg border bg-white">
207+
<div className="flex-1 space-y-4 overflow-y-auto p-4">
208+
{messages.map((message, index) => (
209+
<div
210+
key={index}
211+
className={cn(
212+
"flex items-start gap-2 text-sm",
213+
message.role === "user" ? "justify-end" : "justify-start"
214+
)}
215+
>
216+
<div
217+
className={cn(
218+
"max-w-[80%] rounded-lg p-2",
219+
message.role === "user" ? "bg-blue-500 text-white" : "bg-gray-200 text-black"
220+
)}
221+
>
222+
{message.role === "user" ? (
223+
<p className="whitespace-pre-wrap">{message.content}</p>
224+
) : (
225+
<AssistantMessage content={message.content} />
226+
)}
227+
</div>
228+
</div>
229+
))}
230+
{streamingContent && streamingContent.length > 0 && (
231+
<div className="flex items-start justify-start gap-2 text-sm">
232+
<div className="max-w-[80%] rounded-lg bg-gray-200 p-2 text-black">
233+
<AssistantMessage content={streamingContent} />
234+
</div>
235+
</div>
236+
)}
237+
{isLoading && !streamingContent && <div className="animate-pulse p-2">생각 중...</div>}
238+
<div ref={messagesEndRef} />
239+
</div>
240+
<form
241+
ref={formRef}
242+
onSubmit={onSubmitQuestion}
243+
className="flex items-center gap-2 border-t p-2"
244+
>
245+
<textarea
246+
name="question"
247+
rows={1}
248+
className="max-h-24 flex-1 resize-none overflow-y-auto bg-transparent p-1 outline-none"
249+
placeholder="챗봇에게 질문을 적어주세요."
250+
disabled={isLoading || isUserLoading}
251+
onInput={(e) => {
252+
const target = e.currentTarget;
253+
target.style.height = "auto";
254+
target.style.height = `${target.scrollHeight}px`;
255+
}}
256+
onKeyDown={(e) => {
257+
if (e.key === "Enter" && !e.shiftKey) {
258+
e.preventDefault();
259+
if (!isLoading && formRef.current) {
260+
formRef.current.requestSubmit();
261+
}
262+
}
263+
}}
264+
/>
265+
<button
266+
type="submit"
267+
className="rounded-full bg-black p-1 disabled:bg-gray-400"
268+
disabled={isLoading || isUserLoading}
269+
>
270+
<Icon.send fill="#fff" size={16} />
271+
</button>
272+
</form>
273+
</div>
274+
);
275+
}

0 commit comments

Comments
 (0)