|
|
<!DOCTYPE html> |
|
|
<html lang="ko"> |
|
|
<head> |
|
|
<meta charset="UTF-8"> |
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0"> |
|
|
<title>SOY 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: 64px; |
|
|
} |
|
|
|
|
|
.sidebar.collapsed .sidebar-title-text { |
|
|
display: none; |
|
|
} |
|
|
|
|
|
.sidebar.collapsed .sidebar-title { |
|
|
justify-content: center; |
|
|
} |
|
|
|
|
|
.sidebar.collapsed .sidebar-logo { |
|
|
display: none; |
|
|
} |
|
|
|
|
|
.sidebar.collapsed .new-chat-button span { |
|
|
display: none; |
|
|
} |
|
|
|
|
|
.sidebar.collapsed .new-chat-button { |
|
|
justify-content: center; |
|
|
padding: 12px; |
|
|
border-radius: 50%; |
|
|
width: 40px; |
|
|
height: 40px; |
|
|
margin: 12px auto; |
|
|
} |
|
|
|
|
|
.sidebar.collapsed .chat-item-title, |
|
|
.sidebar.collapsed .chat-item-time { |
|
|
display: none; |
|
|
} |
|
|
|
|
|
.sidebar.collapsed .chat-item { |
|
|
justify-content: center; |
|
|
padding: 12px; |
|
|
} |
|
|
|
|
|
.sidebar.collapsed .available-models-list, |
|
|
.sidebar.collapsed .model-selector-label, |
|
|
.sidebar.collapsed .model-select, |
|
|
.sidebar.collapsed .model-status, |
|
|
.sidebar.collapsed .refresh-models-btn { |
|
|
display: none; |
|
|
} |
|
|
|
|
|
.sidebar.collapsed .novel-selector-label, |
|
|
.sidebar.collapsed .novel-list, |
|
|
.sidebar.collapsed .selected-novels-info { |
|
|
display: none; |
|
|
} |
|
|
|
|
|
.sidebar.collapsed .novel-selector { |
|
|
padding: 8px; |
|
|
} |
|
|
|
|
|
.sidebar.collapsed .logout-button span { |
|
|
display: none; |
|
|
} |
|
|
|
|
|
.sidebar.collapsed .logout-button { |
|
|
justify-content: center; |
|
|
padding: 12px; |
|
|
border-radius: 50%; |
|
|
width: 40px; |
|
|
height: 40px; |
|
|
margin: 0 auto; |
|
|
} |
|
|
|
|
|
.sidebar.collapsed .chat-history { |
|
|
padding: 4px; |
|
|
} |
|
|
|
|
|
.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-title img { |
|
|
width: 24px; |
|
|
height: 24px; |
|
|
object-fit: contain; |
|
|
} |
|
|
|
|
|
.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; |
|
|
} |
|
|
|
|
|
|
|
|
.available-models-list { |
|
|
border-top: 1px solid var(--border); |
|
|
padding: 16px; |
|
|
background: var(--bg-primary); |
|
|
} |
|
|
|
|
|
.available-models-list .model-select { |
|
|
margin-top: 8px; |
|
|
} |
|
|
|
|
|
.available-models-list .model-select:disabled { |
|
|
opacity: 0.6; |
|
|
cursor: not-allowed; |
|
|
} |
|
|
|
|
|
.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); |
|
|
} |
|
|
|
|
|
|
|
|
.modal { |
|
|
display: none; |
|
|
position: fixed; |
|
|
z-index: 1000; |
|
|
left: 0; |
|
|
top: 0; |
|
|
width: 100%; |
|
|
height: 100%; |
|
|
background-color: rgba(0, 0, 0, 0.5); |
|
|
backdrop-filter: blur(4px); |
|
|
} |
|
|
|
|
|
.modal-content { |
|
|
background-color: var(--bg-primary); |
|
|
margin: 5% auto; |
|
|
border-radius: 12px; |
|
|
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3); |
|
|
width: 90%; |
|
|
max-width: 800px; |
|
|
overflow: hidden; |
|
|
display: flex; |
|
|
flex-direction: column; |
|
|
} |
|
|
|
|
|
.modal-header { |
|
|
padding: 20px 24px; |
|
|
border-bottom: 1px solid var(--border); |
|
|
display: flex; |
|
|
justify-content: space-between; |
|
|
align-items: center; |
|
|
background: var(--bg-secondary); |
|
|
} |
|
|
|
|
|
.modal-header h2 { |
|
|
margin: 0; |
|
|
font-size: 20px; |
|
|
font-weight: 500; |
|
|
color: var(--text-primary); |
|
|
} |
|
|
|
|
|
.modal-close { |
|
|
background: none; |
|
|
border: none; |
|
|
font-size: 28px; |
|
|
color: var(--text-secondary); |
|
|
cursor: pointer; |
|
|
padding: 0; |
|
|
width: 32px; |
|
|
height: 32px; |
|
|
display: flex; |
|
|
align-items: center; |
|
|
justify-content: center; |
|
|
border-radius: 50%; |
|
|
transition: background 0.2s; |
|
|
} |
|
|
|
|
|
.modal-close:hover { |
|
|
background: var(--bg-tertiary); |
|
|
} |
|
|
|
|
|
.modal-body { |
|
|
padding: 24px; |
|
|
overflow-y: auto; |
|
|
} |
|
|
|
|
|
.webnovel-item { |
|
|
padding: 16px; |
|
|
margin-bottom: 12px; |
|
|
border: 1px solid var(--border); |
|
|
border-radius: 8px; |
|
|
background: var(--bg-secondary); |
|
|
cursor: pointer; |
|
|
transition: all 0.2s; |
|
|
} |
|
|
|
|
|
.webnovel-item:hover { |
|
|
background: var(--bg-tertiary); |
|
|
border-color: var(--accent); |
|
|
} |
|
|
|
|
|
.webnovel-item-header { |
|
|
display: flex; |
|
|
justify-content: space-between; |
|
|
align-items: center; |
|
|
margin-bottom: 8px; |
|
|
} |
|
|
|
|
|
.webnovel-item-title { |
|
|
font-size: 16px; |
|
|
font-weight: 500; |
|
|
color: var(--text-primary); |
|
|
} |
|
|
|
|
|
.webnovel-item-meta { |
|
|
font-size: 12px; |
|
|
color: var(--text-secondary); |
|
|
display: flex; |
|
|
gap: 12px; |
|
|
flex-wrap: wrap; |
|
|
} |
|
|
|
|
|
.webnovel-item-actions { |
|
|
display: flex; |
|
|
gap: 8px; |
|
|
margin-top: 12px; |
|
|
} |
|
|
|
|
|
.webnovel-item-btn { |
|
|
padding: 6px 12px; |
|
|
border: 1px solid var(--border); |
|
|
border-radius: 6px; |
|
|
background: var(--bg-primary); |
|
|
color: var(--text-primary); |
|
|
font-size: 12px; |
|
|
cursor: pointer; |
|
|
transition: all 0.2s; |
|
|
} |
|
|
|
|
|
.webnovel-item-btn:hover { |
|
|
background: var(--accent); |
|
|
color: white; |
|
|
border-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 img { |
|
|
width: 24px; |
|
|
height: 24px; |
|
|
object-fit: contain; |
|
|
} |
|
|
|
|
|
.header-actions { |
|
|
display: flex; |
|
|
gap: 12px; |
|
|
align-items: center; |
|
|
} |
|
|
|
|
|
.menu-toggle { |
|
|
display: none; |
|
|
background: none; |
|
|
border: none; |
|
|
font-size: 24px; |
|
|
cursor: pointer; |
|
|
padding: 8px; |
|
|
color: var(--text-primary); |
|
|
} |
|
|
|
|
|
.mobile-menu { |
|
|
display: none; |
|
|
position: fixed; |
|
|
top: 0; |
|
|
left: 0; |
|
|
right: 0; |
|
|
bottom: 0; |
|
|
background: rgba(0, 0, 0, 0.5); |
|
|
z-index: 1000; |
|
|
} |
|
|
|
|
|
.mobile-menu.active { |
|
|
display: block; |
|
|
} |
|
|
|
|
|
.mobile-menu-content { |
|
|
position: fixed; |
|
|
top: 0; |
|
|
right: -100%; |
|
|
width: 280px; |
|
|
max-width: 80%; |
|
|
height: 100%; |
|
|
background: var(--bg-primary); |
|
|
box-shadow: -2px 0 8px rgba(0, 0, 0, 0.1); |
|
|
transition: right 0.3s ease; |
|
|
overflow-y: auto; |
|
|
z-index: 1001; |
|
|
} |
|
|
|
|
|
.mobile-menu.active .mobile-menu-content { |
|
|
right: 0; |
|
|
} |
|
|
|
|
|
.mobile-menu-header { |
|
|
padding: 16px 20px; |
|
|
border-bottom: 1px solid var(--border); |
|
|
display: flex; |
|
|
justify-content: space-between; |
|
|
align-items: center; |
|
|
background: var(--bg-primary); |
|
|
position: sticky; |
|
|
top: 0; |
|
|
z-index: 10; |
|
|
} |
|
|
|
|
|
.mobile-menu-title { |
|
|
font-size: 18px; |
|
|
font-weight: 500; |
|
|
} |
|
|
|
|
|
.mobile-menu-close { |
|
|
background: none; |
|
|
border: none; |
|
|
font-size: 24px; |
|
|
cursor: pointer; |
|
|
color: var(--text-primary); |
|
|
padding: 0; |
|
|
width: 32px; |
|
|
height: 32px; |
|
|
display: flex; |
|
|
align-items: center; |
|
|
justify-content: center; |
|
|
} |
|
|
|
|
|
.mobile-menu-items { |
|
|
padding: 8px 0; |
|
|
} |
|
|
|
|
|
.mobile-menu-item { |
|
|
display: block; |
|
|
padding: 12px 20px; |
|
|
color: var(--text-primary); |
|
|
text-decoration: none; |
|
|
border-bottom: 1px solid var(--bg-tertiary); |
|
|
transition: background 0.2s; |
|
|
} |
|
|
|
|
|
.mobile-menu-item:hover { |
|
|
background: var(--bg-secondary); |
|
|
} |
|
|
|
|
|
.mobile-menu-user { |
|
|
padding: 16px 20px; |
|
|
border-bottom: 1px solid var(--border); |
|
|
color: var(--text-secondary); |
|
|
font-size: 14px; |
|
|
} |
|
|
|
|
|
@media (max-width: 768px) { |
|
|
.header { |
|
|
padding: 12px 16px; |
|
|
} |
|
|
|
|
|
.header-title { |
|
|
font-size: 18px; |
|
|
} |
|
|
|
|
|
.menu-toggle { |
|
|
display: block; |
|
|
} |
|
|
|
|
|
.header-actions { |
|
|
display: none; |
|
|
} |
|
|
} |
|
|
|
|
|
.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); |
|
|
} |
|
|
|
|
|
.btn-text-icon { |
|
|
display: flex; |
|
|
align-items: center; |
|
|
gap: 6px; |
|
|
padding: 6px 12px; |
|
|
border-radius: 6px; |
|
|
transition: background 0.2s; |
|
|
font-size: 14px; |
|
|
font-weight: 500; |
|
|
color: var(--text-primary); |
|
|
text-decoration: none; |
|
|
} |
|
|
|
|
|
.btn-text-icon:hover { |
|
|
background: var(--bg-tertiary); |
|
|
color: var(--accent); |
|
|
} |
|
|
|
|
|
|
|
|
.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.ai { |
|
|
flex-direction: column; |
|
|
align-items: flex-start; |
|
|
} |
|
|
|
|
|
.message-avatar { |
|
|
width: 32px; |
|
|
height: 32px; |
|
|
border-radius: 50%; |
|
|
display: flex; |
|
|
align-items: center; |
|
|
justify-content: center; |
|
|
font-size: 16px; |
|
|
flex-shrink: 0; |
|
|
} |
|
|
|
|
|
.message.ai .message-avatar { |
|
|
width: 24px; |
|
|
height: 24px; |
|
|
font-size: 14px; |
|
|
margin-bottom: 8px; |
|
|
} |
|
|
|
|
|
.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.ai .message-bubble { |
|
|
white-space: pre-wrap; |
|
|
} |
|
|
|
|
|
.message.ai .message-bubble br { |
|
|
display: block; |
|
|
content: ""; |
|
|
margin: 0; |
|
|
} |
|
|
|
|
|
.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.ai .message-bubble h1, |
|
|
.message.ai .message-bubble h2, |
|
|
.message.ai .message-bubble h3, |
|
|
.message.ai .message-bubble h4, |
|
|
.message.ai .message-bubble h5, |
|
|
.message.ai .message-bubble h6 { |
|
|
margin: 12px 0 8px 0; |
|
|
font-weight: 600; |
|
|
line-height: 1.3; |
|
|
} |
|
|
|
|
|
.message.ai .message-bubble h1 { |
|
|
font-size: 1.5em; |
|
|
border-bottom: 2px solid var(--border); |
|
|
padding-bottom: 8px; |
|
|
} |
|
|
|
|
|
.message.ai .message-bubble h2 { |
|
|
font-size: 1.3em; |
|
|
border-bottom: 1px solid var(--border); |
|
|
padding-bottom: 6px; |
|
|
} |
|
|
|
|
|
.message.ai .message-bubble h3 { |
|
|
font-size: 1.1em; |
|
|
} |
|
|
|
|
|
.message.ai .message-bubble h4 { |
|
|
font-size: 1em; |
|
|
} |
|
|
|
|
|
.message.ai .message-bubble h5 { |
|
|
font-size: 0.9em; |
|
|
} |
|
|
|
|
|
.message.ai .message-bubble h6 { |
|
|
font-size: 0.85em; |
|
|
} |
|
|
|
|
|
.message.ai .message-bubble p { |
|
|
margin: 8px 0; |
|
|
line-height: 1.6; |
|
|
} |
|
|
|
|
|
.message.ai .message-bubble code { |
|
|
background: var(--bg-tertiary); |
|
|
padding: 2px 6px; |
|
|
border-radius: 4px; |
|
|
font-family: 'Courier New', monospace; |
|
|
font-size: 0.9em; |
|
|
color: var(--accent); |
|
|
} |
|
|
|
|
|
.message.ai .message-bubble pre { |
|
|
background: var(--bg-tertiary); |
|
|
padding: 12px; |
|
|
border-radius: 8px; |
|
|
overflow-x: auto; |
|
|
margin: 12px 0; |
|
|
border: 1px solid var(--border); |
|
|
} |
|
|
|
|
|
.message.ai .message-bubble pre code { |
|
|
background: none; |
|
|
padding: 0; |
|
|
color: var(--text-primary); |
|
|
font-size: 0.85em; |
|
|
} |
|
|
|
|
|
.message.ai .message-bubble ul, |
|
|
.message.ai .message-bubble ol { |
|
|
margin: 8px 0; |
|
|
padding-left: 24px; |
|
|
} |
|
|
|
|
|
.message.ai .message-bubble li { |
|
|
margin: 4px 0; |
|
|
line-height: 1.5; |
|
|
} |
|
|
|
|
|
.message.ai .message-bubble blockquote { |
|
|
border-left: 4px solid var(--accent); |
|
|
padding-left: 16px; |
|
|
margin: 12px 0; |
|
|
color: var(--text-secondary); |
|
|
font-style: italic; |
|
|
} |
|
|
|
|
|
.message.ai .message-bubble hr { |
|
|
border: none; |
|
|
border-top: 1px solid var(--border); |
|
|
margin: 16px 0; |
|
|
} |
|
|
|
|
|
.message.ai .message-bubble a { |
|
|
color: var(--accent); |
|
|
text-decoration: underline; |
|
|
} |
|
|
|
|
|
.message.ai .message-bubble a:hover { |
|
|
opacity: 0.8; |
|
|
} |
|
|
|
|
|
.message.ai .message-bubble img { |
|
|
max-width: 100%; |
|
|
height: auto; |
|
|
border-radius: 8px; |
|
|
margin: 12px 0; |
|
|
} |
|
|
|
|
|
.message.ai .message-bubble strong { |
|
|
font-weight: 600; |
|
|
} |
|
|
|
|
|
.message.ai .message-bubble em { |
|
|
font-style: italic; |
|
|
} |
|
|
|
|
|
|
|
|
.footnote-ref { |
|
|
font-size: 0.75em; |
|
|
vertical-align: super; |
|
|
color: var(--accent); |
|
|
cursor: help; |
|
|
text-decoration: underline; |
|
|
text-decoration-style: dotted; |
|
|
position: relative; |
|
|
font-weight: 500; |
|
|
display: inline-block; |
|
|
} |
|
|
|
|
|
.footnote-ref:hover { |
|
|
color: var(--accent-hover); |
|
|
} |
|
|
|
|
|
|
|
|
.footnote-tooltip { |
|
|
position: absolute; |
|
|
bottom: calc(100% + 8px); |
|
|
left: 50%; |
|
|
transform: translateX(-50%); |
|
|
background: rgba(0, 0, 0, 0.95); |
|
|
color: white; |
|
|
padding: 8px 12px; |
|
|
border-radius: 8px; |
|
|
font-size: 0.75em; |
|
|
max-width: 350px; |
|
|
min-width: 200px; |
|
|
word-wrap: break-word; |
|
|
white-space: normal; |
|
|
opacity: 0; |
|
|
pointer-events: none; |
|
|
transition: opacity 0.2s ease-in-out; |
|
|
z-index: 10000; |
|
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4); |
|
|
line-height: 1.4; |
|
|
text-align: left; |
|
|
} |
|
|
|
|
|
.footnote-tooltip::after { |
|
|
content: ''; |
|
|
position: absolute; |
|
|
top: 100%; |
|
|
left: 50%; |
|
|
transform: translateX(-50%); |
|
|
border: 6px solid transparent; |
|
|
border-top-color: rgba(0, 0, 0, 0.95); |
|
|
} |
|
|
|
|
|
.footnote-ref:hover .footnote-tooltip { |
|
|
opacity: 1; |
|
|
} |
|
|
|
|
|
|
|
|
.message-bubble { |
|
|
position: relative; |
|
|
} |
|
|
|
|
|
|
|
|
.footnotes-container { |
|
|
margin-top: 12px; |
|
|
padding-top: 12px; |
|
|
border-top: 1px solid var(--border); |
|
|
font-size: 0.85em; |
|
|
color: var(--text-secondary); |
|
|
} |
|
|
|
|
|
.footnote-item { |
|
|
margin-bottom: 6px; |
|
|
line-height: 1.4; |
|
|
display: flex; |
|
|
align-items: flex-start; |
|
|
gap: 6px; |
|
|
} |
|
|
|
|
|
.footnote-item sup { |
|
|
font-size: 0.9em; |
|
|
color: var(--accent); |
|
|
font-weight: 500; |
|
|
} |
|
|
|
|
|
.footnote-item span { |
|
|
flex: 1; |
|
|
} |
|
|
|
|
|
.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; |
|
|
} |
|
|
|
|
|
|
|
|
.sidebar-overlay { |
|
|
display: none; |
|
|
position: fixed; |
|
|
top: 0; |
|
|
left: 0; |
|
|
right: 0; |
|
|
bottom: 0; |
|
|
background: rgba(0, 0, 0, 0.5); |
|
|
z-index: 999; |
|
|
} |
|
|
|
|
|
.sidebar-overlay.active { |
|
|
display: block; |
|
|
} |
|
|
|
|
|
|
|
|
@media (max-width: 768px) { |
|
|
.sidebar { |
|
|
position: fixed; |
|
|
left: 0; |
|
|
top: 0; |
|
|
z-index: 1000; |
|
|
box-shadow: 2px 0 8px var(--shadow); |
|
|
width: 0; |
|
|
overflow: hidden; |
|
|
transition: width 0.3s ease; |
|
|
} |
|
|
|
|
|
.sidebar:not(.collapsed) { |
|
|
width: 280px; |
|
|
} |
|
|
|
|
|
.sidebar.collapsed { |
|
|
width: 0; |
|
|
} |
|
|
|
|
|
.main-content { |
|
|
width: 100%; |
|
|
} |
|
|
|
|
|
.header { |
|
|
padding: 12px 16px; |
|
|
} |
|
|
|
|
|
.header-title { |
|
|
font-size: 18px; |
|
|
} |
|
|
|
|
|
#sidebarToggleBtn { |
|
|
display: flex !important; |
|
|
} |
|
|
|
|
|
.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"> |
|
|
<img src="{{ url_for('static', filename='logo.webp') }}" alt="SOY AI ๋ก๊ณ " class="sidebar-logo"> |
|
|
<span class="sidebar-title-text">์ํ ๊ฐ๋ฐ ์ด์์คํดํธ</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> |
|
|
<span>์ ๋ํ</span> |
|
|
</button> |
|
|
<div class="chat-history" id="chatHistory"> |
|
|
|
|
|
</div> |
|
|
|
|
|
|
|
|
<div class="available-models-list"> |
|
|
<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="availableModelsSelect"> |
|
|
<option value="">AI ๋ชฉ๋ก์ ๋ถ๋ฌ์ค๋ ์ค...</option> |
|
|
</select> |
|
|
</div> |
|
|
|
|
|
|
|
|
<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="sidebar-overlay" id="sidebarOverlay" onclick="closeSidebarOnMobile()"></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></span> |
|
|
</div> |
|
|
<button class="menu-toggle" onclick="toggleMobileMenu()" aria-label="๋ฉ๋ด ์ด๊ธฐ">โฐ</button> |
|
|
<div class="header-actions"> |
|
|
<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.webnovels') }}" class="btn-text-icon" title="์์ ์ ๋ณด"> |
|
|
<svg width="18" height="18" 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> |
|
|
<span>์์ ์ ๋ณด</span> |
|
|
</a> |
|
|
{% 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 %} |
|
|
<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="mobile-menu" id="mobileMenu" onclick="closeMobileMenuOnBackdrop(event)"> |
|
|
<div class="mobile-menu-content" onclick="event.stopPropagation()"> |
|
|
<div class="mobile-menu-header"> |
|
|
<div class="mobile-menu-title">๋ฉ๋ด</div> |
|
|
<button class="mobile-menu-close" onclick="toggleMobileMenu()" aria-label="๋ฉ๋ด ๋ซ๊ธฐ">×</button> |
|
|
</div> |
|
|
<div class="mobile-menu-user">{{ current_user.nickname or current_user.username }}</div> |
|
|
<div class="mobile-menu-items"> |
|
|
<a href="{{ url_for('main.webnovels') }}" class="mobile-menu-item" onclick="closeMobileMenu()">์์ ์ ๋ณด</a> |
|
|
{% if current_user.is_admin %} |
|
|
<a href="{{ url_for('main.admin') }}" class="mobile-menu-item" onclick="closeMobileMenu()">๊ด๋ฆฌ์ ํ์ด์ง</a> |
|
|
{% endif %} |
|
|
<a href="{{ url_for('main.logout') }}" class="mobile-menu-item" onclick="closeMobileMenu()">๋ก๊ทธ์์</a> |
|
|
</div> |
|
|
</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">SOYMEDIA ์ํ ์ธ๊ณ์ ์ค์ ๊ฒ์ ํ์ํฉ๋๋ค</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> |
|
|
|
|
|
|
|
|
<div id="webnovelsModal" class="modal" style="display: none;"> |
|
|
<div class="modal-content" style="max-width: 900px; max-height: 90vh;"> |
|
|
<div class="modal-header"> |
|
|
<h2>์
๋ก๋๋ ์น์์ค</h2> |
|
|
<button class="modal-close" onclick="closeWebnovelsModal()">×</button> |
|
|
</div> |
|
|
<div class="modal-body" style="display: flex; flex-direction: column; height: calc(90vh - 120px);"> |
|
|
<div style="margin-bottom: 16px;"> |
|
|
<select id="webnovelModelFilter" onchange="loadWebnovels()" style="width: 100%; padding: 8px; border: 1px solid var(--border); border-radius: 8px; background: var(--bg-primary); color: var(--text-primary);"> |
|
|
<option value="">๋ชจ๋ ๋ชจ๋ธ</option> |
|
|
</select> |
|
|
</div> |
|
|
<div id="webnovelsList" style="flex: 1; overflow-y: auto; border: 1px solid var(--border); border-radius: 8px; padding: 12px;"> |
|
|
<div style="text-align: center; color: var(--text-secondary); padding: 24px;"> |
|
|
์น์์ค ๋ชฉ๋ก์ ๋ถ๋ฌ์ค๋ ์ค... |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
|
|
|
|
|
|
<div id="webnovelContentModal" class="modal" style="display: none;"> |
|
|
<div class="modal-content" style="max-width: 1000px; max-height: 90vh;"> |
|
|
<div class="modal-header"> |
|
|
<h2 id="webnovelContentTitle">์น์์ค ๋ด์ฉ</h2> |
|
|
<button class="modal-close" onclick="closeWebnovelContentModal()">×</button> |
|
|
</div> |
|
|
|
|
|
<div style="padding: 12px 24px; border-bottom: 1px solid var(--border); background: var(--bg-secondary); display: flex; gap: 8px; align-items: center; flex-wrap: wrap;"> |
|
|
<div style="display: flex; align-items: center; gap: 8px; flex: 1; min-width: 200px;"> |
|
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="color: var(--text-secondary);"> |
|
|
<circle cx="11" cy="11" r="8"></circle> |
|
|
<path d="m21 21-4.35-4.35"></path> |
|
|
</svg> |
|
|
<input type="text" id="webnovelSearchInput" placeholder="๊ฒ์์ด๋ฅผ ์
๋ ฅํ์ธ์..." |
|
|
style="flex: 1; padding: 6px 12px; border: 1px solid var(--border); border-radius: 6px; background: var(--bg-primary); color: var(--text-primary); font-size: 14px; outline: none;" |
|
|
onkeydown="if(event.key === 'Enter') performWebnovelSearch()"> |
|
|
</div> |
|
|
<button onclick="performWebnovelSearch()" |
|
|
style="padding: 6px 16px; background: var(--accent); color: white; border: none; border-radius: 6px; cursor: pointer; font-size: 14px; font-weight: 500;"> |
|
|
๊ฒ์ |
|
|
</button> |
|
|
<button onclick="clearWebnovelSearch()" |
|
|
style="padding: 6px 16px; background: var(--bg-tertiary); color: var(--text-secondary); border: 1px solid var(--border); border-radius: 6px; cursor: pointer; font-size: 14px;"> |
|
|
์ด๊ธฐํ |
|
|
</button> |
|
|
<div id="webnovelSearchInfo" style="font-size: 12px; color: var(--text-secondary); min-width: 120px; text-align: right;"> |
|
|
|
|
|
</div> |
|
|
</div> |
|
|
<div style="padding: 8px 24px; border-bottom: 1px solid var(--border); background: var(--bg-secondary); display: flex; gap: 8px; align-items: center; justify-content: center;"> |
|
|
<button onclick="scrollToPreviousMatch()" id="prevMatchBtn" |
|
|
style="padding: 4px 12px; background: var(--bg-tertiary); color: var(--text-primary); border: 1px solid var(--border); border-radius: 4px; cursor: pointer; font-size: 12px;" |
|
|
disabled> |
|
|
โ ์ด์ |
|
|
</button> |
|
|
<button onclick="scrollToNextMatch()" id="nextMatchBtn" |
|
|
style="padding: 4px 12px; background: var(--bg-tertiary); color: var(--text-primary); border: 1px solid var(--border); border-radius: 4px; cursor: pointer; font-size: 12px;" |
|
|
disabled> |
|
|
๋ค์ โ |
|
|
</button> |
|
|
</div> |
|
|
<div class="modal-body" style="height: calc(90vh - 200px); overflow-y: auto; position: relative;" id="webnovelContentContainer"> |
|
|
<div id="webnovelContent" style="white-space: pre-wrap; font-family: inherit; line-height: 1.6; color: var(--text-primary); padding: 16px;"> |
|
|
๋ด์ฉ์ ๋ถ๋ฌ์ค๋ ์ค... |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
|
|
|
<script> |
|
|
function toggleMobileMenu() { |
|
|
const menu = document.getElementById('mobileMenu'); |
|
|
menu.classList.toggle('active'); |
|
|
document.body.style.overflow = menu.classList.contains('active') ? 'hidden' : ''; |
|
|
} |
|
|
|
|
|
function closeMobileMenu() { |
|
|
const menu = document.getElementById('mobileMenu'); |
|
|
menu.classList.remove('active'); |
|
|
document.body.style.overflow = ''; |
|
|
} |
|
|
|
|
|
function closeMobileMenuOnBackdrop(event) { |
|
|
if (event.target.id === 'mobileMenu') { |
|
|
closeMobileMenu(); |
|
|
} |
|
|
} |
|
|
|
|
|
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 answerModel = localStorage.getItem('answerModel') || ''; |
|
|
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('connected'); |
|
|
loadNovels(); |
|
|
}); |
|
|
|
|
|
|
|
|
const availableModelsSelect = document.getElementById('availableModelsSelect'); |
|
|
availableModelsSelect.addEventListener('change', function() { |
|
|
answerModel = this.value; |
|
|
localStorage.setItem('answerModel', answerModel); |
|
|
|
|
|
if (answerModel) { |
|
|
console.log('[๋ต๋ณ ๋ชจ๋ธ ์ ํ]', answerModel); |
|
|
} |
|
|
}); |
|
|
|
|
|
|
|
|
async function loadNovels() { |
|
|
try { |
|
|
|
|
|
|
|
|
const url = selectedModel |
|
|
? `/api/files?model_name=${encodeURIComponent(selectedModel)}&public_only=true` |
|
|
: '/api/files?public_only=true'; |
|
|
|
|
|
console.log('[์น์์ค ๋ชฉ๋ก] API ์์ฒญ:', url, '์ ํ๋ ๋ชจ๋ธ:', selectedModel || '์์ (์ ์ฒด)'); |
|
|
|
|
|
const response = await fetch(url, { |
|
|
credentials: 'include' |
|
|
}); |
|
|
|
|
|
console.log('[์น์์ค ๋ชฉ๋ก] ์๋ต ์ํ:', response.status, response.statusText); |
|
|
|
|
|
if (!response.ok) { |
|
|
const errorText = await response.text(); |
|
|
console.error('[์น์์ค ๋ชฉ๋ก] ์๋ต ์ค๋ฅ:', errorText); |
|
|
throw new Error(`์น์์ค ๋ชฉ๋ก์ ๋ถ๋ฌ์ฌ ์ ์์ต๋๋ค. (${response.status})`); |
|
|
} |
|
|
|
|
|
const data = await response.json(); |
|
|
console.log('[์น์์ค ๋ชฉ๋ก] ์๋ต ๋ฐ์ดํฐ:', data); |
|
|
console.log('[์น์์ค ๋ชฉ๋ก] files ๋ฐฐ์ด:', data.files); |
|
|
|
|
|
novelList.innerHTML = ''; |
|
|
|
|
|
|
|
|
let files = data.files || []; |
|
|
console.log('[์น์์ค ๋ชฉ๋ก] ํ์ผ ๊ฐ์:', files.length); |
|
|
|
|
|
|
|
|
if (selectedModel && files.length === 0) { |
|
|
console.log('[์น์์ค ๋ชฉ๋ก] ์ ํํ ๋ชจ๋ธ์ ํ์ผ์ด ์์ด ์ ์ฒด ํ์ผ ์กฐํ ์ค...'); |
|
|
try { |
|
|
const allFilesResponse = await fetch('/api/files?public_only=true', { |
|
|
credentials: 'include' |
|
|
}); |
|
|
if (allFilesResponse.ok) { |
|
|
const allFilesData = await allFilesResponse.json(); |
|
|
files = allFilesData.files || []; |
|
|
console.log('[์น์์ค ๋ชฉ๋ก] ์ ์ฒด ํ์ผ ๊ฐ์:', files.length); |
|
|
} |
|
|
} catch (e) { |
|
|
console.error('[์น์์ค ๋ชฉ๋ก] ์ ์ฒด ํ์ผ ์กฐํ ์ค๋ฅ:', e); |
|
|
} |
|
|
} |
|
|
|
|
|
if (files.length > 0) { |
|
|
files.forEach(file => { |
|
|
console.log('[์น์์ค ๋ชฉ๋ก] ํ์ผ ์ฒ๋ฆฌ:', 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}`; |
|
|
|
|
|
const displayText = files.some(f => f.model_name && f.model_name !== selectedModel) |
|
|
? `${file.original_filename} (${file.model_name || '๋ฏธ์ง์ '})` |
|
|
: file.original_filename; |
|
|
label.textContent = displayText; |
|
|
label.title = `${file.original_filename} (${file.model_name || '๋ฏธ์ง์ '})`; |
|
|
|
|
|
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">์น์์ค ๋ชฉ๋ก์ ๋ถ๋ฌ์ฌ ์ ์์ต๋๋ค<br><small style="color: #ea4335;">${error.message}</small></div>`; |
|
|
selectedNovelsInfo.textContent = ''; |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
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 loadAvailableModels() { |
|
|
const availableModelsSelect = document.getElementById('availableModelsSelect'); |
|
|
availableModelsSelect.disabled = true; |
|
|
availableModelsSelect.innerHTML = '<option value="">AI ๋ชฉ๋ก์ ๋ถ๋ฌ์ค๋ ์ค...</option>'; |
|
|
|
|
|
try { |
|
|
const response = await fetch('/api/ollama/models?all=true'); |
|
|
const data = await response.json(); |
|
|
|
|
|
availableModelsSelect.innerHTML = '<option value="">๋ต๋ณ ์์ฑํ AI ๋ชจ๋ธ์ ์ ํํ์ธ์...</option>'; |
|
|
|
|
|
if (data.models && data.models.length > 0) { |
|
|
|
|
|
const ollamaModels = []; |
|
|
const geminiModels = []; |
|
|
|
|
|
data.models.forEach(model => { |
|
|
if (model.type === 'gemini') { |
|
|
geminiModels.push(model); |
|
|
} else { |
|
|
ollamaModels.push(model); |
|
|
} |
|
|
}); |
|
|
|
|
|
|
|
|
if (ollamaModels.length > 0) { |
|
|
const optgroup = document.createElement('optgroup'); |
|
|
optgroup.label = '๐ค Ollama ๋ชจ๋ธ'; |
|
|
ollamaModels.forEach(model => { |
|
|
const option = document.createElement('option'); |
|
|
option.value = model.name; |
|
|
option.textContent = model.name; |
|
|
if (model.name === answerModel) { |
|
|
option.selected = true; |
|
|
} |
|
|
optgroup.appendChild(option); |
|
|
}); |
|
|
availableModelsSelect.appendChild(optgroup); |
|
|
} |
|
|
|
|
|
|
|
|
if (geminiModels.length > 0) { |
|
|
const optgroup = document.createElement('optgroup'); |
|
|
optgroup.label = 'โจ Gemini ๋ชจ๋ธ'; |
|
|
geminiModels.forEach(model => { |
|
|
const option = document.createElement('option'); |
|
|
option.value = model.name; |
|
|
|
|
|
const displayName = model.name.startsWith('gemini:') |
|
|
? model.name.substring(7) |
|
|
: model.name; |
|
|
option.textContent = displayName; |
|
|
if (model.name === answerModel) { |
|
|
option.selected = true; |
|
|
} |
|
|
optgroup.appendChild(option); |
|
|
}); |
|
|
availableModelsSelect.appendChild(optgroup); |
|
|
} |
|
|
|
|
|
availableModelsSelect.disabled = false; |
|
|
} else { |
|
|
availableModelsSelect.innerHTML = '<option value="">๋ฑ๋ก๋ AI ๋ชจ๋ธ์ด ์์ต๋๋ค</option>'; |
|
|
} |
|
|
} catch (error) { |
|
|
availableModelsSelect.innerHTML = '<option value="">๋ชจ๋ธ ๋ชฉ๋ก ๋ก๋ ์คํจ</option>'; |
|
|
console.error('์ฌ์ฉ ๊ฐ๋ฅํ ๋ชจ๋ธ ๋ก๋ ์ค๋ฅ:', error); |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
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) { |
|
|
|
|
|
const ollamaModels = []; |
|
|
const geminiModels = []; |
|
|
|
|
|
data.models.forEach(model => { |
|
|
if (model.type === 'gemini') { |
|
|
geminiModels.push(model); |
|
|
} else { |
|
|
ollamaModels.push(model); |
|
|
} |
|
|
}); |
|
|
|
|
|
|
|
|
|
|
|
if (ollamaModels.length > 0) { |
|
|
const optgroup = document.createElement('optgroup'); |
|
|
optgroup.label = '๐ค Ollama ๋ชจ๋ธ'; |
|
|
ollamaModels.forEach(model => { |
|
|
const option = document.createElement('option'); |
|
|
option.value = model.name; |
|
|
option.textContent = model.name; |
|
|
if (model.name === selectedModel) { |
|
|
option.selected = true; |
|
|
} |
|
|
optgroup.appendChild(option); |
|
|
}); |
|
|
modelSelect.appendChild(optgroup); |
|
|
} |
|
|
|
|
|
|
|
|
if (geminiModels.length > 0) { |
|
|
const optgroup = document.createElement('optgroup'); |
|
|
optgroup.label = 'โจ Gemini ๋ชจ๋ธ'; |
|
|
geminiModels.forEach(model => { |
|
|
const option = document.createElement('option'); |
|
|
option.value = model.name; |
|
|
|
|
|
const displayName = model.name.startsWith('gemini:') |
|
|
? model.name.substring(7) |
|
|
: model.name; |
|
|
option.textContent = displayName; |
|
|
if (model.name === selectedModel) { |
|
|
option.selected = true; |
|
|
} |
|
|
optgroup.appendChild(option); |
|
|
}); |
|
|
modelSelect.appendChild(optgroup); |
|
|
} |
|
|
|
|
|
updateModelStatus('connected'); |
|
|
} else { |
|
|
updateModelStatus('error', '์ฌ์ฉ ๊ฐ๋ฅํ ๋ชจ๋ธ์ด ์์ต๋๋ค'); |
|
|
} |
|
|
} catch (error) { |
|
|
updateModelStatus('error', '๋ชจ๋ธ ๋ชฉ๋ก ๋ก๋ ์คํจ'); |
|
|
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'); |
|
|
|
|
|
if (selectedModel) { |
|
|
const modelType = selectedModel.toLowerCase().startsWith('gemini') ? 'Gemini' : 'Ollama'; |
|
|
modelStatus.innerHTML = `<span class="model-status-dot"></span><span>${modelType} ๋ชจ๋ธ ์ ํ๋จ</span>`; |
|
|
} else { |
|
|
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'); |
|
|
const overlay = document.getElementById('sidebarOverlay'); |
|
|
|
|
|
if (window.innerWidth <= 768) { |
|
|
sidebarToggleBtn.style.display = isCollapsed ? 'flex' : 'none'; |
|
|
|
|
|
if (!isCollapsed) { |
|
|
if (overlay) overlay.classList.add('active'); |
|
|
document.body.style.overflow = 'hidden'; |
|
|
} else { |
|
|
if (overlay) overlay.classList.remove('active'); |
|
|
document.body.style.overflow = ''; |
|
|
} |
|
|
} else { |
|
|
if (overlay) overlay.classList.remove('active'); |
|
|
} |
|
|
|
|
|
|
|
|
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 closeSidebarOnMobile() { |
|
|
if (window.innerWidth <= 768) { |
|
|
sidebar.classList.add('collapsed'); |
|
|
const overlay = document.getElementById('sidebarOverlay'); |
|
|
if (overlay) overlay.classList.remove('active'); |
|
|
document.body.style.overflow = ''; |
|
|
sidebarToggleBtn.style.display = 'flex'; |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
function handleResize() { |
|
|
const overlay = document.getElementById('sidebarOverlay'); |
|
|
if (window.innerWidth <= 768) { |
|
|
sidebar.classList.add('collapsed'); |
|
|
sidebarToggleBtn.style.display = 'flex'; |
|
|
if (overlay) overlay.classList.remove('active'); |
|
|
document.body.style.overflow = ''; |
|
|
} else { |
|
|
sidebar.classList.remove('collapsed'); |
|
|
sidebarToggleBtn.style.display = 'none'; |
|
|
if (overlay) overlay.classList.remove('active'); |
|
|
} |
|
|
} |
|
|
|
|
|
window.addEventListener('resize', handleResize); |
|
|
handleResize(); |
|
|
|
|
|
|
|
|
async function startNewChat() { |
|
|
if (confirm('์ ๋ํ๋ฅผ ์์ํ์๊ฒ ์ต๋๊น? ํ์ฌ ๋ํ๋ ์ ์ฅ๋ฉ๋๋ค.')) { |
|
|
clearChat(); |
|
|
currentChatId = null; |
|
|
currentSessionId = null; |
|
|
await loadChatHistory(); |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
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'); |
|
|
|
|
|
if (!response.ok) { |
|
|
const errorData = await response.json().catch(() => ({ error: '์๋ฒ ์ค๋ฅ' })); |
|
|
throw new Error(errorData.error || `HTTP ${response.status}`); |
|
|
} |
|
|
|
|
|
const data = await response.json(); |
|
|
|
|
|
if (data.error) { |
|
|
throw new Error(data.error); |
|
|
} |
|
|
|
|
|
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">${escapeHtml(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: #ea4335; font-size: 14px;">๋ํ ๊ธฐ๋ก์ ๋ถ๋ฌ์ฌ ์ ์์ต๋๋ค<br><small>${error.message || '์ ์ ์๋ ์ค๋ฅ'}</small></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}`); |
|
|
|
|
|
if (!response.ok) { |
|
|
const errorData = await response.json().catch(() => ({ error: '์๋ฒ ์ค๋ฅ' })); |
|
|
if (response.status === 404) { |
|
|
console.warn('๋ํ ์ธ์
์ ์ฐพ์ ์ ์์ต๋๋ค:', sessionId); |
|
|
|
|
|
await loadChatHistory(); |
|
|
return; |
|
|
} |
|
|
throw new Error(errorData.error || `HTTP ${response.status}`); |
|
|
} |
|
|
|
|
|
const data = await response.json(); |
|
|
|
|
|
if (!data.session) { |
|
|
console.warn('์ธ์
๋ฐ์ดํฐ๊ฐ ์์ต๋๋ค:', sessionId); |
|
|
await loadChatHistory(); |
|
|
return; |
|
|
} |
|
|
|
|
|
currentSessionId = sessionId; |
|
|
currentChatId = sessionId; |
|
|
chatContainer.innerHTML = ''; |
|
|
|
|
|
console.log('[๋ํ ๋ก๋] ์ธ์
ID:', sessionId); |
|
|
console.log('[๋ํ ๋ก๋] ๋ฉ์์ง ๊ฐ์:', data.session.messages ? data.session.messages.length : 0); |
|
|
|
|
|
if (data.session.messages && data.session.messages.length > 0) { |
|
|
console.log('[๋ํ ๋ก๋] ๋ฉ์์ง ๋ชฉ๋ก:', data.session.messages.map(m => ({ id: m.id, role: m.role, content_preview: m.content ? m.content.substring(0, 50) : '' }))); |
|
|
|
|
|
|
|
|
for (let index = 0; index < data.session.messages.length; index++) { |
|
|
const msg = data.session.messages[index]; |
|
|
console.log(`[๋ํ ๋ก๋] ๋ฉ์์ง ${index + 1}/${data.session.messages.length} ์ถ๊ฐ ์ค:`, msg.id, msg.role, msg.content ? msg.content.substring(0, 50) : ''); |
|
|
if (msg.content && msg.content.trim() !== '') { |
|
|
addMessage(msg.role, msg.content, false); |
|
|
} else { |
|
|
console.warn(`[๋ํ ๋ก๋] ๋ฉ์์ง ${msg.id}์ ๋ด์ฉ์ด ๋น์ด์์ต๋๋ค.`); |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
setTimeout(() => { |
|
|
chatContainer.scrollTop = chatContainer.scrollHeight; |
|
|
console.log('[๋ํ ๋ก๋] ๋ชจ๋ ๋ฉ์์ง ์ถ๊ฐ ์๋ฃ, ์คํฌ๋กค ์์น:', chatContainer.scrollTop); |
|
|
}, 100); |
|
|
} else { |
|
|
console.log('[๋ํ ๋ก๋] ๋ฉ์์ง๊ฐ ์์ต๋๋ค.'); |
|
|
if (emptyState) { |
|
|
emptyState.style.display = 'flex'; |
|
|
} |
|
|
} |
|
|
|
|
|
await loadChatHistory(); |
|
|
if (window.innerWidth <= 768) { |
|
|
sidebar.classList.add('collapsed'); |
|
|
} |
|
|
} catch (error) { |
|
|
console.error('๋ํ ๋ก๋ ์ค๋ฅ:', error); |
|
|
|
|
|
const errorMessage = error.message || '์ ์ ์๋ ์ค๋ฅ'; |
|
|
console.error('์๋ฌ ์์ธ:', errorMessage); |
|
|
|
|
|
await loadChatHistory(); |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
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, |
|
|
analysis_model: selectedModel || null, |
|
|
answer_model: answerModel || 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'; |
|
|
}); |
|
|
|
|
|
|
|
|
messageInput.addEventListener('keydown', function(e) { |
|
|
if (e.key === 'Enter' && !e.shiftKey) { |
|
|
e.preventDefault(); |
|
|
sendMessage(); |
|
|
} |
|
|
}); |
|
|
|
|
|
|
|
|
|
|
|
function markdownToHtml(text) { |
|
|
if (!text) return ''; |
|
|
|
|
|
let html = text; |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
const codeBlocks = []; |
|
|
html = html.replace(/```([\s\S]*?)```/g, (match, code) => { |
|
|
const placeholder = `__CODE_BLOCK_${codeBlocks.length}__`; |
|
|
codeBlocks.push({ |
|
|
placeholder: placeholder, |
|
|
html: `<pre><code>${escapeHtml(code.trim())}</code></pre>` |
|
|
}); |
|
|
return placeholder; |
|
|
}); |
|
|
|
|
|
|
|
|
const inlineCodes = []; |
|
|
html = html.replace(/`([^`\n]+)`/g, (match, code) => { |
|
|
const placeholder = `__INLINE_CODE_${inlineCodes.length}__`; |
|
|
inlineCodes.push({ |
|
|
placeholder: placeholder, |
|
|
html: `<code>${escapeHtml(code)}</code>` |
|
|
}); |
|
|
return placeholder; |
|
|
}); |
|
|
|
|
|
|
|
|
html = html.replace(/^###### (.*$)/gim, '<h6>$1</h6>'); |
|
|
html = html.replace(/^##### (.*$)/gim, '<h5>$1</h5>'); |
|
|
html = html.replace(/^#### (.*$)/gim, '<h4>$1</h4>'); |
|
|
html = html.replace(/^### (.*$)/gim, '<h3>$1</h3>'); |
|
|
html = html.replace(/^## (.*$)/gim, '<h2>$1</h2>'); |
|
|
html = html.replace(/^# (.*$)/gim, '<h1>$1</h1>'); |
|
|
|
|
|
|
|
|
html = html.replace(/^---$/gim, '<hr>'); |
|
|
html = html.replace(/^\*\*\*$/gim, '<hr>'); |
|
|
|
|
|
|
|
|
html = html.replace(/^> (.+)$/gim, '<blockquote>$1</blockquote>'); |
|
|
|
|
|
|
|
|
html = html.replace(/^[\*\-\+] (.+)$/gim, '<li>$1</li>'); |
|
|
|
|
|
html = html.replace(/(<li>.*?<\/li>(?:\s*<li>.*?<\/li>)*)/gs, '<ul>$1</ul>'); |
|
|
|
|
|
|
|
|
html = html.replace(/^\d+\. (.+)$/gim, '<li>$1</li>'); |
|
|
|
|
|
html = html.replace(/(?<!<ul>)(<li>.*?<\/li>(?:\s*<li>.*?<\/li>)*)(?!<\/ul>)/gs, '<ol>$1</ol>'); |
|
|
|
|
|
|
|
|
html = html.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>'); |
|
|
html = html.replace(/__(.+?)__/g, '<strong>$1</strong>'); |
|
|
|
|
|
|
|
|
html = html.replace(/(?<!\*)\*([^*]+)\*(?!\*)/g, '<em>$1</em>'); |
|
|
html = html.replace(/(?<!_)_([^_]+)_(?!_)/g, '<em>$1</em>'); |
|
|
|
|
|
|
|
|
html = html.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2" target="_blank" rel="noopener noreferrer">$1</a>'); |
|
|
|
|
|
|
|
|
html = html.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, '<img src="$2" alt="$1">'); |
|
|
|
|
|
|
|
|
inlineCodes.forEach(({ placeholder, html: codeHtml }) => { |
|
|
html = html.replace(placeholder, codeHtml); |
|
|
}); |
|
|
|
|
|
|
|
|
codeBlocks.forEach(({ placeholder, html: codeHtml }) => { |
|
|
html = html.replace(placeholder, codeHtml); |
|
|
}); |
|
|
|
|
|
|
|
|
|
|
|
html = html.split(/\n\n+/).map(para => { |
|
|
if (para.trim() && !para.match(/^<[h|u|o|b|p|d]/)) { |
|
|
|
|
|
if (!para.trim().startsWith('<')) { |
|
|
return `<p>${para.trim()}</p>`; |
|
|
} |
|
|
} |
|
|
return para; |
|
|
}).join('\n'); |
|
|
|
|
|
|
|
|
html = html.replace(/(?<!>)\n(?!<)/g, '<br>'); |
|
|
|
|
|
|
|
|
html = html.replace(/<p>\s*<\/p>/g, ''); |
|
|
html = html.replace(/<p>(<[^>]+>)/g, '$1'); |
|
|
html = html.replace(/(<\/[^>]+>)<\/p>/g, '$1'); |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
return html; |
|
|
} |
|
|
|
|
|
function formatContentWithFootnotes(content) { |
|
|
if (!content) return { formattedContent: '', footnotes: [] }; |
|
|
|
|
|
|
|
|
let cleanedContent = content; |
|
|
|
|
|
cleanedContent = cleanedContent.replace(/\s*\[๋ด์ฉ์ ์ฐพ์ ์ ์์ต๋๋ค\]\s*/g, ' '); |
|
|
cleanedContent = cleanedContent.replace(/\s*๋ด์ฉ์ ์ฐพ์ ์ ์์ต๋๋ค\s*/g, ' '); |
|
|
cleanedContent = cleanedContent.replace(/\s*\[๊ทผ๊ฑฐ:\s*๋ด์ฉ์ ์ฐพ์ ์ ์์ต๋๋ค\]\s*/g, ' '); |
|
|
cleanedContent = cleanedContent.replace(/\s*\[๊ทผ๊ฑฐ:\s*"๋ด์ฉ์ ์ฐพ์ ์ ์์ต๋๋ค"\]\s*/g, ' '); |
|
|
cleanedContent = cleanedContent.replace(/\s*\[๊ทผ๊ฑฐ:\s*'๋ด์ฉ์ ์ฐพ์ ์ ์์ต๋๋ค'\]\s*/g, ' '); |
|
|
|
|
|
cleanedContent = cleanedContent.replace(/[ \t]{2,}/g, ' '); |
|
|
|
|
|
cleanedContent = cleanedContent.replace(/[ \t]+\n/g, '\n'); |
|
|
cleanedContent = cleanedContent.replace(/\n[ \t]+/g, '\n'); |
|
|
|
|
|
|
|
|
let htmlContent = markdownToHtml(cleanedContent); |
|
|
|
|
|
|
|
|
htmlContent = htmlContent.replace(/\[๋ด์ฉ์ ์ฐพ์ ์ ์์ต๋๋ค\]/g, ''); |
|
|
htmlContent = htmlContent.replace(/๋ด์ฉ์ ์ฐพ์ ์ ์์ต๋๋ค/g, ''); |
|
|
htmlContent = htmlContent.replace(/<[^>]*>๋ด์ฉ์ ์ฐพ์ ์ ์์ต๋๋ค<\/[^>]*>/g, ''); |
|
|
|
|
|
|
|
|
const footnotePattern = /\[๊ทผ๊ฑฐ:\s*([^\]]+)\]/g; |
|
|
const footnotes = []; |
|
|
let footnoteIndex = 0; |
|
|
|
|
|
|
|
|
htmlContent = htmlContent.replace(footnotePattern, (match, footnoteText) => { |
|
|
const cleanText = footnoteText.trim(); |
|
|
|
|
|
if (cleanText.includes('๋ด์ฉ์ ์ฐพ์ ์ ์์ต๋๋ค')) { |
|
|
return ''; |
|
|
} |
|
|
footnoteIndex++; |
|
|
footnotes.push(cleanText); |
|
|
|
|
|
return `<sup class="footnote-ref" data-footnote="${footnoteIndex}">[${footnoteIndex}]<span class="footnote-tooltip">${escapeHtml(cleanText)}</span></sup>`; |
|
|
}); |
|
|
|
|
|
return { formattedContent: htmlContent, footnotes }; |
|
|
} |
|
|
|
|
|
|
|
|
function escapeHtml(text) { |
|
|
const div = document.createElement('div'); |
|
|
div.textContent = text; |
|
|
return div.innerHTML; |
|
|
} |
|
|
|
|
|
|
|
|
function addMessage(role, content, save = true) { |
|
|
|
|
|
if (!content || (typeof content === 'string' && content.trim() === '')) { |
|
|
if (role === 'ai') { |
|
|
|
|
|
content = '์๋ต์ ์์ฑํ ์ ์์์ต๋๋ค. ๋ค์ ์๋ํด์ฃผ์ธ์.'; |
|
|
console.warn('[addMessage] AI ์๋ต์ด ๋น์ด์์ด ๊ธฐ๋ณธ ๋ฉ์์ง๋ฅผ ํ์ํฉ๋๋ค.'); |
|
|
} else { |
|
|
console.warn('[addMessage] ๋ด์ฉ์ด ๋น์ด์์ด ๋ฉ์์ง๋ฅผ ์ถ๊ฐํ์ง ์์ต๋๋ค. role:', role); |
|
|
return; |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
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'; |
|
|
|
|
|
|
|
|
if (role === 'ai') { |
|
|
const aiInfoDiv = document.createElement('div'); |
|
|
aiInfoDiv.className = 'ai-info'; |
|
|
aiInfoDiv.style.cssText = 'font-size: 12px; color: var(--text-secondary); margin-bottom: 8px; display: flex; align-items: center; gap: 6px;'; |
|
|
|
|
|
const modelType = answerModel ? (answerModel.startsWith('gemini:') ? 'Gemini' : 'Ollama') : 'AI'; |
|
|
const modelName = answerModel ? (answerModel.startsWith('gemini:') ? answerModel.replace('gemini:', '') : answerModel) : '๋ชจ๋ธ ๋ฏธ์ ํ'; |
|
|
|
|
|
aiInfoDiv.innerHTML = ` |
|
|
<span style="font-weight: 500;">${modelType} ๋ชจ๋ธ:</span> |
|
|
<span>${escapeHtml(modelName)}</span> |
|
|
`; |
|
|
contentDiv.appendChild(aiInfoDiv); |
|
|
} |
|
|
|
|
|
const bubble = document.createElement('div'); |
|
|
bubble.className = 'message-bubble'; |
|
|
|
|
|
|
|
|
if (role === 'ai') { |
|
|
|
|
|
|
|
|
|
|
|
const { formattedContent, footnotes } = formatContentWithFootnotes(content); |
|
|
bubble.innerHTML = formattedContent; |
|
|
|
|
|
|
|
|
if (footnotes.length > 0) { |
|
|
const footnoteContainer = document.createElement('div'); |
|
|
footnoteContainer.className = 'footnotes-container'; |
|
|
footnotes.forEach((note, index) => { |
|
|
const footnoteDiv = document.createElement('div'); |
|
|
footnoteDiv.className = 'footnote-item'; |
|
|
footnoteDiv.innerHTML = `<sup>[${index + 1}]</sup> <span>${escapeHtml(note)}</span>`; |
|
|
footnoteContainer.appendChild(footnoteDiv); |
|
|
}); |
|
|
bubble.appendChild(footnoteContainer); |
|
|
} |
|
|
} else { |
|
|
|
|
|
bubble.textContent = 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 getAIModelInfo() { |
|
|
const analysisModelType = selectedModel ? (selectedModel.startsWith('gemini:') ? 'Gemini' : 'Ollama') : null; |
|
|
const analysisModelName = selectedModel ? (selectedModel.startsWith('gemini:') ? selectedModel.replace('gemini:', '') : selectedModel) : null; |
|
|
|
|
|
const answerModelType = answerModel ? (answerModel.startsWith('gemini:') ? 'Gemini' : 'Ollama') : null; |
|
|
const answerModelName = answerModel ? (answerModel.startsWith('gemini:') ? answerModel.replace('gemini:', '') : answerModel) : null; |
|
|
|
|
|
let aiInfo = ''; |
|
|
if (analysisModelName && answerModelName) { |
|
|
if (analysisModelName === answerModelName) { |
|
|
|
|
|
aiInfo = `<div style="font-size: 11px; color: var(--text-secondary); margin-bottom: 6px; display: flex; align-items: center; gap: 6px;"> |
|
|
<span style="font-weight: 500;">${analysisModelType} ๋ชจ๋ธ:</span> |
|
|
<span>${escapeHtml(analysisModelName)}</span> |
|
|
</div>`; |
|
|
} else { |
|
|
|
|
|
aiInfo = `<div style="font-size: 11px; color: var(--text-secondary); margin-bottom: 6px; display: flex; flex-direction: column; gap: 2px;"> |
|
|
<div style="display: flex; align-items: center; gap: 6px;"> |
|
|
<span style="font-weight: 500;">๋ถ์ ๋ชจ๋ธ:</span> |
|
|
<span>${escapeHtml(analysisModelName)} (${analysisModelType})</span> |
|
|
</div> |
|
|
<div style="display: flex; align-items: center; gap: 6px;"> |
|
|
<span style="font-weight: 500;">๋ต๋ณ ๋ชจ๋ธ:</span> |
|
|
<span>${escapeHtml(answerModelName)} (${answerModelType})</span> |
|
|
</div> |
|
|
</div>`; |
|
|
} |
|
|
} else if (answerModelName) { |
|
|
aiInfo = `<div style="font-size: 11px; color: var(--text-secondary); margin-bottom: 6px; display: flex; align-items: center; gap: 6px;"> |
|
|
<span style="font-weight: 500;">${answerModelType} ๋ชจ๋ธ:</span> |
|
|
<span>${escapeHtml(answerModelName)}</span> |
|
|
</div>`; |
|
|
} |
|
|
|
|
|
return aiInfo; |
|
|
} |
|
|
|
|
|
|
|
|
function showTypingIndicator(step = '์ง๋ฌธ ๋ถ์ ์ค...') { |
|
|
let messageDiv = document.getElementById('typingIndicator'); |
|
|
|
|
|
if (!messageDiv) { |
|
|
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'; |
|
|
contentDiv.id = 'typingIndicatorContent'; |
|
|
|
|
|
messageDiv.appendChild(avatar); |
|
|
messageDiv.appendChild(contentDiv); |
|
|
chatContainer.appendChild(messageDiv); |
|
|
chatContainer.scrollTop = chatContainer.scrollHeight; |
|
|
} |
|
|
|
|
|
const aiInfo = getAIModelInfo(); |
|
|
const contentDiv = document.getElementById('typingIndicatorContent'); |
|
|
contentDiv.innerHTML = ` |
|
|
${aiInfo} |
|
|
<div style="display: flex; flex-direction: column; gap: 8px;"> |
|
|
<div style="display: flex; align-items: center; gap: 8px;"> |
|
|
<div class="typing-indicator" style="display: flex; gap: 4px;"> |
|
|
<div class="typing-dot"></div> |
|
|
<div class="typing-dot"></div> |
|
|
<div class="typing-dot"></div> |
|
|
</div> |
|
|
<span style="color: var(--text-secondary); font-size: 13px;">${step}</span> |
|
|
</div> |
|
|
</div> |
|
|
`; |
|
|
chatContainer.scrollTop = chatContainer.scrollHeight; |
|
|
} |
|
|
|
|
|
|
|
|
function updateTypingIndicator(step, details = '') { |
|
|
const contentDiv = document.getElementById('typingIndicatorContent'); |
|
|
if (contentDiv) { |
|
|
const aiInfo = getAIModelInfo(); |
|
|
const detailsHtml = details ? `<div style="font-size: 12px; color: var(--text-secondary); margin-left: 24px; margin-top: 4px;">${details}</div>` : ''; |
|
|
contentDiv.innerHTML = ` |
|
|
${aiInfo} |
|
|
<div style="display: flex; flex-direction: column; gap: 8px;"> |
|
|
<div style="display: flex; align-items: center; gap: 8px;"> |
|
|
<div class="typing-indicator" style="display: flex; gap: 4px;"> |
|
|
<div class="typing-dot"></div> |
|
|
<div class="typing-dot"></div> |
|
|
<div class="typing-dot"></div> |
|
|
</div> |
|
|
<span style="color: var(--text-secondary); font-size: 13px;">${step}</span> |
|
|
</div> |
|
|
${detailsHtml} |
|
|
</div> |
|
|
`; |
|
|
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 (!answerModel) { |
|
|
alert('๋ต๋ณ์ ์์ฑํ AI ๋ชจ๋ธ์ ์ ํํด์ฃผ์ธ์.\n\n"์ฌ์ฉ ๊ฐ๋ฅํ AI ๋ชฉ๋ก"์์ ๋ต๋ณ์ ์์ฑํ AI ๋ชจ๋ธ์ ์ ํํ์ธ์.'); |
|
|
return; |
|
|
} |
|
|
|
|
|
|
|
|
if (!currentSessionId) { |
|
|
currentSessionId = await createNewSession(); |
|
|
} |
|
|
|
|
|
|
|
|
addMessage('user', message, false); |
|
|
|
|
|
messageInput.value = ''; |
|
|
messageInput.style.height = 'auto'; |
|
|
|
|
|
|
|
|
messageInput.disabled = true; |
|
|
sendButton.disabled = true; |
|
|
|
|
|
|
|
|
showTypingIndicator('์ง๋ฌธ ๋ถ์ ์ค...'); |
|
|
|
|
|
|
|
|
const progressSteps = [ |
|
|
{ step: '์ง๋ฌธ ๋ถ์ ์ค...', details: '์ง๋ฌธ ๋ด์ฉ์ ๋ถ์ํ๊ณ ์์ต๋๋ค' }, |
|
|
{ step: '๊ด๋ จ ์ ๋ณด ๊ฒ์ ์ค...', details: '์น์์ค ๋ฐ์ดํฐ๋ฒ ์ด์ค์์ ๊ด๋ จ ์ ๋ณด๋ฅผ ์ฐพ๊ณ ์์ต๋๋ค' }, |
|
|
{ step: 'ํ์ฐจ๋ณ ๋ถ์ ์กฐํ ์ค...', details: 'ํ์ฐจ๋ณ ์์ฝ ์ ๋ณด๋ฅผ ๋ถ๋ฌ์ค๊ณ ์์ต๋๋ค' }, |
|
|
{ step: 'GraphRAG ๋ฐ์ดํฐ ์กฐํ ์ค...', details: '์บ๋ฆญํฐ ๊ด๊ณ ๋ฐ ์ฌ๊ฑด ์ ๋ณด๋ฅผ ์กฐํํ๊ณ ์์ต๋๋ค' }, |
|
|
{ step: '๋ฒกํฐ ๊ฒ์ ๋ฐ ๋ฆฌ๋ญํน ์ค...', details: '๊ด๋ จ๋ ๊ตฌ์ฒด์ ์ธ ๋ด์ฉ์ ๊ฒ์ํ๊ณ ์์ต๋๋ค' }, |
|
|
{ step: '์ปจํ
์คํธ ๊ตฌ์ฑ ์ค...', details: '์ฐธ๊ณ ํ ์ ๋ณด๋ฅผ ์ ๋ฆฌํ๊ณ ์์ต๋๋ค' }, |
|
|
{ step: 'AI ๋ต๋ณ ์์ฑ ์ค...', details: '๋ต๋ณ์ ์์ฑํ๊ณ ์์ต๋๋ค' } |
|
|
]; |
|
|
|
|
|
let currentStepIndex = 0; |
|
|
const startTime = Date.now(); |
|
|
|
|
|
|
|
|
const progressInterval = setInterval(() => { |
|
|
if (currentStepIndex < progressSteps.length) { |
|
|
const currentStep = progressSteps[currentStepIndex]; |
|
|
updateTypingIndicator(currentStep.step, currentStep.details); |
|
|
currentStepIndex++; |
|
|
} |
|
|
}, 1500); |
|
|
|
|
|
try { |
|
|
|
|
|
console.log('[sendMessage] ์์ฒญ ์ ์ก:', { |
|
|
message: message.substring(0, 50) + '...', |
|
|
analysis_model: selectedModel, |
|
|
answer_model: answerModel, |
|
|
file_ids: selectedFileIds, |
|
|
session_id: currentSessionId |
|
|
}); |
|
|
|
|
|
const response = await fetch('/api/chat', { |
|
|
method: 'POST', |
|
|
headers: { |
|
|
'Content-Type': 'application/json', |
|
|
}, |
|
|
body: JSON.stringify({ |
|
|
message: message, |
|
|
analysis_model: selectedModel || null, |
|
|
answer_model: answerModel || null, |
|
|
file_ids: selectedFileIds.length > 0 ? selectedFileIds : [], |
|
|
session_id: currentSessionId |
|
|
}) |
|
|
}); |
|
|
|
|
|
console.log('[sendMessage] ์๋ต ์ํ:', response.status, response.statusText); |
|
|
|
|
|
clearInterval(progressInterval); |
|
|
const elapsedTime = ((Date.now() - startTime) / 1000).toFixed(1); |
|
|
updateTypingIndicator('๋ต๋ณ ์์ ์๋ฃ', `์ด ${elapsedTime}์ด ์์`); |
|
|
|
|
|
|
|
|
setTimeout(() => { |
|
|
removeTypingIndicator(); |
|
|
}, 300); |
|
|
|
|
|
if (response.ok) { |
|
|
const data = await response.json(); |
|
|
console.log('[sendMessage] ์๋ต ๋ฐ์ดํฐ:', data); |
|
|
|
|
|
const aiResponse = data.response || data.message || '์๋ต์ ์์ฑํ๋ ์ค ์ค๋ฅ๊ฐ ๋ฐ์ํ์ต๋๋ค.'; |
|
|
console.log('[sendMessage] AI ์๋ต:', aiResponse ? aiResponse.substring(0, 100) + '...' : '(๋น์ด์์)'); |
|
|
|
|
|
|
|
|
if (!aiResponse || aiResponse.trim() === '') { |
|
|
console.warn('[sendMessage] ์๋ต์ด ๋น์ด์์ต๋๋ค. ๊ธฐ๋ณธ ๋ฉ์์ง๋ฅผ ํ์ํฉ๋๋ค.'); |
|
|
addMessage('ai', '์๋ต์ ์์ฑํ ์ ์์์ต๋๋ค. ๋ค์ ์๋ํด์ฃผ์ธ์.', false); |
|
|
} else { |
|
|
addMessage('ai', aiResponse, false); |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
if (data.session && data.session.title) { |
|
|
|
|
|
await loadChatHistory(); |
|
|
} else { |
|
|
|
|
|
await loadChatHistory(); |
|
|
} |
|
|
} else { |
|
|
const error = await response.json().catch(() => ({ error: '์๋ฒ ์ค๋ฅ' })); |
|
|
console.error('[sendMessage] ์๋ฒ ์ค๋ฅ:', 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'; |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
async function showWebnovelsModal() { |
|
|
const modal = document.getElementById('webnovelsModal'); |
|
|
modal.style.display = 'block'; |
|
|
await loadWebnovels(); |
|
|
await loadWebnovelModelFilter(); |
|
|
} |
|
|
|
|
|
function closeWebnovelsModal() { |
|
|
document.getElementById('webnovelsModal').style.display = 'none'; |
|
|
} |
|
|
|
|
|
async function loadWebnovelModelFilter() { |
|
|
try { |
|
|
const response = await fetch('/api/ollama/models'); |
|
|
if (!response.ok) throw new Error('๋ชจ๋ธ ๋ชฉ๋ก์ ๋ถ๋ฌ์ฌ ์ ์์ต๋๋ค.'); |
|
|
const data = await response.json(); |
|
|
const models = data.models || []; |
|
|
|
|
|
const filter = document.getElementById('webnovelModelFilter'); |
|
|
filter.innerHTML = '<option value="">๋ชจ๋ ๋ชจ๋ธ</option>'; |
|
|
models.forEach(model => { |
|
|
const option = document.createElement('option'); |
|
|
option.value = model.name; |
|
|
option.textContent = model.name; |
|
|
filter.appendChild(option); |
|
|
}); |
|
|
} catch (error) { |
|
|
console.error('๋ชจ๋ธ ํํฐ ๋ก๋ ์ค๋ฅ:', error); |
|
|
} |
|
|
} |
|
|
|
|
|
async function loadWebnovels() { |
|
|
const listContainer = document.getElementById('webnovelsList'); |
|
|
const modelFilter = document.getElementById('webnovelModelFilter'); |
|
|
const modelName = modelFilter ? modelFilter.value : ''; |
|
|
|
|
|
listContainer.innerHTML = '<div style="text-align: center; color: var(--text-secondary); padding: 24px;">์น์์ค ๋ชฉ๋ก์ ๋ถ๋ฌ์ค๋ ์ค...</div>'; |
|
|
|
|
|
try { |
|
|
const url = modelName ? `/api/files?model_name=${encodeURIComponent(modelName)}` : '/api/files'; |
|
|
const response = await fetch(url); |
|
|
if (!response.ok) throw new Error('์น์์ค ๋ชฉ๋ก์ ๋ถ๋ฌ์ฌ ์ ์์ต๋๋ค.'); |
|
|
|
|
|
const data = await response.json(); |
|
|
|
|
|
|
|
|
const files = data.files || []; |
|
|
|
|
|
if (!Array.isArray(files)) { |
|
|
console.error('์์์น ๋ชปํ ์๋ต ํ์:', data); |
|
|
throw new Error('์น์์ค ๋ชฉ๋ก ํ์์ด ์ฌ๋ฐ๋ฅด์ง ์์ต๋๋ค.'); |
|
|
} |
|
|
|
|
|
if (files.length === 0) { |
|
|
listContainer.innerHTML = '<div style="text-align: center; color: var(--text-secondary); padding: 24px;">์
๋ก๋๋ ์น์์ค์ด ์์ต๋๋ค.</div>'; |
|
|
return; |
|
|
} |
|
|
|
|
|
listContainer.innerHTML = ''; |
|
|
files.forEach(file => { |
|
|
const fileItem = document.createElement('div'); |
|
|
fileItem.className = 'webnovel-item'; |
|
|
|
|
|
const uploadedDate = new Date(file.uploaded_at); |
|
|
const formattedDate = uploadedDate.toLocaleDateString('ko-KR', { |
|
|
year: 'numeric', |
|
|
month: 'long', |
|
|
day: 'numeric' |
|
|
}); |
|
|
|
|
|
fileItem.innerHTML = ` |
|
|
<div class="webnovel-item-header"> |
|
|
<div class="webnovel-item-title">${escapeHtml(file.original_filename)}</div> |
|
|
</div> |
|
|
<div class="webnovel-item-meta"> |
|
|
<span>๐
${formattedDate}</span> |
|
|
<span>๐ฆ ${formatFileSize(file.file_size)}</span> |
|
|
<span>๐งฉ ์ฒญํฌ: ${file.chunk_count || 0}๊ฐ</span> |
|
|
${file.model_name ? `<span>๐ค ${escapeHtml(file.model_name)}</span>` : ''} |
|
|
</div> |
|
|
<div class="webnovel-item-actions"> |
|
|
<button class="webnovel-item-btn" onclick="viewWebnovelContent(${file.id}, '${escapeHtml(file.original_filename)}')">๋ด์ฉ ๋ณด๊ธฐ</button> |
|
|
</div> |
|
|
`; |
|
|
listContainer.appendChild(fileItem); |
|
|
}); |
|
|
} catch (error) { |
|
|
console.error('์น์์ค ๋ชฉ๋ก ๋ก๋ ์ค๋ฅ:', error); |
|
|
listContainer.innerHTML = `<div style="text-align: center; color: #ea4335; padding: 24px;">์ค๋ฅ: ${error.message}</div>`; |
|
|
} |
|
|
} |
|
|
|
|
|
let webnovelOriginalContent = ''; |
|
|
let webnovelSearchMatches = []; |
|
|
let webnovelCurrentMatchIndex = -1; |
|
|
|
|
|
async function viewWebnovelContent(fileId, filename) { |
|
|
const modal = document.getElementById('webnovelContentModal'); |
|
|
const title = document.getElementById('webnovelContentTitle'); |
|
|
const content = document.getElementById('webnovelContent'); |
|
|
const searchInput = document.getElementById('webnovelSearchInput'); |
|
|
|
|
|
modal.style.display = 'block'; |
|
|
title.textContent = filename; |
|
|
content.textContent = '๋ด์ฉ์ ๋ถ๋ฌ์ค๋ ์ค...'; |
|
|
searchInput.value = ''; |
|
|
webnovelOriginalContent = ''; |
|
|
webnovelSearchMatches = []; |
|
|
webnovelCurrentMatchIndex = -1; |
|
|
updateWebnovelSearchInfo(); |
|
|
|
|
|
try { |
|
|
const response = await fetch(`/api/files/${fileId}/content`); |
|
|
if (!response.ok) throw new Error('์น์์ค ๋ด์ฉ์ ๋ถ๋ฌ์ฌ ์ ์์ต๋๋ค.'); |
|
|
|
|
|
const data = await response.json(); |
|
|
webnovelOriginalContent = data.content; |
|
|
content.textContent = data.content; |
|
|
|
|
|
|
|
|
searchInput.focus(); |
|
|
} catch (error) { |
|
|
console.error('์น์์ค ๋ด์ฉ ๋ก๋ ์ค๋ฅ:', error); |
|
|
content.textContent = `์ค๋ฅ: ${error.message}`; |
|
|
} |
|
|
} |
|
|
|
|
|
function performWebnovelSearch() { |
|
|
const searchInput = document.getElementById('webnovelSearchInput'); |
|
|
const searchTerm = searchInput.value.trim(); |
|
|
const content = document.getElementById('webnovelContent'); |
|
|
|
|
|
if (!searchTerm) { |
|
|
clearWebnovelSearch(); |
|
|
return; |
|
|
} |
|
|
|
|
|
if (!webnovelOriginalContent) { |
|
|
webnovelOriginalContent = content.textContent; |
|
|
} |
|
|
|
|
|
|
|
|
const regex = new RegExp(`(${escapeRegex(searchTerm)})`, 'gi'); |
|
|
const highlightedContent = webnovelOriginalContent.replace(regex, '<mark style="background: #ffeb3b; padding: 2px 0; border-radius: 2px;">$1</mark>'); |
|
|
content.innerHTML = highlightedContent; |
|
|
|
|
|
|
|
|
webnovelSearchMatches = []; |
|
|
const matches = [...webnovelOriginalContent.matchAll(new RegExp(escapeRegex(searchTerm), 'gi'))]; |
|
|
matches.forEach(match => { |
|
|
webnovelSearchMatches.push(match.index); |
|
|
}); |
|
|
|
|
|
webnovelCurrentMatchIndex = webnovelSearchMatches.length > 0 ? 0 : -1; |
|
|
updateWebnovelSearchInfo(); |
|
|
updateWebnovelNavigationButtons(); |
|
|
|
|
|
if (webnovelCurrentMatchIndex >= 0) { |
|
|
scrollToMatch(webnovelCurrentMatchIndex); |
|
|
} |
|
|
} |
|
|
|
|
|
function clearWebnovelSearch() { |
|
|
const searchInput = document.getElementById('webnovelSearchInput'); |
|
|
const content = document.getElementById('webnovelContent'); |
|
|
|
|
|
searchInput.value = ''; |
|
|
if (webnovelOriginalContent) { |
|
|
content.textContent = webnovelOriginalContent; |
|
|
} |
|
|
webnovelSearchMatches = []; |
|
|
webnovelCurrentMatchIndex = -1; |
|
|
updateWebnovelSearchInfo(); |
|
|
updateWebnovelNavigationButtons(); |
|
|
} |
|
|
|
|
|
function scrollToMatch(index) { |
|
|
if (index < 0 || index >= webnovelSearchMatches.length) return; |
|
|
|
|
|
const content = document.getElementById('webnovelContent'); |
|
|
const container = document.getElementById('webnovelContentContainer'); |
|
|
const searchInput = document.getElementById('webnovelSearchInput'); |
|
|
const searchTerm = searchInput.value.trim(); |
|
|
|
|
|
if (!searchTerm) return; |
|
|
|
|
|
|
|
|
const marks = content.querySelectorAll('mark'); |
|
|
if (marks.length > 0 && marks[index]) { |
|
|
|
|
|
marks.forEach((mark, i) => { |
|
|
if (i === index) { |
|
|
mark.style.background = '#ff9800'; |
|
|
mark.style.fontWeight = 'bold'; |
|
|
mark.style.boxShadow = '0 0 4px rgba(255, 152, 0, 0.5)'; |
|
|
} else { |
|
|
mark.style.background = '#ffeb3b'; |
|
|
mark.style.fontWeight = 'normal'; |
|
|
mark.style.boxShadow = 'none'; |
|
|
} |
|
|
}); |
|
|
|
|
|
|
|
|
marks[index].scrollIntoView({ behavior: 'smooth', block: 'center' }); |
|
|
} else { |
|
|
|
|
|
const textNode = content.firstChild; |
|
|
if (textNode && textNode.nodeType === Node.TEXT_NODE) { |
|
|
const range = document.createRange(); |
|
|
const matchPos = webnovelSearchMatches[index]; |
|
|
const matchLength = searchTerm.length; |
|
|
|
|
|
try { |
|
|
range.setStart(textNode, matchPos); |
|
|
range.setEnd(textNode, matchPos + matchLength); |
|
|
|
|
|
|
|
|
const rect = range.getBoundingClientRect(); |
|
|
const containerRect = container.getBoundingClientRect(); |
|
|
|
|
|
|
|
|
const scrollTop = container.scrollTop + (rect.top - containerRect.top) - (containerRect.height / 2); |
|
|
container.scrollTo({ top: scrollTop, behavior: 'smooth' }); |
|
|
} catch (e) { |
|
|
console.error('์คํฌ๋กค ์ค๋ฅ:', e); |
|
|
} |
|
|
} |
|
|
} |
|
|
} |
|
|
|
|
|
function scrollToNextMatch() { |
|
|
if (webnovelSearchMatches.length === 0) return; |
|
|
webnovelCurrentMatchIndex = (webnovelCurrentMatchIndex + 1) % webnovelSearchMatches.length; |
|
|
scrollToMatch(webnovelCurrentMatchIndex); |
|
|
updateWebnovelSearchInfo(); |
|
|
} |
|
|
|
|
|
function scrollToPreviousMatch() { |
|
|
if (webnovelSearchMatches.length === 0) return; |
|
|
webnovelCurrentMatchIndex = webnovelCurrentMatchIndex <= 0 |
|
|
? webnovelSearchMatches.length - 1 |
|
|
: webnovelCurrentMatchIndex - 1; |
|
|
scrollToMatch(webnovelCurrentMatchIndex); |
|
|
updateWebnovelSearchInfo(); |
|
|
} |
|
|
|
|
|
function updateWebnovelSearchInfo() { |
|
|
const info = document.getElementById('webnovelSearchInfo'); |
|
|
const searchInput = document.getElementById('webnovelSearchInput'); |
|
|
const searchTerm = searchInput.value.trim(); |
|
|
|
|
|
if (!searchTerm || webnovelSearchMatches.length === 0) { |
|
|
info.textContent = ''; |
|
|
return; |
|
|
} |
|
|
|
|
|
if (webnovelCurrentMatchIndex >= 0) { |
|
|
info.textContent = `${webnovelCurrentMatchIndex + 1} / ${webnovelSearchMatches.length}`; |
|
|
} else { |
|
|
info.textContent = `์ด ${webnovelSearchMatches.length}๊ฐ`; |
|
|
} |
|
|
} |
|
|
|
|
|
function updateWebnovelNavigationButtons() { |
|
|
const prevBtn = document.getElementById('prevMatchBtn'); |
|
|
const nextBtn = document.getElementById('nextMatchBtn'); |
|
|
const hasMatches = webnovelSearchMatches.length > 0; |
|
|
|
|
|
prevBtn.disabled = !hasMatches; |
|
|
nextBtn.disabled = !hasMatches; |
|
|
} |
|
|
|
|
|
function escapeRegex(str) { |
|
|
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); |
|
|
} |
|
|
|
|
|
function closeWebnovelContentModal() { |
|
|
document.getElementById('webnovelContentModal').style.display = 'none'; |
|
|
clearWebnovelSearch(); |
|
|
} |
|
|
|
|
|
function formatFileSize(bytes) { |
|
|
if (bytes < 1024) return bytes + ' B'; |
|
|
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB'; |
|
|
return (bytes / (1024 * 1024)).toFixed(1) + ' MB'; |
|
|
} |
|
|
|
|
|
function escapeHtml(text) { |
|
|
const div = document.createElement('div'); |
|
|
div.textContent = text; |
|
|
return div.innerHTML; |
|
|
} |
|
|
|
|
|
|
|
|
window.onclick = function(event) { |
|
|
const webnovelsModal = document.getElementById('webnovelsModal'); |
|
|
const contentModal = document.getElementById('webnovelContentModal'); |
|
|
if (event.target === webnovelsModal) { |
|
|
closeWebnovelsModal(); |
|
|
} |
|
|
if (event.target === contentModal) { |
|
|
closeWebnovelContentModal(); |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
window.addEventListener('load', async () => { |
|
|
await loadChatHistory(); |
|
|
await loadAvailableModels(); |
|
|
await loadModels(); |
|
|
|
|
|
loadNovels(); |
|
|
|
|
|
if (selectedModel) { |
|
|
updateModelStatus('connected'); |
|
|
} |
|
|
messageInput.focus(); |
|
|
}); |
|
|
</script> |
|
|
</body> |
|
|
</html> |
|
|
|