내 블로그에 AI 조수 심기: Groq & Gemini API 호출

1. 도입: 왜 직접 AI API를 연동하는가?

요즘은 웹사이트에서 직접 AI를 쓰는 시대입니다. 하지만 단순히 API 키만 넣고 “Hello”를 주고받는 수준을 넘어, **실제 서비스 수준의 UX(사용자 경험)**를 갖추려면 생각보다 챙길 것이 많습니다.

오늘은 제 블로그 **’Mana’s Lab’**의 AI 조수를 업그레이드하며 겪은 시행착오와 해결책을 공유합니다.


2. 핵심 해결책: 429 Resource Exhausted 에러 처리

무료 티어 API를 쓰다 보면 가장 자주 마주치는 것이 바로 **할당량 초과(429 에러)**입니다.

  • 문제: 에러가 나면 화면에 아무 반응이 없거나 콘솔에만 찍혀 사용자가 답답해함.
  • 해결: PHP에서 받은 에러 객체를 분석해 채팅창에 투명한 빨간색 배경의 전용 메시지를 띄우도록 커스텀 CSS를 적용했습니다.

이제 “잠시 후 다시 시도해 주세요”라는 안내가 시각적으로 명확하게 전달됩니다.


3. UX의 한 끗: Tab 키로 모델 광속 전환

여러 모델(Gemini 1.5, 2.5 등)을 테스트할 때 일일이 마우스로 콤보박스를 클릭하는 건 매우 번거로운 일입니다.

  • 구현 기능: 입력창에서 Tab을 누르면 다음 모델, Shift + Tab을 누르면 이전 모델로 즉시 변경.
  • 디테일: * e.preventDefault()로 포커스 이동 차단.
    • setTimeoutclearTimeout을 조합해 2초간 입력창 placeholder에 현재 바뀐 모델명을 표시.
    • setCookie를 연동해 새로고침 후에도 선택한 모델 유지.

이렇게 하면 키보드에서 손을 떼지 않고도 다양한 모델의 성능을 즉시 비교해 볼 수 있습니다.


4. 청각적 디테일: TTS(음성 합성) 최적화

AI의 답변을 귀로 듣는 것도 중요하죠. 하지만 브라우저 TTS는 첫 로딩 시 목소리 목록을 가져오는 데 시간이 걸려 기본 음성이 나오는 버그가 있습니다.

  • 해결: onvoiceschanged 이벤트를 감시하여 구글 한국어 음성을 우선적으로 매칭하도록 로직을 짰습니다.
  • 이탈 방지: 페이지를 닫거나 새로고침할 때 소리가 계속 나오지 않도록 beforeunload 이벤트에서 synth.cancel()을 실행해 깔끔하게 마무리했습니다.

5. 마무리하며

AI 기술은 빠르게 변하지만, 그것을 다루는 개발자의 디테일이 사용자에게는 가장 큰 차이로 다가옵니다.

이번 포스팅에서 다룬 코드는 블로그 하단에서 확인하실 수 있습니다. 여러분도 여러분만의 스마트한 AI 조수를 만들어 보세요!


Groq AI 실행창

Gemini AI 실행창

PHP Groq API.php

<?php

require("key.php"); // $api_key가 정의된 파일

// 자바스크립트에서 보낸 JSON 데이터 읽기
$json = file_get_contents('php://input');
$data_input = json_decode($json, true);
$user_message = $data_input['message'] ?? '';
$selected_model = $data_input['model'] ?? $model_list[0];

if ($user_message != "") {
    header('Content-Type: application/json; charset=utf-8');
    $url = "https://api.groq.com/openai/v1/chat/completions";

    $post_data = [
        "model" => $selected_model,
        "messages" => [
            [
                "role" => "system",
                "content" => "너는 '마나'라는 이름의 한국인 인공지능이야. 반드시 모든 답변을 순수 한국어로만 작성해. 한국어 문장 속에 외국어 알파벳이나 기호를 최대한 배제해줘."
            ],
            ["role" => "user", "content" => $user_message]
        ],
        "temperature" => 0.5
    ];

    $ch = curl_init($url);
    curl_setopt($ch, CURLOPT_HTTPHEADER, [
        'Content-Type: application/json',
        'Authorization: Bearer ' . $api_key
    ]);
    curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($post_data));
    curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);

    $response = curl_exec($ch);
    echo $response;
    exit;
}
?>
<!DOCTYPE html>
<html lang="ko">

