콘텐츠로 이동

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>


Resources