SOY NV AI
Add Gemini API integration with REST API support, improve error handling, and add markdown bold formatting for messages
665bcdc
| <html lang="ko"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| <title>SOY NV AI</title> | |
| <link rel="preconnect" href="https://fonts.googleapis.com" crossorigin> | |
| <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&display=swap" rel="stylesheet"> | |
| <style> | |
| * { | |
| margin: 0; | |
| padding: 0; | |
| box-sizing: border-box; | |
| } | |
| :root { | |
| --bg-primary: #ffffff; | |
| --bg-secondary: #f8f9fa; | |
| --bg-tertiary: #f1f3f4; | |
| --text-primary: #202124; | |
| --text-secondary: #5f6368; | |
| --accent: #1a73e8; | |
| --accent-hover: #1557b0; | |
| --border: #dadce0; | |
| --user-bg: #e8f0fe; | |
| --ai-bg: #f1f3f4; | |
| --shadow: rgba(0, 0, 0, 0.1); | |
| } | |
| body { | |
| font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; | |
| background: var(--bg-primary); | |
| color: var(--text-primary); | |
| height: 100vh; | |
| overflow: hidden; | |
| display: flex; | |
| flex-direction: row; | |
| } | |
| /* μ¬μ΄λλ° */ | |
| .sidebar { | |
| width: 280px; | |
| background: var(--bg-secondary); | |
| border-right: 1px solid var(--border); | |
| display: flex; | |
| flex-direction: column; | |
| height: 100vh; | |
| transition: width 0.3s ease; | |
| flex-shrink: 0; | |
| } | |
| .sidebar.collapsed { | |
| width: 0; | |
| overflow: hidden; | |
| border-right: none; | |
| } | |
| .sidebar-header { | |
| padding: 16px; | |
| border-bottom: 1px solid var(--border); | |
| display: flex; | |
| align-items: center; | |
| justify-content: space-between; | |
| min-height: 64px; | |
| } | |
| .sidebar-title { | |
| font-size: 18px; | |
| font-weight: 500; | |
| color: var(--text-primary); | |
| display: flex; | |
| align-items: center; | |
| gap: 8px; | |
| } | |
| .sidebar-toggle { | |
| background: none; | |
| border: none; | |
| padding: 8px; | |
| cursor: pointer; | |
| border-radius: 50%; | |
| color: var(--text-secondary); | |
| transition: background 0.2s; | |
| display: flex; | |
| align-items: center; | |
| justify-content: center; | |
| } | |
| .sidebar-toggle:hover { | |
| background: var(--bg-tertiary); | |
| } | |
| .new-chat-button { | |
| margin: 12px 16px; | |
| padding: 12px 16px; | |
| background: var(--accent); | |
| color: white; | |
| border: none; | |
| border-radius: 24px; | |
| font-size: 14px; | |
| font-weight: 500; | |
| cursor: pointer; | |
| display: flex; | |
| align-items: center; | |
| gap: 8px; | |
| transition: background 0.2s; | |
| } | |
| .new-chat-button:hover { | |
| background: var(--accent-hover); | |
| } | |
| .new-chat-button svg { | |
| width: 18px; | |
| height: 18px; | |
| } | |
| .chat-history { | |
| flex: 1; | |
| overflow-y: auto; | |
| padding: 8px; | |
| } | |
| .chat-history::-webkit-scrollbar { | |
| width: 6px; | |
| } | |
| .chat-history::-webkit-scrollbar-track { | |
| background: transparent; | |
| } | |
| .chat-history::-webkit-scrollbar-thumb { | |
| background: var(--border); | |
| border-radius: 3px; | |
| } | |
| .chat-history::-webkit-scrollbar-thumb:hover { | |
| background: var(--text-secondary); | |
| } | |
| .chat-item { | |
| padding: 12px 16px; | |
| margin: 4px 0; | |
| border-radius: 12px; | |
| cursor: pointer; | |
| transition: background 0.2s; | |
| display: flex; | |
| align-items: center; | |
| gap: 12px; | |
| color: var(--text-primary); | |
| text-decoration: none; | |
| } | |
| .chat-item:hover { | |
| background: var(--bg-tertiary); | |
| } | |
| .chat-item.active { | |
| background: var(--accent); | |
| color: white; | |
| } | |
| .chat-item-icon { | |
| width: 20px; | |
| height: 20px; | |
| flex-shrink: 0; | |
| } | |
| .chat-item-title { | |
| flex: 1; | |
| font-size: 14px; | |
| overflow: hidden; | |
| text-overflow: ellipsis; | |
| white-space: nowrap; | |
| } | |
| .chat-item-time { | |
| font-size: 12px; | |
| opacity: 0.7; | |
| flex-shrink: 0; | |
| } | |
| .chat-item.active .chat-item-time { | |
| opacity: 0.9; | |
| } | |
| /* AI λͺ¨λΈ μ ν μμ */ | |
| .model-selector { | |
| border-top: 1px solid var(--border); | |
| padding: 16px; | |
| background: var(--bg-primary); | |
| } | |
| .model-selector-label { | |
| font-size: 12px; | |
| font-weight: 500; | |
| color: var(--text-secondary); | |
| margin-bottom: 8px; | |
| display: flex; | |
| align-items: center; | |
| gap: 6px; | |
| } | |
| .model-selector-label svg { | |
| width: 16px; | |
| height: 16px; | |
| } | |
| .model-select { | |
| width: 100%; | |
| padding: 10px 12px; | |
| border: 1px solid var(--border); | |
| border-radius: 8px; | |
| background: var(--bg-primary); | |
| color: var(--text-primary); | |
| font-size: 14px; | |
| font-family: inherit; | |
| cursor: pointer; | |
| transition: border-color 0.2s, box-shadow 0.2s; | |
| } | |
| .model-select:focus { | |
| outline: none; | |
| border-color: var(--accent); | |
| box-shadow: 0 0 0 3px rgba(26, 115, 232, 0.1); | |
| } | |
| .model-status { | |
| margin-top: 8px; | |
| font-size: 11px; | |
| color: var(--text-secondary); | |
| display: flex; | |
| align-items: center; | |
| gap: 4px; | |
| } | |
| .model-status.connected { | |
| color: #34a853; | |
| } | |
| .model-status.error { | |
| color: #ea4335; | |
| } | |
| .model-status-dot { | |
| width: 6px; | |
| height: 6px; | |
| border-radius: 50%; | |
| background: currentColor; | |
| } | |
| .refresh-models-btn { | |
| margin-top: 8px; | |
| width: 100%; | |
| padding: 8px; | |
| background: var(--bg-tertiary); | |
| border: 1px solid var(--border); | |
| border-radius: 6px; | |
| color: var(--text-primary); | |
| font-size: 12px; | |
| cursor: pointer; | |
| transition: background 0.2s; | |
| display: flex; | |
| align-items: center; | |
| justify-content: center; | |
| gap: 6px; | |
| } | |
| .refresh-models-btn:hover { | |
| background: var(--border); | |
| } | |
| .refresh-models-btn:disabled { | |
| opacity: 0.5; | |
| cursor: not-allowed; | |
| } | |
| .refresh-models-btn svg { | |
| width: 14px; | |
| height: 14px; | |
| } | |
| /* μΉμμ€ μ ν μμ */ | |
| .novel-selector { | |
| border-top: 1px solid var(--border); | |
| padding: 16px; | |
| background: var(--bg-primary); | |
| max-height: 300px; | |
| display: flex; | |
| flex-direction: column; | |
| } | |
| .novel-selector-label { | |
| font-size: 12px; | |
| font-weight: 500; | |
| color: var(--text-secondary); | |
| margin-bottom: 12px; | |
| display: flex; | |
| align-items: center; | |
| gap: 6px; | |
| } | |
| .novel-selector-label svg { | |
| width: 16px; | |
| height: 16px; | |
| } | |
| .novel-list { | |
| max-height: 200px; | |
| overflow-y: auto; | |
| display: flex; | |
| flex-direction: column; | |
| gap: 8px; | |
| } | |
| .novel-list::-webkit-scrollbar { | |
| width: 4px; | |
| } | |
| .novel-list::-webkit-scrollbar-thumb { | |
| background: var(--border); | |
| border-radius: 2px; | |
| } | |
| .novel-item { | |
| display: flex; | |
| align-items: center; | |
| gap: 8px; | |
| padding: 8px; | |
| background: var(--bg-secondary); | |
| border-radius: 6px; | |
| cursor: pointer; | |
| transition: background 0.2s; | |
| } | |
| .novel-item:hover { | |
| background: var(--bg-tertiary); | |
| } | |
| .novel-item input[type="checkbox"] { | |
| width: 16px; | |
| height: 16px; | |
| cursor: pointer; | |
| flex-shrink: 0; | |
| } | |
| .novel-item-name { | |
| flex: 1; | |
| font-size: 12px; | |
| color: var(--text-primary); | |
| overflow: hidden; | |
| text-overflow: ellipsis; | |
| white-space: nowrap; | |
| } | |
| .novel-item-empty { | |
| padding: 16px; | |
| text-align: center; | |
| color: var(--text-secondary); | |
| font-size: 12px; | |
| } | |
| .selected-novels-info { | |
| margin-top: 8px; | |
| font-size: 11px; | |
| color: var(--text-secondary); | |
| } | |
| .selected-novels-info.has-selection { | |
| color: var(--accent); | |
| } | |
| /* λ‘κ·Έμμ λ²νΌ */ | |
| .sidebar-footer { | |
| border-top: 1px solid var(--border); | |
| padding: 16px; | |
| background: var(--bg-primary); | |
| margin-top: auto; | |
| } | |
| .logout-button { | |
| width: 100%; | |
| padding: 12px 16px; | |
| background: var(--bg-secondary); | |
| border: 1px solid var(--border); | |
| border-radius: 8px; | |
| color: var(--text-primary); | |
| font-size: 14px; | |
| font-weight: 500; | |
| cursor: pointer; | |
| transition: background 0.2s, border-color 0.2s; | |
| display: flex; | |
| align-items: center; | |
| justify-content: center; | |
| gap: 8px; | |
| text-decoration: none; | |
| } | |
| .logout-button:hover { | |
| background: var(--bg-tertiary); | |
| border-color: var(--accent); | |
| } | |
| .logout-button svg { | |
| width: 18px; | |
| height: 18px; | |
| } | |
| /* λ©μΈ μ½ν μΈ μμ */ | |
| .main-content { | |
| flex: 1; | |
| display: flex; | |
| flex-direction: column; | |
| height: 100vh; | |
| overflow: hidden; | |
| } | |
| /* ν€λ */ | |
| .header { | |
| background: var(--bg-primary); | |
| border-bottom: 1px solid var(--border); | |
| padding: 16px 24px; | |
| display: flex; | |
| align-items: center; | |
| justify-content: space-between; | |
| box-shadow: 0 1px 2px var(--shadow); | |
| z-index: 10; | |
| } | |
| .header-title { | |
| font-size: 20px; | |
| font-weight: 500; | |
| color: var(--text-primary); | |
| display: flex; | |
| align-items: center; | |
| gap: 12px; | |
| } | |
| .header-title::before { | |
| content: 'π€'; | |
| font-size: 24px; | |
| } | |
| .header-actions { | |
| display: flex; | |
| gap: 12px; | |
| align-items: center; | |
| } | |
| .btn-icon { | |
| background: none; | |
| border: none; | |
| padding: 8px; | |
| cursor: pointer; | |
| border-radius: 50%; | |
| color: var(--text-secondary); | |
| transition: background 0.2s; | |
| display: flex; | |
| align-items: center; | |
| justify-content: center; | |
| } | |
| .btn-icon:hover { | |
| background: var(--bg-tertiary); | |
| } | |
| /* μ±ν μμ */ | |
| .chat-container { | |
| flex: 1; | |
| overflow-y: auto; | |
| padding: 24px; | |
| display: flex; | |
| flex-direction: column; | |
| gap: 16px; | |
| background: var(--bg-primary); | |
| } | |
| .chat-container::-webkit-scrollbar { | |
| width: 8px; | |
| } | |
| .chat-container::-webkit-scrollbar-track { | |
| background: transparent; | |
| } | |
| .chat-container::-webkit-scrollbar-thumb { | |
| background: var(--border); | |
| border-radius: 4px; | |
| } | |
| .chat-container::-webkit-scrollbar-thumb:hover { | |
| background: var(--text-secondary); | |
| } | |
| /* λ©μμ§ */ | |
| .message { | |
| display: flex; | |
| gap: 12px; | |
| max-width: 800px; | |
| animation: fadeIn 0.3s ease-in; | |
| } | |
| @keyframes fadeIn { | |
| from { | |
| opacity: 0; | |
| transform: translateY(10px); | |
| } | |
| to { | |
| opacity: 1; | |
| transform: translateY(0); | |
| } | |
| } | |
| .message.user { | |
| align-self: flex-end; | |
| flex-direction: row-reverse; | |
| } | |
| .message-avatar { | |
| width: 32px; | |
| height: 32px; | |
| border-radius: 50%; | |
| display: flex; | |
| align-items: center; | |
| justify-content: center; | |
| font-size: 16px; | |
| flex-shrink: 0; | |
| } | |
| .message.user .message-avatar { | |
| background: var(--accent); | |
| color: white; | |
| } | |
| .message.ai .message-avatar { | |
| background: var(--bg-tertiary); | |
| color: var(--text-primary); | |
| } | |
| .message-content { | |
| flex: 1; | |
| display: flex; | |
| flex-direction: column; | |
| gap: 4px; | |
| } | |
| .message-bubble { | |
| padding: 12px 16px; | |
| border-radius: 18px; | |
| line-height: 1.5; | |
| font-size: 15px; | |
| word-wrap: break-word; | |
| white-space: pre-wrap; | |
| } | |
| .message-bubble strong { | |
| font-weight: 700; | |
| } | |
| .message.user .message-bubble { | |
| background: var(--accent); | |
| color: white; | |
| border-bottom-right-radius: 4px; | |
| } | |
| .message.ai .message-bubble { | |
| background: var(--ai-bg); | |
| color: var(--text-primary); | |
| border-bottom-left-radius: 4px; | |
| } | |
| .message-time { | |
| font-size: 12px; | |
| color: var(--text-secondary); | |
| padding: 0 4px; | |
| } | |
| .message.user .message-time { | |
| text-align: right; | |
| } | |
| /* μ λ ₯ μμ */ | |
| .input-container { | |
| background: var(--bg-primary); | |
| border-top: 1px solid var(--border); | |
| padding: 16px 24px; | |
| display: flex; | |
| align-items: flex-end; | |
| gap: 12px; | |
| } | |
| .input-wrapper { | |
| flex: 1; | |
| position: relative; | |
| background: var(--bg-secondary); | |
| border: 1px solid var(--border); | |
| border-radius: 24px; | |
| padding: 12px 16px; | |
| display: flex; | |
| align-items: center; | |
| gap: 8px; | |
| transition: border-color 0.2s, box-shadow 0.2s; | |
| } | |
| .input-wrapper:focus-within { | |
| border-color: var(--accent); | |
| box-shadow: 0 0 0 3px rgba(26, 115, 232, 0.1); | |
| } | |
| #messageInput { | |
| flex: 1; | |
| border: none; | |
| outline: none; | |
| background: transparent; | |
| font-size: 15px; | |
| font-family: inherit; | |
| color: var(--text-primary); | |
| resize: none; | |
| max-height: 200px; | |
| min-height: 24px; | |
| line-height: 1.5; | |
| } | |
| #messageInput::placeholder { | |
| color: var(--text-secondary); | |
| } | |
| .send-button { | |
| background: var(--accent); | |
| color: white; | |
| border: none; | |
| border-radius: 50%; | |
| width: 40px; | |
| height: 40px; | |
| display: flex; | |
| align-items: center; | |
| justify-content: center; | |
| cursor: pointer; | |
| transition: background 0.2s, transform 0.1s; | |
| flex-shrink: 0; | |
| } | |
| .send-button:hover:not(:disabled) { | |
| background: var(--accent-hover); | |
| transform: scale(1.05); | |
| } | |
| .send-button:disabled { | |
| opacity: 0.5; | |
| cursor: not-allowed; | |
| } | |
| .send-button svg { | |
| width: 20px; | |
| height: 20px; | |
| } | |
| /* λ‘λ© μΈλμΌμ΄ν° */ | |
| .typing-indicator { | |
| display: flex; | |
| gap: 4px; | |
| padding: 12px 16px; | |
| background: var(--ai-bg); | |
| border-radius: 18px; | |
| border-bottom-left-radius: 4px; | |
| width: fit-content; | |
| } | |
| .typing-dot { | |
| width: 8px; | |
| height: 8px; | |
| border-radius: 50%; | |
| background: var(--text-secondary); | |
| animation: typing 1.4s infinite; | |
| } | |
| .typing-dot:nth-child(2) { | |
| animation-delay: 0.2s; | |
| } | |
| .typing-dot:nth-child(3) { | |
| animation-delay: 0.4s; | |
| } | |
| @keyframes typing { | |
| 0%, 60%, 100% { | |
| transform: translateY(0); | |
| opacity: 0.7; | |
| } | |
| 30% { | |
| transform: translateY(-10px); | |
| opacity: 1; | |
| } | |
| } | |
| /* λΉ μν */ | |
| .empty-state { | |
| flex: 1; | |
| display: flex; | |
| flex-direction: column; | |
| align-items: center; | |
| justify-content: center; | |
| text-align: center; | |
| padding: 48px 24px; | |
| color: var(--text-secondary); | |
| } | |
| .empty-state-icon { | |
| font-size: 64px; | |
| margin-bottom: 16px; | |
| opacity: 0.5; | |
| } | |
| .empty-state-title { | |
| font-size: 24px; | |
| font-weight: 500; | |
| color: var(--text-primary); | |
| margin-bottom: 8px; | |
| } | |
| .empty-state-description { | |
| font-size: 15px; | |
| max-width: 500px; | |
| } | |
| /* λ°μν */ | |
| @media (max-width: 768px) { | |
| .sidebar { | |
| position: fixed; | |
| left: 0; | |
| top: 0; | |
| z-index: 1000; | |
| box-shadow: 2px 0 8px var(--shadow); | |
| } | |
| .sidebar.collapsed { | |
| width: 0; | |
| } | |
| .main-content { | |
| width: 100%; | |
| } | |
| .header { | |
| padding: 12px 16px; | |
| } | |
| .header-title { | |
| font-size: 18px; | |
| } | |
| .chat-container { | |
| padding: 16px; | |
| } | |
| .message { | |
| max-width: 100%; | |
| } | |
| .input-container { | |
| padding: 12px 16px; | |
| } | |
| } | |
| </style> | |
| </head> | |
| <body> | |
| <!-- μ¬μ΄λλ° --> | |
| <div class="sidebar" id="sidebar"> | |
| <div class="sidebar-header"> | |
| <div class="sidebar-title"> | |
| <span>π€</span> | |
| <span>SOY NV AI</span> | |
| </div> | |
| <button class="sidebar-toggle" onclick="toggleSidebar()" title="μ¬μ΄λλ° μ κΈ°"> | |
| <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> | |
| <path d="M9 18l-6-6 6-6M21 18l-6-6 6-6"/> | |
| </svg> | |
| </button> | |
| </div> | |
| <button class="new-chat-button" onclick="startNewChat()"> | |
| <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> | |
| <path d="M12 5v14M5 12h14"/> | |
| </svg> | |
| μ λν | |
| </button> | |
| <div class="chat-history" id="chatHistory"> | |
| <!-- λν νμ€ν 리 νλͺ©λ€μ΄ μ¬κΈ°μ λμ μΌλ‘ μΆκ°λ©λλ€ --> | |
| </div> | |
| <!-- AI λͺ¨λΈ μ ν μμ --> | |
| <div class="model-selector"> | |
| <div class="model-selector-label"> | |
| <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> | |
| <path d="M12 2L2 7l10 5 10-5-10-5zM2 17l10 5 10-5M2 12l10 5 10-5"/> | |
| </svg> | |
| λ‘컬 AI λͺ¨λΈ | |
| </div> | |
| <select class="model-select" id="modelSelect"> | |
| <option value="">λͺ¨λΈμ μ ννμΈμ...</option> | |
| </select> | |
| <div class="model-status" id="modelStatus"> | |
| <span class="model-status-dot"></span> | |
| <span>μ°κ²° μ λ¨</span> | |
| </div> | |
| <button class="refresh-models-btn" id="refreshModelsBtn" onclick="loadModels()"> | |
| <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> | |
| <path d="M23 4v6h-6M1 20v-6h6M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"/> | |
| </svg> | |
| λͺ¨λΈ μλ‘κ³ μΉ¨ | |
| </button> | |
| </div> | |
| <!-- μΉμμ€ μ ν μμ --> | |
| <div class="novel-selector"> | |
| <div class="novel-selector-label"> | |
| <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> | |
| <path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8zM14 2v6h6M16 13H8M16 17H8M10 9H8"/> | |
| </svg> | |
| νμ΅ν μΉμμ€ μ ν | |
| </div> | |
| <div class="novel-list" id="novelList"> | |
| <div class="novel-item-empty">λͺ¨λΈμ μ ννλ©΄ μΉμμ€ λͺ©λ‘μ΄ νμλ©λλ€</div> | |
| </div> | |
| <div class="selected-novels-info" id="selectedNovelsInfo"></div> | |
| </div> | |
| <!-- λ‘κ·Έμμ λ²νΌ --> | |
| <div class="sidebar-footer"> | |
| <a href="{{ url_for('main.logout') }}" class="logout-button" title="λ‘κ·Έμμ"> | |
| <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> | |
| <path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4M16 17l5-5-5-5M21 12H9"/> | |
| </svg> | |
| <span>λ‘κ·Έμμ</span> | |
| </a> | |
| </div> | |
| </div> | |
| <!-- λ©μΈ μ½ν μΈ --> | |
| <div class="main-content"> | |
| <!-- ν€λ --> | |
| <div class="header"> | |
| <div class="header-title"> | |
| <button class="btn-icon" onclick="toggleSidebar()" title="μ¬μ΄λλ° μ΄κΈ°" id="sidebarToggleBtn" style="display: none;"> | |
| <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> | |
| <path d="M3 12h18M3 6h18M3 18h18"/> | |
| </svg> | |
| </button> | |
| <span>SOY NV AI</span> | |
| </div> | |
| <div class="header-actions"> | |
| {% if current_user.is_admin %} | |
| <a href="{{ url_for('main.admin') }}" class="btn-icon" title="κ΄λ¦¬μ νμ΄μ§" style="text-decoration: none; color: var(--text-secondary);"> | |
| <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> | |
| <path d="M12 15v2m-6 4h12a2 2 0 0 0 2-2v-6a2 2 0 0 0-2-2H6a2 2 0 0 0-2 2v6a2 2 0 0 0 2 2zm10-10V7a4 4 0 0 0-8 0v4h8z"/> | |
| </svg> | |
| </a> | |
| {% endif %} | |
| <span style="margin-right: 8px; font-size: 14px; color: var(--text-secondary);">{{ current_user.nickname or current_user.username }}</span> | |
| <a href="{{ url_for('main.logout') }}" class="btn-icon" title="λ‘κ·Έμμ" style="text-decoration: none; color: var(--text-secondary);"> | |
| <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> | |
| <path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4M16 17l5-5-5-5M21 12H9"/> | |
| </svg> | |
| </a> | |
| <button class="btn-icon" onclick="clearChat()" title="λν μ΄κΈ°ν"> | |
| <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> | |
| <path d="M3 6h18M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2m3 0v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6h14z"/> | |
| </svg> | |
| </button> | |
| </div> | |
| </div> | |
| <!-- μ±ν μμ --> | |
| <div class="chat-container" id="chatContainer"> | |
| <div class="empty-state" id="emptyState"> | |
| <div class="empty-state-icon">π¬</div> | |
| <div class="empty-state-title">SOY NV AIμ μ€μ κ²μ νμν©λλ€</div> | |
| <div class="empty-state-description"> | |
| 무μμ΄λ λ¬Όμ΄λ³΄μΈμ. AIκ° λμλλ¦¬κ² μ΅λλ€. | |
| </div> | |
| </div> | |
| </div> | |
| <!-- μ λ ₯ μμ --> | |
| <div class="input-container"> | |
| <div class="input-wrapper"> | |
| <textarea | |
| id="messageInput" | |
| placeholder="λ©μμ§λ₯Ό μ λ ₯νμΈμ..." | |
| rows="1" | |
| ></textarea> | |
| <button class="send-button" id="sendButton" onclick="sendMessage()"> | |
| <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> | |
| <path d="M22 2L11 13M22 2l-7 20-4-9-9-4 20-7z"/> | |
| </svg> | |
| </button> | |
| </div> | |
| </div> | |
| </div> | |
| <script> | |
| const chatContainer = document.getElementById('chatContainer'); | |
| const messageInput = document.getElementById('messageInput'); | |
| const sendButton = document.getElementById('sendButton'); | |
| const emptyState = document.getElementById('emptyState'); | |
| const sidebar = document.getElementById('sidebar'); | |
| const chatHistory = document.getElementById('chatHistory'); | |
| const sidebarToggleBtn = document.getElementById('sidebarToggleBtn'); | |
| const modelSelect = document.getElementById('modelSelect'); | |
| const modelStatus = document.getElementById('modelStatus'); | |
| const refreshModelsBtn = document.getElementById('refreshModelsBtn'); | |
| let currentChatId = null; | |
| let currentSessionId = null; | |
| let chatSessions = []; | |
| let selectedModel = localStorage.getItem('selectedModel') || ''; | |
| let selectedFileIds = JSON.parse(localStorage.getItem('selectedFileIds') || '[]'); | |
| const novelList = document.getElementById('novelList'); | |
| const selectedNovelsInfo = document.getElementById('selectedNovelsInfo'); | |
| // λͺ¨λΈ μ ν μ΄λ²€νΈ | |
| modelSelect.addEventListener('change', function() { | |
| selectedModel = this.value; | |
| localStorage.setItem('selectedModel', selectedModel); | |
| updateModelStatus(); | |
| loadNovels(); // λͺ¨λΈ λ³κ²½ μ μΉμμ€ λͺ©λ‘ λ‘λ | |
| }); | |
| // μΉμμ€ λͺ©λ‘ λ‘λ | |
| async function loadNovels() { | |
| if (!selectedModel) { | |
| novelList.innerHTML = '<div class="novel-item-empty">λͺ¨λΈμ μ ννλ©΄ μΉμμ€ λͺ©λ‘μ΄ νμλ©λλ€</div>'; | |
| selectedNovelsInfo.textContent = ''; | |
| return; | |
| } | |
| try { | |
| const response = await fetch(`/api/files?model_name=${encodeURIComponent(selectedModel)}`); | |
| const data = await response.json(); | |
| novelList.innerHTML = ''; | |
| if (data.files && data.files.length > 0) { | |
| data.files.forEach(file => { | |
| const novelItem = document.createElement('div'); | |
| novelItem.className = 'novel-item'; | |
| const checkbox = document.createElement('input'); | |
| checkbox.type = 'checkbox'; | |
| checkbox.id = `novel-${file.id}`; | |
| checkbox.value = file.id; | |
| checkbox.checked = selectedFileIds.includes(file.id); | |
| checkbox.addEventListener('change', updateSelectedNovels); | |
| const label = document.createElement('label'); | |
| label.className = 'novel-item-name'; | |
| label.htmlFor = `novel-${file.id}`; | |
| label.textContent = file.original_filename; | |
| label.title = file.original_filename; | |
| novelItem.appendChild(checkbox); | |
| novelItem.appendChild(label); | |
| novelList.appendChild(novelItem); | |
| }); | |
| updateSelectedNovelsInfo(); | |
| } else { | |
| novelList.innerHTML = '<div class="novel-item-empty">μ λ‘λλ μΉμμ€μ΄ μμ΅λλ€</div>'; | |
| selectedNovelsInfo.textContent = ''; | |
| } | |
| } catch (error) { | |
| console.error('μΉμμ€ λͺ©λ‘ λ‘λ μ€λ₯:', error); | |
| novelList.innerHTML = '<div class="novel-item-empty">μΉμμ€ λͺ©λ‘μ λΆλ¬μ¬ μ μμ΅λλ€</div>'; | |
| } | |
| } | |
| // μ νλ μΉμμ€ μ λ°μ΄νΈ | |
| function updateSelectedNovels() { | |
| const checkboxes = novelList.querySelectorAll('input[type="checkbox"]'); | |
| selectedFileIds = Array.from(checkboxes) | |
| .filter(cb => cb.checked) | |
| .map(cb => parseInt(cb.value)); | |
| localStorage.setItem('selectedFileIds', JSON.stringify(selectedFileIds)); | |
| updateSelectedNovelsInfo(); | |
| } | |
| // μ νλ μΉμμ€ μ 보 νμ | |
| function updateSelectedNovelsInfo() { | |
| if (selectedFileIds.length === 0) { | |
| selectedNovelsInfo.textContent = 'μ νλ μΉμμ€ μμ (λͺ¨λ μΉμμ€ μ¬μ©)'; | |
| selectedNovelsInfo.className = 'selected-novels-info'; | |
| } else { | |
| const count = selectedFileIds.length; | |
| selectedNovelsInfo.textContent = `${count}κ° μΉμμ€ μ νλ¨`; | |
| selectedNovelsInfo.className = 'selected-novels-info has-selection'; | |
| } | |
| } | |
| // λͺ¨λΈ λͺ©λ‘ λ‘λ | |
| async function loadModels() { | |
| refreshModelsBtn.disabled = true; | |
| refreshModelsBtn.innerHTML = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M23 4v6h-6M1 20v-6h6M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"/></svg> λ‘λ© μ€...'; | |
| try { | |
| const response = await fetch('/api/ollama/models'); | |
| const data = await response.json(); | |
| modelSelect.innerHTML = '<option value="">λͺ¨λΈμ μ ννμΈμ...</option>'; | |
| if (data.models && data.models.length > 0) { | |
| data.models.forEach(model => { | |
| const option = document.createElement('option'); | |
| option.value = model.name; | |
| option.textContent = model.name; | |
| if (model.name === selectedModel) { | |
| option.selected = true; | |
| } | |
| modelSelect.appendChild(option); | |
| }); | |
| updateModelStatus('connected'); | |
| } else { | |
| updateModelStatus('error', 'μ¬μ© κ°λ₯ν λͺ¨λΈμ΄ μμ΅λλ€'); | |
| } | |
| } catch (error) { | |
| updateModelStatus('error', 'Ollama μ°κ²° μ€ν¨'); | |
| console.error('λͺ¨λΈ λ‘λ μ€λ₯:', error); | |
| } finally { | |
| refreshModelsBtn.disabled = false; | |
| refreshModelsBtn.innerHTML = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M23 4v6h-6M1 20v-6h6M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"/></svg> λͺ¨λΈ μλ‘κ³ μΉ¨'; | |
| } | |
| } | |
| // λͺ¨λΈ μν μ λ°μ΄νΈ | |
| function updateModelStatus(status = 'disconnected', message = '') { | |
| modelStatus.className = 'model-status'; | |
| if (status === 'connected') { | |
| modelStatus.classList.add('connected'); | |
| modelStatus.innerHTML = '<span class="model-status-dot"></span><span>μ°κ²°λ¨</span>'; | |
| } else if (status === 'error') { | |
| modelStatus.classList.add('error'); | |
| modelStatus.innerHTML = `<span class="model-status-dot"></span><span>${message || 'μ€λ₯'}</span>`; | |
| } else { | |
| modelStatus.innerHTML = '<span class="model-status-dot"></span><span>μ°κ²° μ λ¨</span>'; | |
| } | |
| } | |
| // μ¬μ΄λλ° ν κΈ | |
| function toggleSidebar() { | |
| sidebar.classList.toggle('collapsed'); | |
| const isCollapsed = sidebar.classList.contains('collapsed'); | |
| if (window.innerWidth <= 768) { | |
| sidebarToggleBtn.style.display = isCollapsed ? 'flex' : 'none'; | |
| } | |
| // μ¬μ΄λλ° λ΄λΆ ν κΈ λ²νΌ μμ΄μ½ μ λ°μ΄νΈ | |
| const sidebarToggle = sidebar.querySelector('.sidebar-toggle'); | |
| if (sidebarToggle) { | |
| sidebarToggle.innerHTML = isCollapsed ? | |
| '<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 12h18M3 6h18M3 18h18"/></svg>' : | |
| '<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 18l-6-6 6-6M21 18l-6-6 6-6"/></svg>'; | |
| } | |
| } | |
| // λ°μν μ¬μ΄λλ° μ²λ¦¬ | |
| function handleResize() { | |
| if (window.innerWidth <= 768) { | |
| sidebar.classList.add('collapsed'); | |
| sidebarToggleBtn.style.display = 'flex'; | |
| } else { | |
| sidebar.classList.remove('collapsed'); | |
| sidebarToggleBtn.style.display = 'none'; | |
| } | |
| } | |
| window.addEventListener('resize', handleResize); | |
| handleResize(); | |
| // μ λν μμ | |
| async function startNewChat() { | |
| if (confirm('μ λνλ₯Ό μμνμκ² μ΅λκΉ? νμ¬ λνλ μ μ₯λ©λλ€.')) { | |
| clearChat(); | |
| currentChatId = null; | |
| currentSessionId = null; | |
| await loadChatHistory(); | |
| } | |
| } | |
| // λν νμ€ν 리 λ‘λ (DBμμ μ΅κ·Ό 20κ°λ§) | |
| async function loadChatHistory() { | |
| chatHistory.innerHTML = '<div style="padding: 16px; text-align: center; color: var(--text-secondary); font-size: 14px;">λ‘λ© μ€...</div>'; | |
| try { | |
| const response = await fetch('/api/chat/sessions'); | |
| const data = await response.json(); | |
| chatHistory.innerHTML = ''; | |
| chatSessions = data.sessions || []; | |
| if (chatSessions.length === 0) { | |
| const emptyMsg = document.createElement('div'); | |
| emptyMsg.style.padding = '16px'; | |
| emptyMsg.style.textAlign = 'center'; | |
| emptyMsg.style.color = 'var(--text-secondary)'; | |
| emptyMsg.style.fontSize = '14px'; | |
| emptyMsg.textContent = 'λν κΈ°λ‘μ΄ μμ΅λλ€'; | |
| chatHistory.appendChild(emptyMsg); | |
| return; | |
| } | |
| chatSessions.forEach((session) => { | |
| const chatItem = document.createElement('div'); | |
| chatItem.className = 'chat-item'; | |
| if (session.id === currentSessionId) { | |
| chatItem.classList.add('active'); | |
| } | |
| chatItem.innerHTML = ` | |
| <svg class="chat-item-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> | |
| <path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/> | |
| </svg> | |
| <div class="chat-item-title">${session.title || 'μ λν'}</div> | |
| <div class="chat-item-time">${formatTime(session.updated_at)}</div> | |
| `; | |
| chatItem.onclick = () => loadChat(session.id); | |
| chatHistory.appendChild(chatItem); | |
| }); | |
| } catch (error) { | |
| console.error('λν νμ€ν 리 λ‘λ μ€λ₯:', error); | |
| chatHistory.innerHTML = '<div style="padding: 16px; text-align: center; color: var(--text-secondary); font-size: 14px;">λν κΈ°λ‘μ λΆλ¬μ¬ μ μμ΅λλ€</div>'; | |
| } | |
| } | |
| // μκ° ν¬λ§· | |
| function formatTime(timestamp) { | |
| const date = new Date(timestamp); | |
| const now = new Date(); | |
| const diff = now - date; | |
| const minutes = Math.floor(diff / 60000); | |
| const hours = Math.floor(diff / 3600000); | |
| const days = Math.floor(diff / 86400000); | |
| if (minutes < 1) return 'λ°©κΈ'; | |
| if (minutes < 60) return `${minutes}λΆ μ `; | |
| if (hours < 24) return `${hours}μκ° μ `; | |
| if (days < 7) return `${days}μΌ μ `; | |
| return date.toLocaleDateString('ko-KR', { month: 'short', day: 'numeric' }); | |
| } | |
| // λν λ‘λ | |
| async function loadChat(sessionId) { | |
| try { | |
| const response = await fetch(`/api/chat/sessions/${sessionId}`); | |
| const data = await response.json(); | |
| if (!data.session) return; | |
| currentSessionId = sessionId; | |
| currentChatId = sessionId; | |
| chatContainer.innerHTML = ''; | |
| if (data.session.messages && data.session.messages.length > 0) { | |
| data.session.messages.forEach(msg => { | |
| addMessage(msg.role, msg.content, false); | |
| }); | |
| } else { | |
| if (emptyState) { | |
| emptyState.style.display = 'flex'; | |
| } | |
| } | |
| await loadChatHistory(); | |
| if (window.innerWidth <= 768) { | |
| sidebar.classList.add('collapsed'); | |
| } | |
| } catch (error) { | |
| console.error('λν λ‘λ μ€λ₯:', error); | |
| alert('λνλ₯Ό λΆλ¬μ¬ μ μμ΅λλ€.'); | |
| } | |
| } | |
| // μ λν μΈμ μμ± | |
| async function createNewSession() { | |
| try { | |
| const response = await fetch('/api/chat/sessions', { | |
| method: 'POST', | |
| headers: { | |
| 'Content-Type': 'application/json', | |
| }, | |
| body: JSON.stringify({ | |
| title: 'μ λν', | |
| model_name: selectedModel || null | |
| }) | |
| }); | |
| const data = await response.json(); | |
| if (response.ok && data.session) { | |
| currentSessionId = data.session.id; | |
| currentChatId = data.session.id; | |
| await loadChatHistory(); | |
| return data.session.id; | |
| } | |
| } catch (error) { | |
| console.error('μΈμ μμ± μ€λ₯:', error); | |
| } | |
| return null; | |
| } | |
| // μλ λμ΄ μ‘°μ | |
| messageInput.addEventListener('input', function() { | |
| this.style.height = 'auto'; | |
| this.style.height = Math.min(this.scrollHeight, 200) + 'px'; | |
| }); | |
| // Enter ν€ μ²λ¦¬ | |
| messageInput.addEventListener('keydown', function(e) { | |
| if (e.key === 'Enter' && !e.shiftKey) { | |
| e.preventDefault(); | |
| sendMessage(); | |
| } | |
| }); | |
| // Markdown μ€νμΌ κ°μ‘° νμλ₯Ό HTMLλ‘ λ³ν | |
| function formatMessageText(text) { | |
| if (!text) return ''; | |
| // HTML νΉμλ¬Έμ μ΄μ€μΌμ΄ν | |
| let html = text | |
| .replace(/&/g, '&') | |
| .replace(/</g, '<') | |
| .replace(/>/g, '>'); | |
| // **ν μ€νΈ** ν¨ν΄μ <strong>ν μ€νΈ</strong>λ‘ λ³ν | |
| // λ¨, ** μ¬μ΄μ λ΄μ©μ΄ μμ΄μΌ ν¨ | |
| html = html.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>'); | |
| // μ€λ°κΏ μ²λ¦¬ | |
| html = html.replace(/\n/g, '<br>'); | |
| return html; | |
| } | |
| // λ©μμ§ μΆκ° | |
| function addMessage(role, content, save = true) { | |
| // λΉ μν μ¨κΈ°κΈ° | |
| if (emptyState) { | |
| emptyState.style.display = 'none'; | |
| } | |
| const messageDiv = document.createElement('div'); | |
| messageDiv.className = `message ${role}`; | |
| const avatar = document.createElement('div'); | |
| avatar.className = 'message-avatar'; | |
| avatar.textContent = role === 'user' ? 'π€' : 'π€'; | |
| const contentDiv = document.createElement('div'); | |
| contentDiv.className = 'message-content'; | |
| const bubble = document.createElement('div'); | |
| bubble.className = 'message-bubble'; | |
| bubble.innerHTML = formatMessageText(content); | |
| const time = document.createElement('div'); | |
| time.className = 'message-time'; | |
| time.textContent = new Date().toLocaleTimeString('ko-KR', { | |
| hour: '2-digit', | |
| minute: '2-digit' | |
| }); | |
| contentDiv.appendChild(bubble); | |
| contentDiv.appendChild(time); | |
| messageDiv.appendChild(avatar); | |
| messageDiv.appendChild(contentDiv); | |
| chatContainer.appendChild(messageDiv); | |
| // μ€ν¬λ‘€μ 맨 μλλ‘ | |
| chatContainer.scrollTop = chatContainer.scrollHeight; | |
| } | |
| // νμ΄ν μΈλμΌμ΄ν° νμ | |
| function showTypingIndicator() { | |
| const messageDiv = document.createElement('div'); | |
| messageDiv.className = 'message ai'; | |
| messageDiv.id = 'typingIndicator'; | |
| const avatar = document.createElement('div'); | |
| avatar.className = 'message-avatar'; | |
| avatar.textContent = 'π€'; | |
| const contentDiv = document.createElement('div'); | |
| contentDiv.className = 'message-content'; | |
| const typingDiv = document.createElement('div'); | |
| typingDiv.className = 'typing-indicator'; | |
| for (let i = 0; i < 3; i++) { | |
| const dot = document.createElement('div'); | |
| dot.className = 'typing-dot'; | |
| typingDiv.appendChild(dot); | |
| } | |
| contentDiv.appendChild(typingDiv); | |
| messageDiv.appendChild(avatar); | |
| messageDiv.appendChild(contentDiv); | |
| chatContainer.appendChild(messageDiv); | |
| chatContainer.scrollTop = chatContainer.scrollHeight; | |
| } | |
| // νμ΄ν μΈλμΌμ΄ν° μ κ±° | |
| function removeTypingIndicator() { | |
| const indicator = document.getElementById('typingIndicator'); | |
| if (indicator) { | |
| indicator.remove(); | |
| } | |
| } | |
| // λ©μμ§ μ μ‘ | |
| async function sendMessage() { | |
| const message = messageInput.value.trim(); | |
| if (!message) return; | |
| // μΈμ μ΄ μμΌλ©΄ μλ‘ μμ± | |
| if (!currentSessionId) { | |
| currentSessionId = await createNewSession(); | |
| } | |
| // μ¬μ©μ λ©μμ§ νμ (DB μ μ₯μ /api/chatμμ μ²λ¦¬) | |
| addMessage('user', message, false); | |
| messageInput.value = ''; | |
| messageInput.style.height = 'auto'; | |
| // μ λ ₯ λΉνμ±ν | |
| messageInput.disabled = true; | |
| sendButton.disabled = true; | |
| // νμ΄ν μΈλμΌμ΄ν° νμ | |
| showTypingIndicator(); | |
| try { | |
| // API νΈμΆ | |
| const response = await fetch('/api/chat', { | |
| method: 'POST', | |
| headers: { | |
| 'Content-Type': 'application/json', | |
| }, | |
| body: JSON.stringify({ | |
| message: message, | |
| model: selectedModel || null, | |
| file_ids: selectedFileIds.length > 0 ? selectedFileIds : [], | |
| session_id: currentSessionId | |
| }) | |
| }); | |
| removeTypingIndicator(); | |
| if (response.ok) { | |
| const data = await response.json(); | |
| const aiResponse = data.response || 'μλ΅μ μμ±νλ μ€ μ€λ₯κ° λ°μνμ΅λλ€.'; | |
| addMessage('ai', aiResponse, false); | |
| // DBμ AI μλ΅ μ μ₯ (μ΄λ―Έ λ°±μλμμ μ μ₯λ¨) | |
| // μΈμ λͺ©λ‘ μλ‘κ³ μΉ¨ (μ λͺ© μ λ°μ΄νΈ λ°μ) | |
| if (data.session && data.session.title) { | |
| // μΈμ μ λͺ©μ΄ μ λ°μ΄νΈλμμΌλ©΄ λͺ©λ‘ μλ‘κ³ μΉ¨ | |
| await loadChatHistory(); | |
| } else { | |
| // μΈμ μ λ³΄κ° μμ΄λ λͺ©λ‘ μλ‘κ³ μΉ¨ | |
| await loadChatHistory(); | |
| } | |
| } else { | |
| const error = await response.json().catch(() => ({ error: 'μλ² μ€λ₯' })); | |
| addMessage('ai', `μ€λ₯: ${error.error || 'μ μ μλ μ€λ₯κ° λ°μνμ΅λλ€.'}`, false); | |
| } | |
| } catch (error) { | |
| removeTypingIndicator(); | |
| addMessage('ai', `μ°κ²° μ€λ₯: ${error.message}`, false); | |
| } finally { | |
| // μ μ‘ μν ν΄μ | |
| isSending = false; | |
| // μ λ ₯ νμ±ν | |
| messageInput.disabled = false; | |
| sendButton.disabled = false; | |
| messageInput.focus(); | |
| } | |
| } | |
| // λν μ΄κΈ°ν | |
| function clearChat() { | |
| chatContainer.innerHTML = ''; | |
| currentChatId = null; | |
| currentSessionId = null; | |
| if (emptyState) { | |
| emptyState.style.display = 'flex'; | |
| } | |
| } | |
| // νμ΄μ§ λ‘λ μ μ΄κΈ°ν | |
| window.addEventListener('load', async () => { | |
| await loadChatHistory(); | |
| await loadModels(); | |
| if (selectedModel) { | |
| loadNovels(); | |
| } | |
| messageInput.focus(); | |
| }); | |
| </script> | |
| </body> | |
| </html> | |