<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Mana's Lab - AI Assistant</title>
    <style>
        body {
            font-family: 'Pretendard', sans-serif;
            background: #f0f2f5;
            display: flex;
            justify-content: center;
            /* 기본 중앙 정렬 */
            align-items: center;
            height: 100vh;
            margin: 0;
            transition: justify-content 0.3s ease;
        }

        body.show-data {
            justify-content: flex-end;
            padding-right: 20px;
        }

        #data-viewer {
            position: fixed;
            left: 20px;
            top: 7.5vh;
            width: calc(100% - 580px);
            height: 85vh;
            background: #272822;
            color: #f8f8f2;
            padding: 15px;
            border-radius: 15px;
            overflow: auto;
            font-family: 'Courier New', monospace;
            font-size: 13px;
            box-shadow: inset 0 0 10px rgba(0, 0, 0, 0.5);
            display: none;
        }

        /* 스위치 컨테이너 */
        .switch {
            position: relative;
            display: inline-block;
            width: 34px;
            height: 20px;
            vertical-align: middle;
            margin-left: 5px;
        }

        /* 실제 체크박스 숨기기 */
        .switch input {
            opacity: 0;
            width: 0;
            height: 0;
        }

        /* 슬라이더 배경 */
        .slider {
            position: absolute;
            cursor: pointer;
            top: 0;
            left: 0;
            right: 0;
            bottom: 0;
            background-color: #ccc;
            transition: .4s;
            border-radius: 20px;
        }

        /* 슬라이더 동그라미 */
        .slider:before {
            position: absolute;
            content: "";
            height: 14px;
            width: 14px;
            left: 3px;
            bottom: 3px;
            background-color: white;
            transition: .4s;
            border-radius: 50%;
        }

        /* 체크되었을 때 배경색 */
        input:checked+.slider {
            background-color: #28a745;
            /* 초록색으로 변경 */
        }

        /* 체크되었을 때 동그라미 이동 */
        input:checked+.slider:before {
            transform: translateX(14px);
        }


        #chat-container {
            width: 95%;
            max-width: 500px;
            background: white;
            border-radius: 15px;
            box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
            display: flex;
            flex-direction: column;
            height: 85vh;
            overflow: hidden;
        }

        #chat-header {
            background: #007bff;
            color: white;
            padding: 15px;
            text-align: center;
            font-weight: bold;
        }

        /* 에러 메시지 전용 스타일: 약간 투명한 빨간색 배경 */
        .message.ai.error-msg {
            background: rgba(255, 0, 0, 0.1);
            border: 1px solid rgba(255, 0, 0, 0.3);
            color: #d32f2f;
            /* font-weight: 500; */
            font-size: 13px;
        }

        #chat-window {
            flex: 1;
            overflow-y: auto;
            padding: 20px;
            display: flex;
            flex-direction: column;
            gap: 15px;
            background: #fafafa;
        }

        .message {
            padding: 12px 16px;
            border-radius: 15px;
            max-width: 85%;
            line-height: 1.5;
            font-size: 0.95em;
            position: relative;
            cursor: pointer;
        }

        .user {
            background: #007bff;
            color: white;
            align-self: flex-end;
            border-bottom-right-radius: 2px;
        }

        .ai {
            background: #e9ecef;
            color: #333;
            align-self: flex-start;
            border-bottom-left-radius: 2px;
            white-space: pre-wrap;
            word-break: break-word;
        }

        #input-area {
            padding: 15px;
            display: flex;
            gap: 10px;
            background: white;
            border-top: 1px solid #eee;
        }

        #user-input {
            flex: 1;
            padding: 12px;
            border: 1px solid #ddd;
            border-radius: 8px;
            outline: none;
        }

        button {
            padding: 10px 20px;
            background: #007bff;
            color: white;
            border: none;
            border-radius: 8px;
            cursor: pointer;
            font-weight: bold;
        }

        button:disabled {
            background: #ccc;
        }
    </style>
</head>

