Skip to content
Merged
9 changes: 9 additions & 0 deletions src/api/api.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import axios from 'axios';

export const apiClient = axios.create({
baseURL: import.meta.env.VITE_API_BASE_URL || 'https://backendbase.site',
headers: {
'Content-Type': 'application/json',
},
Comment on lines +5 to +7
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

전역 Content-Type 헤더를 제거하세요.

Content-Type: multipart/form-data를 전역 헤더로 설정하면 두 가지 문제가 발생합니다:

  1. 파일 업로드 실패: multipart/form-data는 boundary 파라미터가 필요한데, 수동으로 헤더를 설정하면 boundary가 포함되지 않아 업로드가 실패합니다.
  2. 다른 요청에 영향: 이 클라이언트를 사용하는 모든 요청(JSON 등)에 잘못된 Content-Type이 적용됩니다.

Axios는 FormData 객체를 전달할 때 자동으로 올바른 헤더와 boundary를 설정하므로, 이 헤더 설정을 완전히 제거해야 합니다.

다음 diff를 적용하세요:

 export const apiClient = axios.create({
   baseURL: import.meta.env.VITE_API_BASE_URL || 'https://backendbase.site',
-  headers: {
-    'Content-Type': 'multipart/form-data',
-  },
   withCredentials: false,
 });
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
headers: {
'Content-Type': 'multipart/form-data',
},
export const apiClient = axios.create({
baseURL: import.meta.env.VITE_API_BASE_URL || 'https://backendbase.site',
withCredentials: false,
});
🤖 Prompt for AI Agents
In src/api/api.ts around lines 5 to 7, remove the global headers entry that sets
'Content-Type': 'multipart/form-data' because setting it globally prevents Axios
from auto-generating the required boundary for FormData uploads and incorrectly
forces that Content-Type on all requests; delete that header configuration so
Axios can set Content-Type per-request when a FormData body is used.

withCredentials: false,
});
133 changes: 133 additions & 0 deletions src/components/chat/FileSendButton.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
import { useRef, useState } from 'react';
import { apiClient } from '../../api/api';
import axios from 'axios';

interface UploadResponse {
filename: string;
url: string;
}

const FileSendButton = () => {
const [uploading, setUploading] = useState(false);
const [uploadStatus, setUploadStatus] = useState<string | null>(null);
const [uploadedFile, setUploadedFile] = useState<UploadResponse | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);

