구글의 제미나이를 내 홈페이지에 넣을 수 있는 HTML + JS 소스입니다.

여기서 수정해야 할 변수는 3개입니다.

  • ALLOWED_ORIGINS: 내 소스를 무단으로 퍼가는 것을 막습니다. js 코드는 암호화해서 사용하세요.
  • API_KEY: 구글의 제미나이 API 키입니다. 무료입니다.
  • PEXELS_KEY:  Pexels 키를 발급받아서 사용하세요. 무료이며 사진 출력에 사용됩니다.

js 암호화는 https://obfuscator.io/ 같은 사이트에서 가능합니다.

API 키가 없다면 구글에서 키를 발급받으세요.

<!DOCTYPE html>
<html lang="ko">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Gemini Direct Chat</title>
    <style>
        .post-header{display:none;}
        .gemini-chat-wrapper{display:flex;justify-content:center;padding:20px 0;background-color:#fff0}#chat-container{width:100%;max-width:500px;height:600px;background:#fff;border:1px solid #ddd;border-radius:15px;box-shadow:0 5px 15px rgb(0 0 0 / .05);display:flex;flex-direction:column;overflow:hidden}#chat-header{background-color:#fff;padding:15px 20px;display:flex;justify-content:space-between;align-items:center;border-bottom:1px solid #eee}.header-left{display:flex;align-items:center;gap:10px}.status-dot{width:8px;height:8px;background-color:#28a745;border-radius:50%;box-shadow:0 0 5px rgb(40 167 69 / .5)}.header-title{font-size:16px;font-weight:600;color:#333}.header-right{display:flex;align-items:center}.model-badge{background-color:#f0f4ff;color:#007bff;font-size:11px;font-weight:700;padding:3px 8px;border-radius:20px;text-transform:uppercase}#chat-container{width:450px;height:700px;background:#fff;border-radius:15px;box-shadow:0 5px 15px rgb(0 0 0 / .1);display:flex;flex-direction:column;overflow:hidden}#chat-box{flex:1;padding:20px;overflow-y:auto;border-bottom:1px solid #eee;display:flex;flex-direction:column;gap:15px}.message{padding:10px 15px;border-radius:15px;max-width:80%;line-height:1.4;font-size:14px;word-break:break-word}.user{align-self:flex-end;background-color:#007bff;color:#fff;border-bottom-right-radius:2px}.ai{align-self:flex-start;background-color:#e9e9eb;color:#333;border-bottom-left-radius:2px}#input-area{padding:15px;display:flex;gap:10px;background:#fff}input{flex:1;padding:10px;border:1px solid #ddd;border-radius:5px;outline:none}button{padding:10px 15px;background:#007bff;color:#fff;border:none;border-radius:5px;cursor:pointer}.copy-btn{font-size:10px;padding:3px 8px;margin-top:5px;cursor:pointer;border:none;border-radius:5px;background:#6c757d;color:#fff}.ai p{margin:0 0 10px 0}.ai p:last-child{margin-bottom:0}.ai ul,.ai ol{padding-left:20px;margin:5px 0}.ai code{background:#f0f0f0;padding:2px 4px;border-radius:3px;font-family:monospace}.ai pre{background:#2d2d2d;color:#fff;padding:10px;border-radius:5px;overflow-x:auto}.ai table{border-collapse:collapse;width:100%;margin:10px 0}.ai th,.ai td{border:1px solid #ddd;padding:8px;text-align:left}.ai th{background-color:#f2f2f2}.typing{display:inline-flex;align-items:center;gap:3px;font-weight:700;color:#007bff}.dot{width:4px;height:4px;background-color:#007bff;border-radius:50%;animation:bounce 1.4s infinite ease-in-out both}.dot:nth-child(1){animation-delay:-0.32s}.dot:nth-child(2){animation-delay:-0.16s}@keyframes bounce{0%,80%,100%{transform:scale(0)}40%{transform:scale(1)}}
		.message { animation: fadeIn 0.4s ease-out; }
        @keyframes fadeIn { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: translateY(0); } }
		/* 기존 스타일에 추가 */
        .ai img { cursor: pointer; transition: transform 0.2s; }
        .ai img:hover { transform: scale(1.02); }
        /* 모달 스타일 (source 3 기반) */
        .modal { display: none; position: fixed; z-index: 10000; left: 0; top: 0; width: 100%; height: 100%; background-color: rgba(0,0,0,0.9); overflow: auto; }
        .modal-content-wrapper { margin: auto; display: flex; flex-direction: column; align-items: center; padding: 40px 0; }
        .modal-content { max-width: 90%; max-height: 70vh; border-radius: 5px; }
        .close-modal { position: absolute; top: 20px; right: 35px; color: #f1f1f1; font-size: 40px; font-weight: bold; cursor: pointer; }
        .download-btn-modal { margin-top: 20px; padding: 12px 25px; background-color: #05a081; color: #fff; border: none; border-radius: 5px; font-weight: 700; cursor: pointer; }
        #modal-caption { margin-top: 15px; color: #ccc; }
    </style>
</head>
<body>
<div id="image-modal" class="modal">
    <span class="close-modal" onclick="closeImgModal()">&times;</span>
    <div class="modal-content-wrapper">
        <img class="modal-content" id="full-image">
        <div id="modal-caption"></div>
        <button id="modal-download-btn" class="download-btn-modal">PC에 저장</button>
    </div>
</div>

<div class="gemini-chat-wrapper">
    <div id="chat-container">
        <div id="chat-header">
            <div class="header-left">
                <div class="status-dot"></div>
                <span class="header-title">Google AI</span>
            </div>
            <div class="header-right">
                <span class="model-badge">Gemini 1.5 Flash</span>
            </div>
        </div>
        <div id="chat-box">
            <div class="message ai">반갑습니다! 무엇을 도와드릴까요?</div>
        </div>
        <form id="chat-form" style="display:contents;">
            <div id="input-area">
                <input type="text" id="user-input" placeholder="메시지를 입력하세요..." required>
                <button type="submit" id="send-btn">전송</button>
                <button type="button" id="stop-btn" style="display:none; background:#ff4d4d;">취소</button>
            </div>
        </form>
    </div>
</div>

<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
<script>
    const ALLOWED_ORIGINS = ['localhost'];
	const API_KEY = "YOUR_API_KEY";
    (function() {
        const currentHost = window.location.hostname;
        const isAllowed = ALLOWED_ORIGINS.some(origin => currentHost.includes(origin));
        if (!isAllowed) {
            alert("이 서비스는 허용되지 않은 도메인에서 실행되었습니다.");
            document.body.innerHTML = "<h1>Access Denied</h1>";
            throw new Error("Unauthorized domain access.");
        }
    })();

    const API_URL = `https://generativelanguage.googleapis.com/v1beta/models/gemini-flash-latest:generateContent?key=${API_KEY}`;
    let controller;

    // Pexels 이미지 가져오기 함수
    async function fetchPexelsImage(query) {
        const PEXELS_KEY = "YOUR_API_KEY";
        const url = `https://api.pexels.com/v1/search?query=${encodeURIComponent(query)}&per_page=1`;
        try {
            const response = await fetch(url, { headers: { Authorization: PEXELS_KEY } });
            const data = await response.json();
            return data.photos && data.photos.length > 0 ? data.photos[0].src.large : null;
        } catch (error) {
            console.error("이미지 호출 실패:", error);
            return null;
        }
    }

    document.getElementById('chat-form').addEventListener('submit', async (e) => {
        e.preventDefault();
        const inputField = document.getElementById('user-input');
        const sendBtn = document.getElementById('send-btn');
        const stopBtn = document.getElementById('stop-btn');
        const message = inputField.value.trim();
        if (!message) return;

        sendBtn.style.display = 'none';
        stopBtn.style.display = 'inline-block';

        appendMessage('user', message);
        inputField.value = '';
        const tempMsgId = 'temp-' + Date.now();
        const thinkingHTML = `<div class="typing">AI가 생각 중<span class="dot"></span><span class="dot"></span><span class="dot"></span></div>`;
        appendMessage('ai', thinkingHTML, tempMsgId); 

        controller = new AbortController();

        try {
            const response = await fetch(API_URL, {
                method: 'POST',
                headers: { 'Content-Type': 'application/json' },
                body: JSON.stringify({ contents: [{ parts: [{ text: message }] }] }),
                signal: controller.signal
            });

            const data = await response.json();
            // 1. 구글 서버가 에러를 보낸 경우
            if (data.error) {
                let friendlyMessage = "";

                if (data.error.code === 429 || data.error.status === "RESOURCE_EXHAUSTED") {
                    updateMessage(tempMsgId, "💡 AI가 지금 조금 바쁘네요! 약 20~30초만 기다렸다가 다시 전송 버튼을 눌러주시겠어요? 😊");
                } else if (data.error.code === 404) {
                    updateMessage(tempMsgId, "💡 AI 모델 경로를 찾을 수 없습니다. 주소 설정을 다시 확인해 주세요.");
                } else {
                    updateMessage(tempMsgId, "💡️ 서비스에 잠시 문제가 생겼습니다: " + data.error.message);
                }
                return;
            }

            if (data.candidates && data.candidates[0].content) {
                const aiResponse = data.candidates[0].content.parts[0].text;
                // 여기서 updateMessage를 호출 (async 함수이므로 내부 처리가 부드러워짐)
                await updateMessage(tempMsgId, aiResponse);
            }
        } catch (error) {
            updateMessage(tempMsgId, error.name === 'AbortError' ? "🚫 사용자가 요청을 취소했습니다." : "❌ 통신 중 오류가 발생했습니다. 잠시 후 다시 시도해 주세요.");
        } finally {
            sendBtn.style.display = 'inline-block';
            stopBtn.style.display = 'none';
        }
    });

    document.getElementById('stop-btn').onclick = () => controller && controller.abort();

    function appendMessage(sender, text, id = null) {
        const chatBox = document.getElementById('chat-box');
        const wrapper = document.createElement('div');
        wrapper.style.display = 'flex';
        wrapper.style.flexDirection = 'column';
        wrapper.style.alignItems = (sender === 'user') ? 'flex-end' : 'flex-start';

        const msgDiv = document.createElement('div');
        msgDiv.classList.add('message', sender);
        if (id) msgDiv.id = id;
        
        if (sender === 'ai') {
            msgDiv.innerHTML = text; // 초기 애니메이션용
        } else {
            msgDiv.innerText = text;
        }
        
        wrapper.appendChild(msgDiv);
        if (sender === 'ai') {
            const copyBtn = document.createElement('button');
            copyBtn.innerText = '📋 Copy';
            copyBtn.classList.add('copy-btn');
            copyBtn.style.display = 'none';
            wrapper.appendChild(copyBtn);
        }
        chatBox.appendChild(wrapper);
        chatBox.scrollTop = chatBox.scrollHeight;
    }

    // 핵심: async 키워드를 붙여 await 사용 가능하게 수정
    async function updateMessage(id, newText) {
        const msgDiv = document.getElementById(id);
        if (!msgDiv) return;

        // 취소(Abort) 메시지인지 확인
        const isCancelled = 
            newText.includes("💡️") || 
            newText.includes("❌") || 
            newText.includes("🚫") || 
            newText.includes("서비스에 잠시 문제가 생겼습니다");

        let cleanText = newText;
        let imageUrl = null;

        // JSON 데이터(이미지 요청)가 포함되어 있는지 확인 및 추출
        const jsonMatch = newText.match(/\{[\s\S]*\}/);
        if (jsonMatch && !isCancelled) {
            try {
                const parsed = JSON.parse(jsonMatch[0]);
                let prompt = "";
                if (parsed.action_input) {
                    const inner = typeof parsed.action_input === 'string' ? JSON.parse(parsed.action_input) : parsed.action_input;
                    prompt = inner.prompt || "";
                }
                if (prompt) {
                    imageUrl = await fetchPexelsImage(prompt);
                    cleanText = newText.replace(jsonMatch[0], "").trim();
                }
            } catch (e) { console.error("JSON 파싱 에러"); }
        }

        // 부드러운 타이핑 효과
        let i = 0;
        msgDiv.innerHTML = "";
        // 취소된 메시지나 에러 메시지는 타이핑 없이 즉시 출력
        if (isCancelled) {
            msgDiv.innerText = cleanText;
            // 옆에 붙어있던 복사 버튼을 찾아서 삭제합니다.
            const sideBtn = msgDiv.parentElement.querySelector('.copy-btn');
            if (sideBtn) sideBtn.remove(); 
            return; // 복사 버튼 등을 만들지 않고 종료
        }

        const timer = setInterval(() => {
            if (i < cleanText.length) {
                msgDiv.innerText = cleanText.slice(0, i + 1);
                i++;
                const chatBox = document.getElementById('chat-box');
                chatBox.scrollTop = chatBox.scrollHeight;
            } else {
                clearInterval(timer);
                msgDiv.innerHTML = marked.parse(cleanText);

                if (imageUrl) {
                    const img = document.createElement('img');
                    img.src = imageUrl;
                    img.style.cssText = 'width: 100%; border-radius: 10px; display: block; margin-top:10px;';
        
                    // 이미지 클릭 시 모달 열기 연결[cite: 3]
                    img.onclick = () => openImgModal(imageUrl, "AI 추천 이미지");
        
                    img.onload = () => { 
                        const chatBox = document.getElementById('chat-box');
                        chatBox.scrollTop = chatBox.scrollHeight; 
                    };
                    msgDiv.appendChild(img);

                    // [수정] 채팅창 안의 '이미지 저장' 버튼도 모달을 거치지 않고 즉시 다운로드 가능하도록 수정
                    const quickDlBtn = document.createElement('button');
                    quickDlBtn.innerText = '💾 즉시 저장';
                    quickDlBtn.classList.add('copy-btn');
                    quickDlBtn.style.cssText = 'display: block; margin-top: 5px; background: #28a745; color:white;';
                    quickDlBtn.onclick = () => { currentDownloadUrl = imageUrl; document.getElementById('modal-download-btn').click(); };
                    msgDiv.appendChild(quickDlBtn);
                }

                const btn = msgDiv.parentElement.querySelector('.copy-btn');
                if (btn) {
                    btn.style.display = 'inline-block';
                    btn.onclick = () => copyToClipboard(cleanText, btn);
                }
            }
        }, 30); // 타이핑 속도 조절
    }

    // 이미지 다운로드 함수
    async function downloadImage(url) {
        try {
            const response = await fetch(url);
            const blob = await response.blob();
            const blobUrl = window.URL.createObjectURL(blob);
            const a = document.createElement('a');
            a.href = blobUrl;
            a.download = `gemini-image-${Date.now()}.jpg`;
            document.body.appendChild(a);
            a.click();
            document.body.removeChild(a);
            window.URL.revokeObjectURL(blobUrl);
        } catch (error) {
            alert("이미지 다운로드에 실패했습니다.");
        }
    }

    async function copyToClipboard(text, btn) {
        await navigator.clipboard.writeText(text);
        btn.innerText = "🔴 Copied!";
        setTimeout(() => btn.innerText = '🔵 Copy', 2000);
    }
let currentDownloadUrl = "";

// 1. 모달 열기 함수
function openImgModal(url, alt) {
    currentDownloadUrl = url;
    const modal = document.getElementById('image-modal');
    modal.style.display = "block";
    document.getElementById('full-image').src = url;
    document.getElementById('modal-caption').innerText = alt || "Pexels Image";
}

// 2. 모달 닫기 함수
function closeImgModal() {
    document.getElementById('image-modal').style.display = "none";
}

// 3. 강제 다운로드 로직 (Blob 방식)[cite: 3]
document.getElementById('modal-download-btn').onclick = async () => {
    try {
        const response = await fetch(currentDownloadUrl);
        const blob = await response.blob();
        const url = window.URL.createObjectURL(blob);
        
        const a = document.createElement('a');
        a.href = url;
        a.download = `gemini-photo-${Date.now()}.jpg`;
        document.body.appendChild(a);
        a.click();
        window.URL.revokeObjectURL(url);
        document.body.removeChild(a);
    } catch (error) {
        alert("다운로드에 실패했습니다. 이미지를 우클릭하여 저장해 주세요.");
    }
};
</script>

</body>
</html>

0 댓글