Skip to content

Commit 7695a7a

Browse files
authored
Merge pull request #7 from Snowgent/feature/#4-chatting
[FEATURE] 채팅 주고받기 UI 및 파일 전송 버튼
2 parents 4e985a9 + 25297e8 commit 7695a7a

File tree

7 files changed

+259
-65
lines changed

7 files changed

+259
-65
lines changed

src/api/api.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
import axios from 'axios';
2+
3+
export const apiClient = axios.create({
4+
baseURL: import.meta.env.VITE_API_BASE_URL || 'https://backendbase.site',
5+
headers: {
6+
'Content-Type': 'multipart/form-data',
7+
},
8+
withCredentials: false,
9+
});
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
import { useRef, useState } from 'react';
2+
import { apiClient } from '../../api/api';
3+
import axios from 'axios';
4+
5+
interface UploadResponse {
6+
filename: string;
7+
url: string;
8+
}
9+
10+
interface FileSendButtonProps {
11+
onUploadSuccess?: () => void;
12+
}
13+
14+
const FileSendButton = ({ onUploadSuccess }: FileSendButtonProps) => {
15+
const [uploading, setUploading] = useState(false);
16+
const [uploadStatus, setUploadStatus] = useState<string | null>(null);
17+
const fileInputRef = useRef<HTMLInputElement>(null);
18+
19+
const handleFileSelect = async (event: React.ChangeEvent<HTMLInputElement>) => {
20+
const file = event.target.files?.[0];
21+
if (!file) return;
22+
23+
if (!file.name.toLowerCase().endsWith('.csv')) {
24+
setUploadStatus('CSV 파일만 업로드 가능합니다.');
25+
return;
26+
}
27+
28+
setUploading(true);
29+
setUploadStatus(null);
30+
31+
try {
32+
const formData = new FormData();
33+
formData.append('file', file);
34+
35+
console.log('Uploading file:', file.name);
36+
37+
const response = await apiClient.post<UploadResponse>('/chat/upload', formData, {
38+
headers: {
39+
'Content-Type': 'multipart/form-data',
40+
},
41+
});
42+
43+
console.log('Upload success:', response.data);
44+
45+
// 업로드 성공 콜백 호출
46+
onUploadSuccess?.();
47+
48+
// 파일 입력 초기화
49+
if (fileInputRef.current) {
50+
fileInputRef.current.value = '';
51+
}
52+
} catch (error) {
53+
console.error('Upload error:', error);
54+
55+
if (axios.isAxiosError(error)) {
56+
const message =
57+
error.response?.data?.message || error.response?.statusText || error.message;
58+
const status = error.response?.status || '';
59+
setUploadStatus(`업로드 실패${status ? ` (${status})` : ''}: ${message}`);
60+
} else if (error instanceof Error) {
61+
setUploadStatus(`에러: ${error.message}`);
62+
} else {
63+
setUploadStatus('알 수 없는 에러가 발생했습니다.');
64+
}
65+
} finally {
66+
setUploading(false);
67+
}
68+
};
69+
70+
const handleButtonClick = () => {
71+
fileInputRef.current?.click();
72+
};
73+
74+
return (
75+
<div className="flex flex-col gap-2">
76+
<input
77+
ref={fileInputRef}
78+
type="file"
79+
accept=".csv"
80+
onChange={handleFileSelect}
81+
className="hidden"
82+
disabled={uploading}
83+
/>
84+
<button
85+
onClick={handleButtonClick}
86+
disabled={uploading}
87+
className="my-2 w-fit rounded-md bg-blue-500 p-4 text-white transition-colors hover:bg-blue-600 disabled:cursor-not-allowed disabled:bg-gray-400"
88+
>
89+
{uploading ? '업로드 중...' : '📎파일 업로드'}
90+
</button>
91+
{uploadStatus && <p className="text-sm text-red-600">dd{uploadStatus}</p>}
92+
</div>
93+
);
94+
};
95+
96+
export default FileSendButton;

src/index.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ const router = createBrowserRouter([
1919
},
2020

2121
{
22-
path: 'test',
22+
path: 'chat',
2323
element: <ChatPageTest />,
2424
},
2525
],

src/pages/chat/Chat.tsx

Lines changed: 0 additions & 5 deletions
This file was deleted.

src/pages/chat/ChatPageTest.tsx

Lines changed: 139 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,27 @@
11
import { useEffect, useRef, useState } from 'react';
2+
import Navigation from '../../components/Navigation';
3+
import FileSendButton from '../../components/chat/FileSendButton';
4+
5+
interface Message {
6+
id: string;
7+
role: 'user' | 'assistant';
8+
content: string;
9+
}
210