const handleFileSelect = async (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (!file) return;

if (!file.name.toLowerCase().endsWith('.csv')) {
setUploadStatus('CSV 파일만 업로드 가능합니다.');
return;
}
Comment on lines +23 to +26
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

서버 측 파일 타입 검증을 추가하세요.

클라이언트 측 CSV 확장자 검증만으로는 충분하지 않습니다. 사용자가 개발자 도구를 통해 우회할 수 있으므로 백엔드에서도 파일 타입과 내용을 검증해야 합니다.

Comment on lines +19 to +26
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

파일 크기 검증을 추가하세요.

현재 CSV 확장자만 검증하고 있으나, 파일 크기 제한이 없어 매우 큰 파일을 업로드 시도할 수 있습니다. 이는 다음과 같은 문제를 발생시킬 수 있습니다:

  • 긴 업로드 시간 후 서버에서 실패하는 나쁜 사용자 경험
  • 클라이언트 메모리 및 네트워크 리소스 소비
  • 서버 부하 증가

다음과 같이 파일 크기 검증을 추가하세요:

     if (!file.name.toLowerCase().endsWith('.csv')) {
       setUploadStatus('CSV 파일만 업로드 가능합니다.');
       return;
     }
+
+    const maxSizeInMB = 10;
+    const maxSizeInBytes = maxSizeInMB * 1024 * 1024;
+    if (file.size > maxSizeInBytes) {
+      setUploadStatus(`파일 크기는 ${maxSizeInMB}MB를 초과할 수 없습니다.`);
+      return;
+    }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const handleFileSelect = async (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (!file) return;
if (!file.name.toLowerCase().endsWith('.csv')) {
setUploadStatus('CSV 파일만 업로드 가능합니다.');
return;
}
const handleFileSelect = async (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (!file) return;
if (!file.name.toLowerCase().endsWith('.csv')) {
setUploadStatus('CSV 파일만 업로드 가능합니다.');
return;
}
const maxSizeInMB = 10;
const maxSizeInBytes = maxSizeInMB * 1024 * 1024;
if (file.size > maxSizeInBytes) {
setUploadStatus(`파일 크기는 ${maxSizeInMB}MB를 초과할 수 없습니다.`);
return;
}
🤖 Prompt for AI Agents
In src/components/chat/FileSendButton.tsx around lines 16 to 23, add a file size
validation after you verify the .csv extension: define or import a
MAX_FILE_SIZE_BYTES (e.g. 5 * 1024 * 1024 for 5MB) and check file.size against
it; if the file exceeds the limit, call setUploadStatus with a clear error
message (e.g. "파일 크기가 XMB를 초과했습니다.") and return early to prevent further
processing/upload; ensure the validation happens before any memory-heavy
operations and make the size limit configurable via a constant or prop.


setUploading(true);
setUploadStatus(null);
setUploadedFile(null);

try {
const formData = new FormData();
formData.append('file', file);

console.log('Uploading file:', file.name);

const response = await apiClient.post<UploadResponse>('/chat/upload', formData, {
headers: {
'Content-Type': 'multipart/form-data',
},
});

console.log('=== 응답 정보 ===');
console.log('Status:', response.status);
console.log('Data:', response.data);
console.log('Data type:', typeof response.data);
console.log('Is null?', response.data === null);
console.log('Is undefined?', response.data === undefined);
console.log('Keys:', response.data ? Object.keys(response.data) : 'no data');
console.log('Stringified:', JSON.stringify(response.data));

// 응답이 있으면 성공 처리
if (response.status === 200) {
const data = response.data;

// 데이터가 있으면 저장
if (data && typeof data === 'object') {
setUploadedFile({
filename: data.filename || file.name,
url: data.url || '',
});
setUploadStatus(`업로드 성공! (${data.filename || file.name})`);
} else {
// 데이터가 없어도 200이면 성공으로 간주
setUploadStatus('업로드 성공!');
}

// 파일 입력 초기화
if (fileInputRef.current) {
fileInputRef.current.value = '';
}
}
} catch (error) {
console.error('Upload error:', error);

if (axios.isAxiosError(error)) {
console.error('Axios error response:', error.response?.data);
console.error('Axios error status:', error.response?.status);
const message =
error.response?.data?.message || error.response?.statusText || error.message;
const status = error.response?.status || '';
setUploadStatus(`업로드 실패${status ? ` (${status})` : ''}: ${message}`);
} else {
setUploadStatus(error instanceof Error ? error.message : '업로드 실패');
}
} finally {
setUploading(false);
}
};

const handleButtonClick = () => {
fileInputRef.current?.click();
};

return (
<div className="flex flex-col gap-2">
<input
ref={fileInputRef}
type="file"
accept=".csv"
onChange={handleFileSelect}
className="hidden"
disabled={uploading}
/>
<button
onClick={handleButtonClick}
disabled={uploading}
className="rounded-md bg-blue-500 p-2 text-white transition-colors hover:bg-blue-600 disabled:cursor-not-allowed disabled:bg-gray-400"
>
{uploading ? '업로드 중...' : '📎 CSV 파일 업로드'}
</button>
{uploadStatus && (
<div className="flex flex-col gap-1">
<p
className={`text-sm ${uploadStatus.includes('성공') ? 'text-green-600' : 'text-red-600'}`}
>
{uploadStatus}
</p>
{uploadedFile && (
<a
href={uploadedFile.url}
target="_blank"
rel="noopener noreferrer"
className="text-xs text-blue-500 underline hover:text-blue-700"
>
파일 보기
</a>
)}
</div>
)}
</div>
);
};

export default FileSendButton;
174 changes: 115 additions & 59 deletions src/pages/chat/ChatPageTest.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,20 @@
import { useEffect, useRef, useState } from 'react';
import Navigation from '../../components/Navigation';
import FileSendButton from '../../components/chat/FileSendButton';

interface Message {
id: string;
role: 'user' | 'assistant';
content: string;
}

export default function ChatPageTest() {
const [output, setOutput] = useState('');
const [messages, setMessages] = useState<Message[]>([]);
const [input, setInput] = useState('');
const [sessionId, setSessionId] = useState<string | null>(null);
const socketRef = useRef<WebSocket | null>(null);
const outputRef = useRef<HTMLTextAreaElement>(null);
const messagesEndRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLTextAreaElement>(null);

const mountedRef = useRef(false);

Expand All @@ -18,11 +27,9 @@

socket.onopen = () => {
console.log('Connected');
setOutput((prev) => prev + 'Connected\n');
};

socket.onmessage = (event) => {
// bedrock stream done 메시지는 콘솔에만 표시
if (event.data.includes('[bedrock stream done]')) {
console.log('Stream done:', event.data);
return;
Expand All @@ -32,101 +39,150 @@
const json = JSON.parse(event.data);
if (json.type === 'session') {
setSessionId(json.session_id);
setOutput((prev) => prev + `[세션 ID: ${json.session_id}]\n`);
console.log(`${sessionId}`);
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

잘못된 변수 참조를 수정하세요.

sessionId 상태 변수는 이전 라인(41)에서 방금 설정되었지만, 이 로그에서 사용되는 값은 아직 업데이트되지 않은 이전 상태입니다. React의 상태 업데이트는 비동기이므로 json.session_id를 직접 사용해야 합니다.

다음과 같이 수정하세요:

         if (json.type === 'session') {
           setSessionId(json.session_id);
-          console.log(`${sessionId}`);
+          console.log(`Session ID: ${json.session_id}`);
           return;
         }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
console.log(`${sessionId}`);
if (json.type === 'session') {
setSessionId(json.session_id);
console.log(`Session ID: ${json.session_id}`);
return;
}
🤖 Prompt for AI Agents
In src/pages/chat/ChatPageTest.tsx around line 42, the console.log uses the
stale state variable sessionId which hasn't updated yet; replace the log to use
the freshly received value json.session_id (e.g., log json.session_id directly)
so you log the correct session id instead of the previous React state value.

return;
}
} catch {
/* not JSON */
}
setOutput((prev) => prev + event.data + '\n');

// 어시스턴트 메시지 처리
setMessages((prev) => {
const lastMessage = prev[prev.length - 1];

// 마지막 메시지가 어시스턴트이고 내용이 비어있으면 업데이트
if (lastMessage && lastMessage.role === 'assistant' && lastMessage.content === '') {
const updated = [...prev];
updated[updated.length - 1] = {
...lastMessage,
content: lastMessage.content + event.data,
};
return updated;
}

// 마지막 메시지가 어시스턴트이면 이어붙이기
if (lastMessage && lastMessage.role === 'assistant') {
const updated = [...prev];
updated[updated.length - 1] = {
...lastMessage,
content: lastMessage.content + event.data,
};
return updated;
}

// 새로운 어시스턴트 메시지 생성
return [
...prev,
{
id: Date.now().toString(),
role: 'assistant',
content: event.data,
},
];
});
};

socket.onerror = (error) => {
console.error('WebSocket Error:', error);
setOutput((prev) => prev + 'Error occurred\n');
};

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

return () => {
socket.close();
};
}, []);