<body>

    <div id="data-viewer"></div>

    <div id="chat-container">
        <div id="chat-header">
            Micro2iot.com's GROQ AI Assistant
            <label
                style="font-size: 0.75em; font-weight: normal; margin-left: 10px; cursor: pointer; display: inline-flex; align-items: center;">
                Data View
                <div class="switch">
                    <input type="checkbox" id="data-toggle">
                    <span class="slider"></span>
                </div>
            </label>
            <select id="model-selector" style="margin-top:10px; width:95%; padding:5px; border-radius:5px;">
                <?php foreach ($model_list as $m): ?>
                    <option value="<?php echo $m; ?>"><?php echo $m; ?></option>
                <?php endforeach; ?>
            </select>
        </div>
        <div id="chat-window">
            <div class="message ai">반갑습니다! 설정을 마치셨군요. 무엇을 도와드릴까요?</div>
        </div>
        <div id="input-area">
            <input type="text" id="user-input" placeholder="메시지를 입력하세요..." autocomplete="off">
            <button id="send-btn">전송</button>
        </div>
    </div>

    <script>
        const modelSelector = document.getElementById('model-selector');
        const dataToggle = document.getElementById('data-toggle');
        const dataViewer = document.getElementById('data-viewer');

        // 쿠키 함수
        const setCookie = (name, value, days) => {
            const d = new Date();
            d.setTime(d.getTime() + (days * 24 * 60 * 60 * 1000));
            document.cookie = `${name}=${value};expires=${d.toUTCString()};path=/`;
        };

        const getCookie = (name) => {
            const value = `; ${document.cookie}`;
            const parts = value.split(`; ${name}=`);
            if (parts.length === 2) return parts.pop().split(';').shift();
        };

        // 레이아웃 업데이트
        const updateLayout = () => {
            if (dataToggle.checked) {
                document.body.classList.add('show-data');
                dataViewer.style.display = 'block';
                setCookie('data_view_enabled', 'true', 30);
            } else {
                document.body.classList.remove('show-data');
                dataViewer.style.display = 'none';
                setCookie('data_view_enabled', 'false', 30);
            }
        };

        // 초기화
        window.addEventListener('DOMContentLoaded', () => {
            const savedModel = getCookie('selected_model');
            if (savedModel) modelSelector.value = savedModel;

            const savedDataView = getCookie('data_view_enabled');
            dataToggle.checked = (savedDataView === 'true');
            updateLayout();
        });

        modelSelector.addEventListener('change', (e) => setCookie('selected_model', e.target.value, 30));
        dataToggle.addEventListener('change', updateLayout);

        // TTS 함수 (Google 한국어 우선 선택)
        const speak = (text) => {
            if (!window.speechSynthesis) return;
            window.speechSynthesis.cancel();

            const utterance = new SpeechSynthesisUtterance(text);
            const voices = window.speechSynthesis.getVoices();

            // 1. 한국어 목소리들만 필터링
            const koVoices = voices.filter(v => v.lang.includes('ko'));

            // 2. 그 중 이름에 'Google'이 포함된 목소리를 먼저 찾음
            let selectedVoice = koVoices.find(v => v.name.includes('Google'));

            // 3. 구글 목소리가 없으면 일반 한국어 목소리, 그것도 없으면 기본값
            if (!selectedVoice) {
                selectedVoice = koVoices[0] || voices[0];
            }

            if (selectedVoice) {
                utterance.voice = selectedVoice;
                console.log("사용 중인 목소리:", selectedVoice.name);
            }

            utterance.lang = 'ko-KR';
            utterance.rate = 1.0;
            utterance.pitch = 1.0;

            window.speechSynthesis.speak(utterance);
        };

        // URL 링크 변환
        const convertUrlsToLinks = (text) => {
            const urlPattern = /(https?:\/\/[^\s]+)/g;
            return text.replace(urlPattern, (url) =>
                `<a href="${url}" target="_blank" rel="noopener noreferrer" style="color: #007bff; text-decoration: underline;">${url}</a>`
            );
        };

        // 메시지 추가
        const appendMessage = (sender, text) => {
            const chatWindow = document.getElementById('chat-window');
            const msgDiv = document.createElement('div');
            msgDiv.className = "message " + sender;
            msgDiv.innerHTML = convertUrlsToLinks(text);

            msgDiv.addEventListener('click', () => {
                if (window.speechSynthesis.speaking) window.speechSynthesis.cancel();
                else speak(text);
            });

            chatWindow.appendChild(msgDiv);
            chatWindow.scrollTop = chatWindow.scrollHeight;
        };

        // 바탕 클릭 시 TTS 정지
        document.addEventListener('click', (e) => {
            if (!e.target.closest('.message') && window.speechSynthesis.speaking) {
                // window.speechSynthesis.cancel();
            }
        });

        // 전송 로직
        const sendMessage = async () => {
            const input = document.getElementById('user-input');
            const btn = document.getElementById('send-btn');
            const message = input.value.trim();
            if (!message) return;

            appendMessage('user', message);
            input.value = '';
            btn.disabled = true;

            try {
                const response = await fetch('', {
                    method: 'POST',
                    headers: { 'Content-Type': 'application/json' },
                    body: JSON.stringify({ "message": message, "model": modelSelector.value })
                });

                const data = await response.json();
                dataViewer.innerHTML = '<h3>[Raw API Data]</h3><pre>' + JSON.stringify(data, null, 4) + '</pre>';

                // --- 에러 처리 코드 수정 ---
                if (data.error) {
                    let errorMsg = `[Error ${data.error.code}] ${data.error.message}`;
                    if (data.error.code === 429) {
                        errorMsg = "⚠️ 할당량 초과! 잠시 후 다시 시도해 주세요.\n" + data.error.message;
                    }

                    // 메시지를 추가한 후, 해당 요소에 error-msg 클래스를 강제로 입힙니다.
                    appendMessage('ai', errorMsg);
                    const lastMsg = document.querySelector('#chat-window .message.ai:last-child');
                    if (lastMsg) lastMsg.classList.add('error-msg');

                    return;
                }

                if (data.choices && data.choices[0]) {
                    const aiResponse = data.choices[0].message.content;
                    appendMessage('ai', aiResponse);
                    speak(aiResponse);
                }
            } catch (e) {
                appendMessage('ai', "오류: " + e.message);
            } finally {
                btn.disabled = false;
            }
        };

        let placeholderTimer = null;

        document.getElementById('user-input').addEventListener('keydown', (e) => {
            // 1. 탭(Tab) 키 처리
            if (e.key === 'Tab') {
                e.preventDefault();

                const options = modelSelector.options;
                let currentIndex = modelSelector.selectedIndex;
                let nextIndex;

                // Shift + Tab이면 이전으로, 아니면 다음으로
                if (e.shiftKey) {
                    nextIndex = (currentIndex - 1 + options.length) % options.length;
                } else {
                    nextIndex = (currentIndex + 1) % options.length;
                }

                // 화면의 콤보박스 값 변경
                modelSelector.selectedIndex = nextIndex;

                // ★ 핵심: 바뀐 모델 값을 쿠키에 즉시 저장 (30일 유지)
                setCookie('selected_model', modelSelector.value, 30);

                // 타이머 관리 및 placeholder 표시
                if (placeholderTimer) {
                    clearTimeout(placeholderTimer);
                }

                const inputEl = document.getElementById('user-input');
                inputEl.placeholder = `모델 변경: ${modelSelector.value}`;

                placeholderTimer = setTimeout(() => {
                    inputEl.placeholder = "메시지를 입력하세요...";
                    placeholderTimer = null;
                }, 2000);
            }

            // 2. 엔터(Enter) 키 처리
            if (e.key === 'Enter') {
                sendMessage();
            }
        });


        // 페이지를 떠날 때(새로고침, 닫기, 뒤로가기 등) TTS 중지
        window.addEventListener('beforeunload', () => {
            if (!window.speechSynthesis) return;
            window.speechSynthesis.cancel();
        });

        document.getElementById('send-btn').addEventListener('click', sendMessage);
        document.getElementById('user-input').addEventListener('keypress', (e) => { if (e.key === 'Enter') sendMessage(); });
    </script>
