soyailabs / templates /index.html
SOY NV AI
Add Gemini API integration with REST API support, improve error handling, and add markdown bold formatting for messages
665bcdc
raw
history blame
47.7 kB
<!DOCTYPE html>
<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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
// **ν…μŠ€νŠΈ** νŒ¨ν„΄μ„ <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>