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': 'multipart/form-data',
},
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,
});
96 changes: 96 additions & 0 deletions src/components/chat/FileSendButton.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
import { useRef, useState } from 'react';
import { apiClient } from '../../api/api';
import axios from 'axios';

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

interface FileSendButtonProps {
onUploadSuccess?: () => void;
}

const FileSendButton = ({ onUploadSuccess }: FileSendButtonProps) => {
const [uploading, setUploading] = useState(false);
const [uploadStatus, setUploadStatus] = useState<string | 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);

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('Upload success:', response.data);

// 업로드 성공 콜백 호출
onUploadSuccess?.();

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

if (axios.isAxiosError(error)) {
const message =
error.response?.data?.message || error.response?.statusText || error.message;
const status = error.response?.status || '';
setUploadStatus(`업로드 실패${status ? ` (${status})` : ''}: ${message}`);
} else if (error instanceof Error) {
setUploadStatus(`에러: ${error.message}`);
} else {
setUploadStatus('알 수 없는 에러가 발생했습니다.');
}
} 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="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"
>
{uploading ? '업로드 중...' : '📎파일 업로드'}
</button>
{uploadStatus && <p className="text-sm text-red-600">dd{uploadStatus}</p>}
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

오타를 수정하세요.

에러 메시지 표시 부분에 "dd" 텍스트가 잘못 포함되어 있습니다.

다음과 같이 수정하세요:

-      {uploadStatus && <p className="text-sm text-red-600">dd{uploadStatus}</p>}
+      {uploadStatus && <p className="text-sm text-red-600">{uploadStatus}</p>}
📝 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
{uploadStatus && <p className="text-sm text-red-600">dd{uploadStatus}</p>}
{uploadStatus && <p className="text-sm text-red-600">{uploadStatus}</p>}
🤖 Prompt for AI Agents
In src/components/chat/FileSendButton.tsx around line 91, there is a stray "dd"
text inside the error message render; remove the "dd" so the paragraph only
renders the uploadStatus (i.e., change the JSX to render {uploadStatus} without
the "dd"), keeping the conditional check and styling intact.

</div>
);
};

export default FileSendButton;
2 changes: 1 addition & 1 deletion src/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ const router = createBrowserRouter([
},

{
path: 'test',
path: 'chat',
element: <ChatPageTest />,
},
],
Expand Down
5 changes: 0 additions & 5 deletions src/pages/chat/Chat.tsx

This file was deleted.

197 changes: 139 additions & 58 deletions src/pages/chat/ChatPageTest.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,27 @@
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[]>([
{
id: 'initial',
role: 'assistant',
content: '안녕하세요! Snowgent입니다❄️ \n재고 데이터 파일을 업로드 해주세요',
},
]);
const [input, setInput] = useState('');
const [sessionId, setSessionId] = useState<string | null>(null);
const [isFileUploaded, setIsFileUploaded] = useState(false);
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 +34,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,100 +46,167 @@
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.

console.log(`Session ID: ${json.session_id}`);
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 104 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 handleUploadSuccess = () => {
setIsFileUploaded(true);
setMessages((prev) => [
...prev,
{
id: Date.now().toString(),
role: 'assistant',
content: '업로드 완료되었습니다. 재고 관리 채팅을 시작하세요',
},
]);
};

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>
<div className="flex h-screen flex-col">
<Navigation />
<div className="flex flex-1 flex-col overflow-hidden p-5">
{/* 메시지 목록 */}
{/* 파일 업로드 버튼 - 업로드 전에만 표시 */}
{!isFileUploaded && (
<div className="shrink-0">
<FileSendButton onUploadSuccess={handleUploadSuccess} />
</div>
)}
<div className="flex-1 overflow-y-auto pb-4">
{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>

{/* 입력창 - 업로드 후에만 표시 */}
{isFileUploaded && (
<div className="flex shrink-0 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"
/>
<button
onClick={sendMessage}
className="rounded-xl bg-[#0D2D84] px-6 text-lg text-white hover:bg-[#0a2366]"
>
</button>
</div>
)}
</div>
</div>
);
Expand Down
6 changes: 5 additions & 1 deletion vercel.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
{
"rewrites": [
{ "source": "/(.*)", "destination": "/index.html" }
{ "source": "/(.*)", "destination": "/index.html" },
{
"source": "/chat/:path*",
"destination": "https://backendbase.site/chat/:path*"
}
]
Comment on lines 2 to 8
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

라우팅 순서를 수정하세요.

Vercel은 rewrites를 배열 순서대로 처리합니다. 현재 Line 3의 catch-all 패턴 /(.*)이 먼저 매칭되므로 /chat 경로를 포함한 모든 요청이 /index.html로 리라이트됩니다. 따라서 Lines 4-7의 /chat 백엔드 프록시 규칙이 절대 실행되지 않습니다.

다음과 같이 순서를 변경하세요:

 {
   "rewrites": [
-    { "source": "/(.*)", "destination": "/index.html" },
     {
       "source": "/chat/:path*",
       "destination": "https://backendbase.site/chat/:path*"
-    }
+    },
+    { "source": "/(.*)", "destination": "/index.html" }
   ]
 }
📝 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
"rewrites": [
{ "source": "/(.*)", "destination": "/index.html" }
{ "source": "/(.*)", "destination": "/index.html" },
{
"source": "/chat/:path*",
"destination": "https://backendbase.site/chat/:path*"
}
]
"rewrites": [
{
"source": "/chat/:path*",
"destination": "https://backendbase.site/chat/:path*"
},
{ "source": "/(.*)", "destination": "/index.html" }
]
🤖 Prompt for AI Agents
vercel.json around lines 2 to 8: the catch-all rewrite "{ "source": "/(.*)",
"destination": "/index.html" }" is listed before the specific "/chat/:path*"
proxy, so it will match every request (including /chat) and prevent the /chat
rule from ever running; fix by moving the "/chat/:path*" rewrite entry above the
catch-all entry so the specific proxy is evaluated first, preserving the
intended backend proxy for /chat paths.

}
Loading