</body>

</html>

PHP GeminiAPI.php

<?php
// 1. 설정 로드 (key.php 내에 $API_KEY와 $model_list가 있어야 함)
require "key.php";
$clean_key = trim($API_KEY);

// 2. 자바스크립트로부터 전달받은 메시지 및 모델 읽기
$json = file_get_contents('php://input');
$data_input = json_decode($json, true);
$userMessage = $data_input['message'] ?? '';
$selected_model = $data_input['model'] ?? $model_list[0];

if ($userMessage != "") {
    header('Content-Type: application/json; charset=UTF-8');

    // 구글 API URL (선택된 모델 적용)
    $API_URL = "https://generativelanguage.googleapis.com/v1beta/models/{$selected_model}:generateContent?key={$clean_key}";

    // 구글 API 전송 데이터 구조
    $postData = [
        "contents" => [
            ["parts" => [["text" => $userMessage]]]
        ],
        "tools" => [
            [
                "google_search" => new stdClass() // 구글 검색 기능을 강제로 활성화
            ]
        ],
        "generationConfig" => [
            "temperature" => 0.7,
            "maxOutputTokens" => 2048
        ]
    ];

    $ch = curl_init($API_URL);
    curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
    curl_setopt($ch, CURLOPT_POST, true);
    curl_setopt($ch, CURLOPT_HTTPHEADER, ['Content-Type: application/json']);
    curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($postData, JSON_UNESCAPED_UNICODE));
    curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false);

    $response = curl_exec($ch);
    echo $response;
    exit;
}
?>