311
export default function ChatPageTest() {
4-
const [output, setOutput] = useState('');
12+
const [messages, setMessages] = useState<Message[]>([
13+
{
14+
id: 'initial',
15+
role: 'assistant',
16+
content: '안녕하세요! Snowgent입니다❄️ \n재고 데이터 파일을 업로드 해주세요',
17+
},
18+
]);
519
const [input, setInput] = useState('');
620
const [sessionId, setSessionId] = useState<string | null>(null);
21+
const [isFileUploaded, setIsFileUploaded] = useState(false);
722
const socketRef = useRef<WebSocket | null>(null);
8-
const outputRef = useRef<HTMLTextAreaElement>(null);
23+
const messagesEndRef = useRef<HTMLDivElement>(null);
24+
const inputRef = useRef<HTMLTextAreaElement>(null);
925

1026
const mountedRef = useRef(false);
1127

@@ -18,11 +34,9 @@ export default function ChatPageTest() {
1834

1935
socket.onopen = () => {
2036
console.log('Connected');
21-
setOutput((prev) => prev + 'Connected\n');
2237
};
2338

2439
socket.onmessage = (event) => {
25-
// bedrock stream done 메시지는 콘솔에만 표시
2640
if (event.data.includes('[bedrock stream done]')) {
2741
console.log('Stream done:', event.data);
2842
return;
@@ -32,23 +46,56 @@ export default function ChatPageTest() {
3246
const json = JSON.parse(event.data);
3347
if (json.type === 'session') {
3448
setSessionId(json.session_id);
35-
setOutput((prev) => prev + `[세션 ID: ${json.session_id}]\n`);
49+
console.log(`${sessionId}`);
50+
console.log(`Session ID: ${json.session_id}`);
3651
return;
3752
}
3853
} catch {
3954
/* not JSON */
4055
}
41-
setOutput((prev) => prev + event.data + '\n');
56+
57+
// 어시스턴트 메시지 처리
58+
setMessages((prev) => {
59+
const lastMessage = prev[prev.length - 1];
60+
61+
// 마지막 메시지가 어시스턴트이고 내용이 비어있으면 업데이트
62+
if (lastMessage && lastMessage.role === 'assistant' && lastMessage.content === '') {
63+
const updated = [...prev];
64+
updated[updated.length - 1] = {
65+
...lastMessage,
66+
content: lastMessage.content + event.data,
67+
};
68+
return updated;
69+
}
70+
71+
// 마지막 메시지가 어시스턴트이면 이어붙이기
72+
if (lastMessage && lastMessage.role === 'assistant') {
73+
const updated = [...prev];
74+
updated[updated.length - 1] = {
75+
...lastMessage,
76+
content: lastMessage.content + event.data,
77+
};
78+
return updated;
79+
}
80+
81+
// 새로운 어시스턴트 메시지 생성
82+
return [
83+
...prev,
84+
{
85+
id: Date.now().toString(),
86+
role: 'assistant',
87+
content: event.data,
88+
},
89+
];
90+
});
4291
};
4392

4493
socket.onerror = (error) => {
4594
console.error('WebSocket Error:', error);
46-
setOutput((prev) => prev + 'Error occurred\n');
4795
};
4896

4997
socket.onclose = (event) => {
5098
console.log('Disconnected:', event.code, event.reason);
51-
setOutput((prev) => prev + `Disconnected: ${event.code}\n`);
5299
};
53100

54101
return () => {
@@ -58,74 +105,108 @@ export default function ChatPageTest() {
58105

59106
// 자동 스크롤
60107
useEffect(() => {
61-
if (outputRef.current) {
62-
outputRef.current.scrollTop = outputRef.current.scrollHeight;
108+
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
109+
}, [messages]);
110+
111+
// 입력창 자동 높이 조절
112+
useEffect(() => {
113+
if (inputRef.current) {
114+
inputRef.current.style.height = 'auto';
115+
inputRef.current.style.height = inputRef.current.scrollHeight + 'px';
63116
}
64-
}, [output]);
117+
}, [input]);
118+
119+
const handleUploadSuccess = () => {
120+
setIsFileUploaded(true);
121+
setMessages((prev) => [
122+
...prev,
123+
{
124+
id: Date.now().toString(),
125+
role: 'assistant',
126+
content: '업로드 완료되었습니다. 재고 관리 채팅을 시작하세요',
127+
},
128+
]);
129+
};
65130

66131
const sendMessage = () => {
67132
const text = input.trim();
68133
if (!text || !socketRef.current) return;
69134

70135
if (socketRef.current.readyState === WebSocket.OPEN) {
136+
// 사용자 메시지 추가
137+
setMessages((prev) => [
138+
...prev,
139+
{
140+
id: Date.now().toString(),
141+
role: 'user',
142+
content: text,
143+
},
144+
]);
145+
146+
// WebSocket으로 전송
71147
socketRef.current.send(JSON.stringify({ role: 'user', content: text }));
72148
setInput('');
73149
} else {
74-
setOutput((prev) => prev + 'WebSocket이 연결되지 않음\n');
150+
console.error('WebSocket이 연결되지 않음');
75151
}
76152
};
77153

78-
const handleKeyPress = (e: React.KeyboardEvent<HTMLInputElement>) => {
79-
if (e.key === 'Enter') {
154+
const handleKeyPress = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
155+
if (e.key === 'Enter' && !e.shiftKey) {
156+
e.preventDefault();
80157
sendMessage();
81158
}
82159
};
83160

84161
return (
85-
<div style={{ padding: '20px', maxWidth: '600px', margin: '0 auto' }}>
86-
<h3>Chat Test</h3>
87-
{sessionId && <p style={{ color: '#666' }}>세션 ID: {sessionId}</p>}
88-
<textarea
89-
ref={outputRef}
90-
value={output}
91-
readOnly
92-
rows={15}
93-
style={{
94-
width: '100%',
95-
marginBottom: '10px',
96-
padding: '10px',
97-
border: '1px solid #ccc',
98-
borderRadius: '4px',
99-
fontFamily: 'monospace',
100-
}}
101-
/>
102-
<br />
103-
<div style={{ display: 'flex', gap: '10px' }}>
104-
<input
105-
value={input}
106-
onChange={(e) => setInput(e.target.value)}
107-
onKeyPress={handleKeyPress}
108-
placeholder="메시지를 입력하세요"
109-
style={{
110-
flex: 1,
111-
padding: '8px',
112-
border: '1px solid #ccc',
113-
borderRadius: '4px',
114-
}}
115-
/>
116-
<button
117-
onClick={sendMessage}
118-
style={{
119-
padding: '8px 20px',
120-
background: '#007bff',
121-
color: 'white',
122-
border: 'none',
123-
borderRadius: '4px',
124-
cursor: 'pointer',
125-
}}
126-
>
127-
보내기
128-
</button>
162+
<div className="flex h-screen flex-col">
163+
<Navigation />
164+
<div className="flex flex-1 flex-col overflow-hidden p-5">
165+
{/* 메시지 목록 */}
166+
{/* 파일 업로드 버튼 - 업로드 전에만 표시 */}
167+
{!isFileUploaded && (
168+
<div className="shrink-0">
169+
<FileSendButton onUploadSuccess={handleUploadSuccess} />
170+
</div>
171+
)}
172+
<div className="flex-1 overflow-y-auto pb-4">
173+
{messages.map((message) => (
174+
<div
175+
key={message.id}
176+
className={`mb-4 flex ${message.role === 'user' ? 'justify-end' : 'justify-start'}`}
177+
>
178+
<div
179+
className={`max-w-[70%] rounded-2xl px-4 py-3 ${
180+
message.role === 'user' ? 'bg-[#0D2D84] text-white' : 'bg-gray-200 text-gray-800'
181+
}`}
182+
>
183+
<p className="break-words whitespace-pre-wrap">{message.content}</p>
184+
</div>
185+
</div>
186+
))}
187+
<div ref={messagesEndRef} />
188+
</div>
189+
190+
{/* 입력창 - 업로드 후에만 표시 */}
191+
{isFileUploaded && (
192+
<div className="flex shrink-0 gap-2">
193+
<textarea
194+
ref={inputRef}
195+
value={input}
196+
onChange={(e) => setInput(e.target.value)}
197+
onKeyDown={handleKeyPress}
198+
placeholder="메시지를 입력하세요"
199+
rows={1}
200+
className="max-h-20 flex-1 resize-none overflow-hidden rounded-xl border px-3 py-4 text-xl outline-none focus:border-blue-500"
201+
/>
202+
<button
203+
onClick={sendMessage}
204+
className="rounded-xl bg-[#0D2D84] px-6 text-lg text-white hover:bg-[#0a2366]"
205+
>
206+
207+
</button>
208+
</div>
209+
)}
129210
</div>
130211
</div>
131212
);

vercel.json

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
11
{
22
"rewrites": [
3-
{ "source": "/(.*)", "destination": "/index.html" }
3+
{ "source": "/(.*)", "destination": "/index.html" },
4+
{
5+
"source": "/chat/:path*",
6+
"destination": "https://backendbase.site/chat/:path*"
7+
}
48
]
59
}

0 commit comments

Comments
 (0)