Check warning on line 96 in src/pages/chat/ChatPageTest.tsx

View workflow job for this annotation

GitHub Actions / checks

React Hook useEffect has a missing dependency: 'sessionId'. Either include it or remove the dependency array

// 자동 스크롤
useEffect(() => {
if (outputRef.current) {
outputRef.current.scrollTop = outputRef.current.scrollHeight;
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [messages]);

// 입력창 자동 높이 조절
useEffect(() => {
if (inputRef.current) {
inputRef.current.style.height = 'auto';
inputRef.current.style.height = inputRef.current.scrollHeight + 'px';
}
}, [output]);
}, [input]);

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

if (socketRef.current.readyState === WebSocket.OPEN) {
// 사용자 메시지 추가
setMessages((prev) => [
...prev,
{
id: Date.now().toString(),
role: 'user',
content: text,
},
]);

// WebSocket으로 전송
socketRef.current.send(JSON.stringify({ role: 'user', content: text }));
setInput('');
} else {
setOutput((prev) => prev + 'WebSocket이 연결되지 않음\n');
console.error('WebSocket이 연결되지 않음');
}
};

const handleKeyPress = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter') {
const handleKeyPress = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
sendMessage();
}
};

return (
<div style={{ padding: '20px', maxWidth: '600px', margin: '0 auto' }}>
<h3>Chat Test</h3>
{sessionId && <p style={{ color: '#666' }}>세션 ID: {sessionId}</p>}
<textarea
ref={outputRef}
value={output}
readOnly
rows={15}
style={{
width: '100%',
marginBottom: '10px',
padding: '10px',
border: '1px solid #ccc',
borderRadius: '4px',
fontFamily: 'monospace',
}}
/>
<br />
<div style={{ display: 'flex', gap: '10px' }}>
<input
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyPress={handleKeyPress}
placeholder="메시지를 입력하세요"
style={{
flex: 1,
padding: '8px',
border: '1px solid #ccc',
borderRadius: '4px',
}}
/>
<button
onClick={sendMessage}
style={{
padding: '8px 20px',
background: '#007bff',
color: 'white',
border: 'none',
borderRadius: '4px',
cursor: 'pointer',
}}
>
보내기
</button>
<>
<Navigation />
<div className="flex h-full flex-col p-5">
{/* 메시지 목록 */}
<div className="mb-4 flex-1 overflow-y-auto">
{messages.map((message) => (
<div
key={message.id}
className={`mb-4 flex ${message.role === 'user' ? 'justify-end' : 'justify-start'}`}
>
<div
className={`max-w-[70%] rounded-2xl px-4 py-3 ${
message.role === 'user' ? 'bg-[#0D2D84] text-white' : 'bg-gray-200 text-gray-800'
}`}
>
<p className="break-words whitespace-pre-wrap">{message.content}</p>
</div>
</div>
))}
<div ref={messagesEndRef} />
</div>

{/* 파일 업로드 버튼 */}
<FileSendButton />

{/* 입력창 */}
<div className="mt-2 flex gap-2">
<textarea
ref={inputRef}
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={handleKeyPress}
placeholder="메세지를 입력하세요"
rows={1}
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"
/>
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

입력창 높이 제한을 검토하세요.

max-h-20(5rem)과 overflow-hidden 설정으로 인해 사용자가 긴 메시지를 입력할 때 텍스트가 잘려 보이지 않게 됩니다. overflow-hidden 대신 overflow-y-auto를 사용하여 스크롤을 허용하거나, 최대 높이를 늘리는 것을 고려하세요.

다음과 같이 수정하는 것을 고려하세요:

           <textarea
             ref={inputRef}
             value={input}
             onChange={(e) => setInput(e.target.value)}
             onKeyDown={handleKeyPress}
             placeholder="메세지를 입력하세요"
             rows={1}
-            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"
+            className="max-h-32 flex-1 resize-none overflow-y-auto rounded-xl border px-3 py-4 text-xl outline-none focus:border-blue-500"
           />
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
<textarea
ref={inputRef}
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={handleKeyPress}
placeholder="메세지를 입력하세요"
rows={1}
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"
/>
<textarea
ref={inputRef}
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={handleKeyPress}
placeholder="메세지를 입력하세요"
rows={1}
className="max-h-32 flex-1 resize-none overflow-y-auto rounded-xl border px-3 py-4 text-xl outline-none focus:border-blue-500"
/>
🤖 Prompt for AI Agents
In src/pages/chat/ChatPageTest.tsx around lines 169 to 177, the textarea uses
max-h-20 and overflow-hidden which causes long messages to be visually clipped;
change the styling to allow vertical scrolling or larger max height — replace
overflow-hidden with overflow-y-auto (or increase max-h value) so users can
scroll through multi-line input, and ensure rows/minHeight remain appropriate
for UX.

<button
onClick={sendMessage}
className="rounded-xl bg-[#0D2D84] px-6 text-lg text-white hover:bg-[#0a2366]"
>
</button>
</div>
</div>
</div>
</>
);
}
9 changes: 9 additions & 0 deletions vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,4 +5,13 @@ import tailwindcss from '@tailwindcss/vite';
// https://vite.dev/config/
export default defineConfig({
plugins: [react(), tailwindcss()],
server: {
proxy: {
'/chat': {
target: 'https://backendbase.site',
changeOrigin: true,
secure: false,
},
},
},
});