<!DOCTYPE html>
<html lang="ko">

<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Mana's Lab - Gemini AI</title>
    <style>
        body {
            font-family: 'Pretendard', sans-serif;
            background: #f0f2f5;
            display: flex;
            justify-content: center;
            align-items: center;
            height: 100vh;
            margin: 0;
            transition: justify-content 0.3s ease;
        }

        body.show-data {
            justify-content: flex-end;
            padding-right: 20px;
        }

        #data-viewer {
            position: fixed;
            left: 20px;
            top: 7.5vh;
            width: calc(100% - 580px);
            height: 85vh;
            background: #272822;
            color: #f8f8f2;
            padding: 15px;
            border-radius: 15px;
            overflow: auto;
            font-family: 'Courier New', monospace;
            font-size: 13px;
            box-shadow: inset 0 0 10px rgba(0, 0, 0, 0.5);
            display: none;
        }

        #chat-container {
            width: 95%;
            max-width: 500px;
            background: white;
            border-radius: 15px;
            box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
            display: flex;
            flex-direction: column;
            height: 85vh;
            overflow: hidden;
        }

        #chat-header {
            background: #1a73e8;
            color: white;
            padding: 15px;
            text-align: center;
            font-weight: bold;
        }

        /* 에러 메시지 전용 스타일: 약간 투명한 빨간색 배경 */
        .message.ai.error-msg {
            background: rgba(255, 0, 0, 0.1);
            border: 1px solid rgba(255, 0, 0, 0.3);
            color: #d32f2f;
            /* font-weight: 500; */
            font-size: 13px;
        }

        /* 스위치 디자인 */
        .switch {
            position: relative;
            display: inline-block;
            width: 34px;
            height: 20px;
            vertical-align: middle;
            margin-left: 5px;
        }

        .switch input {
            opacity: 0;
            width: 0;
            height: 0;
        }

        .slider {
            position: absolute;
            cursor: pointer;
            top: 0;
            left: 0;
            right: 0;
            bottom: 0;
            background-color: #ccc;
            transition: .4s;
            border-radius: 20px;
        }

        .slider:before {
            position: absolute;
            content: "";
            height: 14px;
            width: 14px;
            left: 3px;
            bottom: 3px;
            background-color: white;
            transition: .4s;
            border-radius: 50%;
        }

        input:checked+.slider {
            background-color: #28a745;
        }

        input:checked+.slider:before {
            transform: translateX(14px);
        }

        #chat-window {
            flex: 1;
            overflow-y: auto;
            padding: 20px;
            display: flex;
            flex-direction: column;
            gap: 15px;
            background: #fafafa;
        }

        .message {
            padding: 12px 16px;
            border-radius: 15px;
            max-width: 85%;
            line-height: 1.5;
            font-size: 0.95em;
            cursor: pointer;
            word-break: break-all;
        }

        .user {
            background: #1a73e8;
            color: white;
            align-self: flex-end;
            border-bottom-right-radius: 2px;
        }

        .ai {
            background: #e9ecef;
            color: #333;
            align-self: flex-start;
            border-bottom-left-radius: 2px;
            white-space: pre-wrap;
        }

        #input-area {
            padding: 15px;
            display: flex;
            gap: 10px;
            background: white;
            border-top: 1px solid #eee;
        }

        #user-input {
            flex: 1;
            padding: 12px;
            border: 1px solid #ddd;
            border-radius: 8px;
            outline: none;
        }

        button {
            padding: 10px 20px;
            background: #1a73e8;
            color: white;
            border: none;
            border-radius: 8px;
            cursor: pointer;
            font-weight: bold;
        }

        button:disabled {
            background: #ccc;
        }
    </style>
