47. 멀티모달 챗봇 웹앱 프로젝트
Video
준비 중
Note
소스 코드
app.py
from flask import Flask, request, Response, render_template, send_file
import yaml
from dotenv import load_dotenv
from openai import OpenAI
import base64
import json
import tempfile
import threading
from pathlib import Path
with open("config.yaml") as f:
config = yaml.safe_load(f)
load_dotenv()
client = OpenAI()
app = Flask(__name__)
# 임시 파일 저장 디렉토리
TEMP_DIR = Path(tempfile.gettempdir()) / "images"
TEMP_DIR.mkdir(exist_ok=True)
@app.route("/")
def index():
title = "OpenAI API Agent School"
return render_template('index.html', title=title)
@app.route("/api/chat", methods=["POST"])
def chat_api():
data = request.get_json()
input_message = data.get("input_message", [])
previous_response_id = data.get("previous_response_id")
print(f"Input message: {input_message}")
print(f"Previous response ID: {previous_response_id}")
def generate():
nonlocal previous_response_id
try:
print("Starting chat API call...")
# OpenAI API 호출
response = client.responses.create(
input=input_message,
previous_response_id=previous_response_id,
stream=True,
**config
)
print("OpenAI API response created successfully")
max_repeats = 5
for _ in range(max_repeats):
function_call_outputs = []
for event in response:
print(f"Processing event type: {event.type}")
if event.type == "response.output_text.delta":
yield f"data: {json.dumps({'type': 'text_delta', 'delta': event.delta})}\n\n"
elif event.type == "response.completed":
previous_response_id = event.response.id
elif event.type == "response.image_generation_call.partial_image":
image_name = f"{event.item_id}.{event.output_format}"
file_path = TEMP_DIR / image_name
with open(file_path, 'wb') as f:
f.write(base64.b64decode(event.partial_image_b64))
threading.Timer(60.0, lambda: file_path.unlink(missing_ok=True)).start()
yield f"data: {json.dumps({'type': 'image_generated', 'image_url': f"/image/{image_name}", 'is_partial': True})}\n\n"
elif (event.type == "response.output_item.done") and (event.item.type == "function_call"):
try:
import function_call
func = getattr(function_call, event.item.name)
args = json.loads(event.item.arguments)
func_output = str(func(**args))
except Exception as e:
func_output = str(e)
function_call_outputs.append({"type": "function_call_output", "call_id": event.item.call_id, "output": func_output})
# 함수 호출 결과가 있으면 다시 API 호출
if function_call_outputs:
print(f"Making follow-up API call with {len(function_call_outputs)} outputs")
response = client.responses.create(
input=function_call_outputs,
previous_response_id=previous_response_id,
stream=True,
**config
)
else:
print("No function calls, ending stream")
break
yield f"data: {json.dumps({'type': 'done', 'response_id': previous_response_id})}\n\n"
print("Stream completed successfully")
except Exception as e:
import traceback
print(f"Error in chat API: {str(e)}")
print(f"Traceback: {traceback.format_exc()}")
yield f"data: {json.dumps({'type': 'error', 'message': str(e)})}\n\n"
return Response(generate(), mimetype='text/plain')
@app.route('/image/<image_name>')
def serve_image(image_name):
return send_file(
TEMP_DIR / image_name,
mimetype=f'image/{image_name.split(".")[-1]}',
as_attachment=False
)
if __name__ == "__main__":
app.run(host="0.0.0.0")
templates 폴더에 아래와 같이 index.html 파일 작성
templates/index.html
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{ title }}</title>
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html, body {
height: 100%;
font-family: Arial, sans-serif;
background-color: #f5f5f5;
}
body {
display: flex;
justify-content: center;
align-items: stretch;
}
.chat-container {
width: 100%;
max-width: 800px;
height: 100vh;
background: white;
display: flex;
flex-direction: column;
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
}
h1 {
padding: 10px;
margin: 0;
border-bottom: 1px solid #ddd;
background-color: #f8f9fa;
text-align: center;
}
.messages {
flex: 1;
overflow-y: auto;
padding: 20px;
background-color: #fafafa;
}
.message {
margin-bottom: 15px;
padding: 10px;
border-radius: 8px;
}
.user-message {
background-color: #007bff;
color: white;
margin-left: 20%;
}
.assistant-message {
background-color: #e9ecef;
color: #333;
margin-right: 20%;
}
.reasoning {
background-color: #fff3cd;
border-left: 4px solid #ffc107;
padding: 10px;
margin: 10px 0;
font-style: italic;
}
.input-section {
padding: 10px;
border-top: 1px solid #ddd;
background-color: white;
display: flex;
flex-direction: column;
gap: 10px;
position: relative;
}
/* 파일 미리보기 영역 */
.file-preview {
display: flex;
flex-wrap: wrap;
gap: 8px;
padding: 10px;
background-color: #f8f9fa;
border-radius: 8px;
margin-bottom: 10px;
}
.file-preview-item {
position: relative;
display: flex;
align-items: center;
background: white;
border: 1px solid #ddd;
border-radius: 6px;
padding: 8px;
max-width: 200px;
}
.file-preview-item img {
width: 40px;
height: 40px;
object-fit: cover;
border-radius: 4px;
margin-right: 8px;
}
.file-preview-item .file-icon {
width: 40px;
height: 40px;
background-color: #dc3545;
color: white;
display: flex;
align-items: center;
justify-content: center;
border-radius: 4px;
margin-right: 8px;
font-size: 18px;
}
.file-preview-item .file-info {
flex: 1;
min-width: 0;
}
.file-preview-item .file-name {
font-size: 12px;
font-weight: 500;
color: #333;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* 오류 메시지 스타일 */
.error-message {
color: #dc3545;
text-align: center;
padding: 15px;
margin: 10px 0;
background-color: #f8d7da;
border: 1px solid #f5c6cb;
border-radius: 8px;
font-weight: 500;
}
.file-preview-item .file-size {
font-size: 11px;
color: #666;
}
.file-preview-item .remove-btn {
position: absolute;
top: -5px;
right: -5px;
width: 20px;
height: 20px;
background: #dc3545;
color: white;
border: none;
border-radius: 50%;
cursor: pointer;
font-size: 12px;
display: flex;
align-items: center;
justify-content: center;
}
.file-preview-item .remove-btn:hover {
background: #c82333;
}
.text-input-container {
display: flex;
gap: 10px;
align-items: flex-end;
position: relative;
}
/* 첨부 버튼 */
.attach-button {
width: 40px;
height: 40px;
background-color: #6c757d;
color: white;
border: none;
border-radius: 50%;
cursor: pointer;
font-size: 20px;
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 0;
}
.attach-button:hover {
background-color: #5a6268;
}
.attach-button:disabled {
background-color: #adb5bd;
cursor: not-allowed;
opacity: 0.6;
}
.attach-button:disabled:hover {
background-color: #adb5bd;
}
/* 파일 메뉴 */
.file-menu {
position: absolute;
bottom: 50px;
left: 0;
background: white;
border: 1px solid #ddd;
border-radius: 8px;
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
z-index: 1000;
min-width: 120px;
}
.file-menu button {
display: block;
width: 100%;
padding: 10px 15px;
border: none;
background: none;
text-align: left;
cursor: pointer;
font-size: 14px;
border-radius: 0;
}
.file-menu button:first-child {
border-top-left-radius: 8px;
border-top-right-radius: 8px;
}
.file-menu button:last-child {
border-bottom-left-radius: 8px;
border-bottom-right-radius: 8px;
}
.file-menu button:hover {
background-color: #f8f9fa;
}
.file-inputs {
display: flex;
gap: 10px;
margin-bottom: 10px;
}
.file-input {
flex: 1;
}
#messageInput {
flex: 1;
padding: 12px;
border: 1px solid #ddd;
border-radius: 5px;
font-size: 16px;
font-family: Arial, sans-serif;
resize: vertical;
min-height: 20px;
max-height: 150px;
overflow-y: auto;
line-height: 1.4;
}
label {
display: flex;
align-items: center;
gap: 5px;
font-size: 14px;
white-space: nowrap;
margin-bottom: 12px;
}
#sendButton {
padding: 12px 20px;
background-color: #007bff;
color: white;
border: none;
border-radius: 5px;
cursor: pointer;
font-size: 16px;
height: fit-content;
margin-bottom: 0;
}
#sendButton:hover {
background-color: #0056b3;
}
#sendButton:disabled {
background-color: #6c757d;
cursor: not-allowed;
}
.generated-image {
max-width: 100%;
border-radius: 8px;
margin: 10px 0;
}
input[type="file"] {
padding: 8px;
border: 1px solid #ddd;
border-radius: 5px;
background-color: white;
}
/* 사용자 메시지의 파일 표시 스타일 */
.user-files {
margin-top: 8px;
display: flex;
flex-wrap: wrap;
gap: 4px;
}
.file-badge {
background-color: rgba(255, 255, 255, 0.2);
padding: 4px 8px;
border-radius: 12px;
font-size: 12px;
display: inline-block;
margin: 2px;
}
/* Markdown 스타일 */
.message h1, .message h2, .message h3, .message h4, .message h5, .message h6 {
margin: 10px 0 5px 0;
font-weight: bold;
}
.message h1 { font-size: 1.5em; }
.message h2 { font-size: 1.3em; }
.message h3 { font-size: 1.1em; }
.message p {
margin: 8px 0;
line-height: 1.5;
}
.message ul, .message ol {
margin: 10px 0;
padding-left: 20px;
}
.message li {
margin: 5px 0;
line-height: 1.4;
}
.message code {
background-color: #f4f4f4;
padding: 2px 4px;
border-radius: 3px;
font-family: 'Courier New', monospace;
font-size: 0.9em;
}
.message pre {
background-color: #f8f8f8;
border: 1px solid #ddd;
border-radius: 5px;
padding: 10px;
margin: 10px 0;
overflow-x: auto;
white-space: pre-wrap;
}
.message pre code {
background-color: transparent;
padding: 0;
border-radius: 0;
}
.message blockquote {
border-left: 4px solid #ddd;
margin: 10px 0;
padding-left: 15px;
color: #666;
font-style: italic;
}
.message strong {
font-weight: bold;
}
.message em {
font-style: italic;
}
.message table {
border-collapse: collapse;
width: 100%;
margin: 10px 0;
}
.message th, .message td {
border: 1px solid #ddd;
padding: 8px;
text-align: left;
}
.message th {
background-color: #f2f2f2;
font-weight: bold;
}
/* 스피너 스타일 */
.spinner {
display: inline-block;
width: 20px;
height: 20px;
border: 3px solid #f3f3f3;
border-top: 3px solid #007bff;
border-radius: 50%;
animation: spin 1s linear infinite;
margin-right: 10px;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
.thinking-message {
display: flex;
align-items: center;
font-style: italic;
color: #666;
}
/* 저작권 정보 스타일 */
.copyright {
text-align: center;
padding: 10px;
background-color: #f8f9fa;
border-top: 1px solid #ddd;
font-size: 14px;
color: #666;
line-height: 1.4;
}
.copyright a {
color: #007bff;
text-decoration: none;
}
.copyright a:hover {
text-decoration: underline;
}
</style>
</head>
<body>
<div class="chat-container">
<h1>{{ title }}</h1>
<div class="messages" id="messages"></div>
<div class="input-section">
<!-- 업로드된 파일 미리보기 영역 -->
<div id="filePreview" class="file-preview" style="display: none;"></div>
<div class="text-input-container">
<button id="attachButton" class="attach-button" onclick="toggleFileMenu()">
<span>+</span>
</button>
<!-- 파일 업로드 메뉴 (숨김) -->
<div id="fileMenu" class="file-menu" style="display: none;">
<button onclick="triggerImageUpload()">📷 이미지</button>
<button onclick="triggerPdfUpload()">📄 PDF</button>
</div>
<!-- 숨겨진 파일 입력들 -->
<input type="file" id="imageInput" accept=".jfif,.pjp,.jpg,.pjpeg,.jpeg,.gif,.png" multiple style="display: none;" onchange="handleFileUpload(this, 'image')">
<input type="file" id="pdfInput" accept=".pdf" multiple style="display: none;" onchange="handleFileUpload(this, 'pdf')">
<textarea id="messageInput" placeholder="메시지를 입력하세요... (Shift+Enter로 줄바꿈, Ctrl+V로 이미지 붙여넣기)" onkeydown="handleKeyDown(event)" oninput="autoResizeTextarea(this)" onpaste="handlePaste(event)" rows="1"></textarea>
<button id="sendButton" onclick="sendMessage()">전송</button>
</div>
</div>
<!-- 저작권 정보 -->
<div class="copyright">
<strong><a href="https://openai-api-agent.aicastle.school" target="_blank">OpenAI API Agent School</a></strong>
| Copyright © 2025 <a href="https://aicastle.com" target="_blank">AICASTLE</a>)</div>
</div>
<script>
let previousResponseId = null;
let isProcessing = false;
let uploadedFiles = []; // 업로드된 파일들을 저장하는 배열
function addMessage(content, isUser = false, files = []) {
const messagesDiv = document.getElementById('messages');
const messageDiv = document.createElement('div');
messageDiv.className = `message ${isUser ? 'user-message' : 'assistant-message'}`;
let messageContent = '';
// 어시스턴트가 생성한 이미지인 경우 (content가 이미지 URL이고 files가 'image'인 경우)
if (!isUser && files === 'image' && content) {
messageContent = `<img src="${content}" class="generated-image" alt="Generated Image" style="max-width: 100%; height: auto; border-radius: 8px;">`;
} else {
// 텍스트 내용 처리
if (content) {
if (typeof marked !== 'undefined' && !isUser) {
messageContent += marked.parse(content);
} else {
messageContent += content.replace(/\n/g, '<br>');
}
}
// 사용자 메시지의 경우 파일들도 함께 표시
if (isUser && files && Array.isArray(files) && files.length > 0) {
if (content) messageContent += '<br><br>';
messageContent += '<div class="user-files">';
for (const fileObj of files) {
if (fileObj.type === 'image') {
// 이미지 파일인 경우 썸네일 표시
const reader = new FileReader();
reader.onload = function(e) {
const img = messageDiv.querySelector(`[data-file-id="${fileObj.id}"]`);
if (img) {
img.src = e.target.result;
}
};
reader.readAsDataURL(fileObj.file);
messageContent += `<img data-file-id="${fileObj.id}" src="" style="max-width: 100px; max-height: 100px; margin: 2px; border-radius: 4px;" title="${fileObj.name}">`;
} else {
messageContent += `<span class="file-badge">📄 ${fileObj.name}</span>`;
}
}
messageContent += '</div>';
}
}
messageDiv.innerHTML = messageContent;
messagesDiv.appendChild(messageDiv);
messagesDiv.scrollTop = messagesDiv.scrollHeight;
}
function setProcessing(processing) {
isProcessing = processing;
const sendButton = document.getElementById('sendButton');
const messageInput = document.getElementById('messageInput');
const attachButton = document.getElementById('attachButton');
sendButton.disabled = processing;
messageInput.disabled = processing;
attachButton.disabled = processing;
if (processing) {
// 어시스턴트 메시지 박스를 먼저 생성하고 그 안에 스피너 표시
const messagesDiv = document.getElementById('messages');
const assistantDiv = document.createElement('div');
assistantDiv.className = 'message assistant-message';
assistantDiv.id = 'current-assistant-message';
assistantDiv.innerHTML = '<div class="spinner"></div>';
messagesDiv.appendChild(assistantDiv);
messagesDiv.scrollTop = messagesDiv.scrollHeight;
}
}
// 파일 메뉴 토글
function toggleFileMenu() {
const fileMenu = document.getElementById('fileMenu');
fileMenu.style.display = fileMenu.style.display === 'none' ? 'block' : 'none';
}
// 외부 클릭시 파일 메뉴 닫기
document.addEventListener('click', function(event) {
const fileMenu = document.getElementById('fileMenu');
const attachButton = document.getElementById('attachButton');
if (!fileMenu.contains(event.target) && !attachButton.contains(event.target)) {
fileMenu.style.display = 'none';
}
});
// 파일 업로드 트리거
function triggerImageUpload() {
document.getElementById('imageInput').click();
document.getElementById('fileMenu').style.display = 'none';
}
function triggerPdfUpload() {
document.getElementById('pdfInput').click();
document.getElementById('fileMenu').style.display = 'none';
}
// 파일 업로드 처리
function handleFileUpload(input, type) {
const files = Array.from(input.files);
// 지원되는 이미지 포맷 정의
const supportedImageFormats = ['.jfif', '.pjp', '.jpg', '.pjpeg', '.jpeg', '.gif', '.png'];
const supportedPdfFormats = ['.pdf'];
files.forEach(file => {
const fileName = file.name.toLowerCase();
const fileExtension = '.' + fileName.split('.').pop();
// 파일 포맷 검증
if (type === 'image') {
if (!supportedImageFormats.includes(fileExtension)) {
alert(`지원되지 않는 이미지 포맷입니다: ${file.name}\n지원되는 포맷: ${supportedImageFormats.join(', ')}`);
return;
}
} else if (type === 'pdf') {
if (!supportedPdfFormats.includes(fileExtension)) {
alert(`지원되지 않는 파일 포맷입니다: ${file.name}\n지원되는 포맷: ${supportedPdfFormats.join(', ')}`);
return;
}
}
const fileObj = {
id: Date.now() + Math.random(),
file: file,
type: type,
name: file.name,
size: formatFileSize(file.size)
};
uploadedFiles.push(fileObj);
if (type === 'image') {
createImagePreview(fileObj);
} else {
createFilePreview(fileObj);
}
});
updateFilePreviewVisibility();
input.value = ''; // 입력 초기화
}
// 클립보드 이미지 붙여넣기
function handlePaste(event) {
const items = (event.clipboardData || event.originalEvent.clipboardData).items;
for (let item of items) {
if (item.type.indexOf('image') !== -1) {
const file = item.getAsFile();
if (file) {
const fileObj = {
id: Date.now() + Math.random(),
file: file,
type: 'image',
name: `클립보드_이미지_${new Date().getTime()}.png`,
size: formatFileSize(file.size)
};
uploadedFiles.push(fileObj);
createImagePreview(fileObj);
updateFilePreviewVisibility();
}
}
}
}
// 이미지 미리보기 생성
function createImagePreview(fileObj) {
const reader = new FileReader();
reader.onload = function(e) {
const previewItem = createPreviewItem(fileObj, e.target.result);
document.getElementById('filePreview').appendChild(previewItem);
};
reader.readAsDataURL(fileObj.file);
}
// 파일 미리보기 생성 (PDF 등)
function createFilePreview(fileObj) {
const previewItem = createPreviewItem(fileObj, null);
document.getElementById('filePreview').appendChild(previewItem);
}
// 미리보기 아이템 생성
function createPreviewItem(fileObj, imageSrc) {
const div = document.createElement('div');
div.className = 'file-preview-item';
div.setAttribute('data-file-id', fileObj.id);
let iconOrImage;
if (imageSrc) {
iconOrImage = `<img src="${imageSrc}" alt="${fileObj.name}">`;
} else {
iconOrImage = `<div class="file-icon">📄</div>`;
}
div.innerHTML = `
${iconOrImage}
<div class="file-info">
<div class="file-name">${fileObj.name}</div>
<div class="file-size">${fileObj.size}</div>
</div>
<button class="remove-btn" onclick="removeFile('${fileObj.id}')">×</button>
`;
return div;
}
// 파일 제거
function removeFile(fileId) {
uploadedFiles = uploadedFiles.filter(file => file.id != fileId);
const previewItem = document.querySelector(`[data-file-id="${fileId}"]`);
if (previewItem) {
previewItem.remove();
}
updateFilePreviewVisibility();
}
// 파일 미리보기 영역 표시/숨김
function updateFilePreviewVisibility() {
const filePreview = document.getElementById('filePreview');
filePreview.style.display = uploadedFiles.length > 0 ? 'flex' : 'none';
}
// 파일 크기 포맷팅
function formatFileSize(bytes) {
if (bytes === 0) return '0 Bytes';
const k = 1024;
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
}
async function fileToBase64(file) {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result);
reader.onerror = reject;
reader.readAsDataURL(file);
});
}
async function fileToBase64Only(file) {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => {
// data:mime;base64, 부분을 제거하고 base64 문자열만 반환
const base64 = reader.result.split(',')[1];
resolve(base64);
};
reader.onerror = reject;
reader.readAsDataURL(file);
});
}
function handleKeyDown(event) {
if (event.key === 'Enter') {
if (event.shiftKey) {
// Shift+Enter: 줄바꿈 허용 (기본 동작)
autoResizeTextarea(event.target);
return true;
} else {
// Enter만: 메시지 전송
event.preventDefault();
if (!isProcessing) {
sendMessage();
}
return false;
}
}
}
function autoResizeTextarea(textarea) {
// textarea 높이 자동 조절
textarea.style.height = 'auto';
textarea.style.height = Math.min(textarea.scrollHeight, 150) + 'px';
}
async function sendMessage() {
if (isProcessing) return;
// 기존 오류 메시지 제거
const existingErrorMessages = document.querySelectorAll('.error-message');
existingErrorMessages.forEach(errorMsg => errorMsg.remove());
const messageInput = document.getElementById('messageInput');
const messageText = messageInput.value.trim();
if (!messageText && uploadedFiles.length === 0) {
alert('메시지를 입력하거나 파일을 선택해주세요.');
return;
}
try {
// 사용자 메시지를 먼저 화면에 표시
addMessage(messageText, true, uploadedFiles);
// 처리 중 상태로 설정 (어시스턴트 메시지 박스와 스피너 생성)
setProcessing(true);
// 입력 메시지 구성 (파일 초기화 전에 먼저 처리)
const inputContent = [];
// 업로드된 파일들 처리
for (const fileObj of uploadedFiles) {
if (fileObj.type === 'image') {
const imageBase64 = await fileToBase64(fileObj.file);
inputContent.push({
type: "input_image",
image_url: imageBase64
});
} else if (fileObj.type === 'pdf') {
const pdfBase64Only = await fileToBase64Only(fileObj.file);
inputContent.push({
type: "input_file",
filename: fileObj.name,
file_data: `data:application/pdf;base64,${pdfBase64Only}`
});
}
}
// 텍스트 메시지 처리
if (messageText) {
inputContent.push({
type: "input_text",
text: messageText
});
}
// 입력 필드와 파일 목록 초기화 (메시지 구성 후)
messageInput.value = '';
uploadedFiles = [];
document.getElementById('filePreview').innerHTML = '';
updateFilePreviewVisibility();
// textarea 높이도 리셋
messageInput.style.height = 'auto';
const inputMessage = [{
role: "user",
content: inputContent
}];
// API 호출 (스트리밍만 사용)
const response = await fetch('/api/chat', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
input_message: inputMessage,
previous_response_id: previousResponseId
})
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
// 스트리밍 응답 처리
const reader = response.body.getReader();
const decoder = new TextDecoder();
let assistantMessageDiv = null;
let accumulatedText = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
const chunk = decoder.decode(value);
const lines = chunk.split('\n');
for (const line of lines) {
if (line.startsWith('data: ') && line.trim() !== 'data: ') {
try {
const jsonStr = line.slice(6).trim();
if (jsonStr) {
const data = JSON.parse(jsonStr);
if (data.type === 'text_delta') {
if (!assistantMessageDiv) {
// 기존 어시스턴트 메시지 박스 찾기 (스피너가 있는)
assistantMessageDiv = document.getElementById('current-assistant-message');
if (!assistantMessageDiv) {
const messagesDiv = document.getElementById('messages');
assistantMessageDiv = document.createElement('div');
assistantMessageDiv.className = 'message assistant-message';
assistantMessageDiv.id = 'current-assistant-message';
messagesDiv.appendChild(assistantMessageDiv);
}
}
accumulatedText += data.delta;
// 스피너와 텍스트를 함께 표시
let content = '<div class="spinner"></div>';
if (typeof marked !== 'undefined') {
content += marked.parse(accumulatedText);
} else {
content += accumulatedText.replace(/\n/g, '<br>');
}
assistantMessageDiv.innerHTML = content;
document.getElementById('messages').scrollTop = document.getElementById('messages').scrollHeight;
} else if (data.type === 'image_generated') {
if (!assistantMessageDiv) {
// 기존 어시스턴트 메시지 박스 찾기 (스피너가 있는)
assistantMessageDiv = document.getElementById('current-assistant-message');
if (!assistantMessageDiv) {
const messagesDiv = document.getElementById('messages');
assistantMessageDiv = document.createElement('div');
assistantMessageDiv.className = 'message assistant-message';
assistantMessageDiv.id = 'current-assistant-message';
messagesDiv.appendChild(assistantMessageDiv);
}
}
// 기존 어시스턴트 메시지 박스에 이미지 추가
let content = '<div class="spinner"></div>';
if (accumulatedText) {
if (typeof marked !== 'undefined') {
content += marked.parse(accumulatedText);
} else {
content += accumulatedText.replace(/\n/g, '<br>');
}
}
content += `<img src="${data.image_url}" style="max-width: 100%; height: auto; border-radius: 8px; margin-top: 10px;">`;
assistantMessageDiv.innerHTML = content;
document.getElementById('messages').scrollTop = document.getElementById('messages').scrollHeight; } else if (data.type === 'done') {
// 응답 완료 시 스피너 제거
if (assistantMessageDiv) {
const spinnerElement = assistantMessageDiv.querySelector('.spinner');
if (spinnerElement) {
spinnerElement.remove();
}
assistantMessageDiv.id = ''; // ID 제거
}
if (data.response_id) {
previousResponseId = data.response_id;
}
console.log('Stream completed');
}
}
} catch (e) {
console.error('JSON parsing error:', e);
// JSON 파싱 에러 시에도 스피너 제거하고 오류 표시
const currentAssistantMessage = document.getElementById('current-assistant-message');
if (currentAssistantMessage) {
currentAssistantMessage.remove();
}
const messagesDiv = document.getElementById('messages');
const errorDiv = document.createElement('div');
errorDiv.className = 'error-message';
errorDiv.textContent = '응답 처리 중 오류가 발생했습니다.';
messagesDiv.appendChild(errorDiv);
messagesDiv.scrollTop = messagesDiv.scrollHeight;
break; // 스트리밍 루프 종료
}
}
}
}
} catch (error) {
console.error('Error:', error);
// 스피너가 있는 어시스턴트 메시지 박스 제거
const currentAssistantMessage = document.getElementById('current-assistant-message');
if (currentAssistantMessage) {
currentAssistantMessage.remove();
}
// 오류 메시지를 빨간색으로 중앙 정렬해서 표시
const messagesDiv = document.getElementById('messages');
const errorDiv = document.createElement('div');
errorDiv.className = 'error-message';
errorDiv.textContent = `오류가 발생했습니다: ${error.message}`;
messagesDiv.appendChild(errorDiv);
messagesDiv.scrollTop = messagesDiv.scrollHeight;
} finally {
setProcessing(false);
}
}
</script>
</body>
</html>