</head>

<body>

    <div id="data-viewer"></div>

    <div id="chat-container">
        <div id="chat-header">
            Micro2iot.com's Gemini Assistant
            <label
                style="font-size: 0.75em; font-weight: normal; margin-left: 10px; cursor: pointer; display: inline-flex; align-items: center;">
                Data View
                <div class="switch">
                    <input type="checkbox" id="data-toggle">
                    <span class="slider"></span>
                </div>
            </label>
            <select id="model-selector" style="margin-top:10px; width:95%; padding:5px; border-radius:5px;">
                <?php foreach ($model_list as $m): ?>
                    <option value="<?php echo $m; ?>"><?php echo $m; ?></option>
                <?php endforeach; ?>
            </select>
        </div>
        <div id="chat-window">
            <div class="message ai">반갑습니다! 제미나이 조수입니다. 무엇을 도와드릴까요?</div>
        </div>
        <div id="input-area">
            <input type="text" id="user-input" placeholder="메시지를 입력하세요..." autocomplete="off">
            <button id="send-btn">전송</button>
        </div>
    </div>

    <script>
        const modelSelector = document.getElementById('model-selector');
        const dataToggle = document.getElementById('data-toggle');
        const dataViewer = document.getElementById('data-viewer');
        const synth = window.speechSynthesis;

        // 쿠키 로직
        const setCookie = (name, value, days) => {
            const d = new Date();
            d.setTime(d.getTime() + (days * 24 * 60 * 60 * 1000));
            document.cookie = `${name}=${value};expires=${d.toUTCString()};path=/`;
        };
        const getCookie = (name) => {
            const value = `; ${document.cookie}`;
            const parts = value.split(`; ${name}=`);
            if (parts.length === 2) return parts.pop().split(';').shift();
        };

        const updateLayout = () => {
            if (dataToggle.checked) {
                document.body.classList.add('show-data');
                dataViewer.style.display = 'block';
                setCookie('gemini_data_view', 'true', 30);
            } else {
                document.body.classList.remove('show-data');
                dataViewer.style.display = 'none';
                setCookie('gemini_data_view', 'false', 30);
            }
        };

        window.addEventListener('DOMContentLoaded', () => {
            const savedModel = getCookie('gemini_model');
            if (savedModel) modelSelector.value = savedModel;
            const savedDataView = getCookie('gemini_data_view');
            dataToggle.checked = (savedDataView === 'true');
            updateLayout();
        });

        modelSelector.addEventListener('change', (e) => setCookie('gemini_model', e.target.value, 30));
        dataToggle.addEventListener('change', updateLayout);

        // TTS (Google 한국어 우선)
        const speak = (text) => {
            if (!synth) return;
            synth.cancel();
            const utterance = new SpeechSynthesisUtterance(text);
            const voices = synth.getVoices();
            const selectedVoice = voices.find(v => v.name.includes('Google') && v.lang.includes('ko')) || voices.find(v => v.lang.includes('ko'));
            if (selectedVoice) utterance.voice = selectedVoice;
            utterance.lang = 'ko-KR';
            synth.speak(utterance);
        };

        const convertUrlsToLinks = (text) => {
            const urlPattern = /(https?:\/\/[^\s]+)/g;
            return text.replace(urlPattern, (url) =>
                `<a href="${url}" target="_blank" rel="noopener noreferrer" style="color: #1a73e8; text-decoration: underline;">${url}</a>`
            );
        };

        const appendMessage = (sender, text) => {
            const msgDiv = document.createElement('div');
            msgDiv.className = `message ${sender}`;
            msgDiv.innerHTML = convertUrlsToLinks(text);
            msgDiv.onclick = () => {
                if (synth.speaking) synth.cancel();
                else speak(text);
            };
            document.getElementById('chat-window').appendChild(msgDiv);
            document.getElementById('chat-window').scrollTop = document.getElementById('chat-window').scrollHeight;
        };

        // 바탕 클릭 시 TTS 중지
        document.addEventListener('click', (e) => {
            if (!e.target.closest('.message') && synth.speaking) synth.cancel();
        });

        const sendMessage = async () => {
            const input = document.getElementById('user-input');
            const message = input.value.trim();
            if (!message) return;

            appendMessage('user', message);
            input.value = '';
            document.getElementById('send-btn').disabled = true;

            try {
                const response = await fetch('', {
                    method: 'POST',
                    headers: { 'Content-Type': 'application/json' },
                    body: JSON.stringify({ "message": message, "model": modelSelector.value })
                });

                const data = await response.json();
                dataViewer.innerHTML = '<h3>[Gemini Raw Data]</h3><pre>' + JSON.stringify(data, null, 4) + '</pre>';

                // --- 에러 처리 코드 수정 ---
                if (data.error) {
                    let errorMsg = `[Error ${data.error.code}] ${data.error.message}`;
                    if (data.error.code === 429) {
                        errorMsg = "⚠️ 할당량 초과! 잠시 후 다시 시도해 주세요.\n" + data.error.message;
                    }

                    // 메시지를 추가한 후, 해당 요소에 error-msg 클래스를 강제로 입힙니다.
                    appendMessage('ai', errorMsg);
                    const lastMsg = document.querySelector('#chat-window .message.ai:last-child');
                    if (lastMsg) lastMsg.classList.add('error-msg');

                    return;
                }
                // --- 에러 처리 코드 추가 끝 ---

                if (data.candidates && data.candidates[0]) {
                    const aiResponse = data.candidates[0].content.parts[0].text;
                    appendMessage('ai', aiResponse);
                    speak(aiResponse);
                }
            } catch (e) {
                appendMessage('ai', "네트워크 오류가 발생했습니다: " + e.message);
            } finally {
                document.getElementById('send-btn').disabled = false;
            }
        };

        // 전역 변수로 타이머 저장 공간 확보
        let placeholderTimer = null;

        document.getElementById('user-input').addEventListener('keydown', (e) => {
            // 1. 탭(Tab) 키 처리
            if (e.key === 'Tab') {
                e.preventDefault(); // 포커스 이동 방지

                const options = modelSelector.options;
                let currentIndex = modelSelector.selectedIndex;
                let nextIndex;

                // Shift + Tab이면 이전으로, 아니면 다음으로
                if (e.shiftKey) {
                    nextIndex = (currentIndex - 1 + options.length) % options.length;
                } else {
                    nextIndex = (currentIndex + 1) % options.length;
                }

                modelSelector.selectedIndex = nextIndex;
                setCookie('gemini_model', modelSelector.value, 30);

                // --- 타이머 중복 방지 및 2초 설정 ---
                if (placeholderTimer) {
                    clearTimeout(placeholderTimer);
                }

                const inputEl = document.getElementById('user-input');
                inputEl.placeholder = `모델 변경: ${modelSelector.value}`;

                // 2000ms(2초) 후에 원래 문구로 복구
                placeholderTimer = setTimeout(() => {
                    inputEl.placeholder = "메시지를 입력하세요...";
                    placeholderTimer = null;
                }, 2000);
            }

            // 2. 엔터(Enter) 키 처리
            if (e.key === 'Enter') {
                sendMessage();
            }
        });


        document.getElementById('send-btn').addEventListener('click', sendMessage);
        document.getElementById('user-input').addEventListener('keypress', (e) => { if (e.key === 'Enter') sendMessage(); });
        if (synth.onvoiceschanged !== undefined) synth.onvoiceschanged = () => synth.getVoices();
    </script>
</body>

</html>

코멘트

답글 남기기

이메일 주소는 공개되지 않습니다. 필수 필드는 *로 표시됩니다