soyailabs / templates /index.html
SOY NV AI
Add Hugging Face Spaces deployment support and file public/private feature
ae31891
raw
history blame
119 kB
<!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;
}
/* AI ๋ชจ๋ธ ์„ ํƒ ์˜์—ญ */
.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>
<!-- ์‚ฌ์šฉ ๊ฐ€๋Šฅํ•œ AI ๋ชฉ๋ก -->
<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>
<!-- 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="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="๋ฉ”๋‰ด ๋‹ซ๊ธฐ">&times;</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()">&times;</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()">&times;</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(); // ๋ชจ๋ธ ๋ณ€๊ฒฝ ์‹œ ์›น์†Œ์„ค ๋ชฉ๋ก ๋กœ๋“œ
});
// ์‚ฌ์šฉ ๊ฐ€๋Šฅํ•œ AI ๋ชฉ๋ก ์„ ํƒ ์ด๋ฒคํŠธ (์ตœ์ข… ๋‹ต๋ณ€์šฉ)
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 {
// ๋ชจ๋ธ์ด ์„ ํƒ๋˜์–ด ์žˆ์œผ๋ฉด ํ•ด๋‹น ๋ชจ๋ธ์˜ ํŒŒ์ผ๋งŒ, ์—†์œผ๋ฉด ๋ชจ๋“  ํŒŒ์ผ
// ๊ณต๊ฐœ ํŒŒ์ผ๋งŒ ์กฐํšŒ (public_only=true)
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 = '';
// API ์‘๋‹ต์€ { files: [...], model_stats: {...} } ํ˜•ํƒœ
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';
}
}
// ์‚ฌ์šฉ ๊ฐ€๋Šฅํ•œ AI ๋ชฉ๋ก ๋กœ๋“œ (๋ชจ๋“  ๋ชจ๋ธ)
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);
}
});
// Ollama ๋ชจ๋ธ ๊ทธ๋ฃน
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);
}
// Gemini ๋ชจ๋ธ ๊ทธ๋ฃน
if (geminiModels.length > 0) {
const optgroup = document.createElement('optgroup');
optgroup.label = 'โœจ Gemini ๋ชจ๋ธ';
geminiModels.forEach(model => {
const option = document.createElement('option');
option.value = model.name;
// "gemini:" ์ ‘๋‘์‚ฌ ์ œ๊ฑฐํ•˜์—ฌ ํ‘œ์‹œ
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);
}
});
// ๋“œ๋กญ๋‹ค์šด์— ์ถ”๊ฐ€
// Ollama ๋ชจ๋ธ ๊ทธ๋ฃน
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);
}
// Gemini ๋ชจ๋ธ ๊ทธ๋ฃน
if (geminiModels.length > 0) {
const optgroup = document.createElement('optgroup');
optgroup.label = 'โœจ Gemini ๋ชจ๋ธ';
geminiModels.forEach(model => {
const option = document.createElement('option');
option.value = model.name;
// "gemini:" ์ ‘๋‘์‚ฌ ์ œ๊ฑฐํ•˜์—ฌ ํ‘œ์‹œ
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();
}
}
// ๋Œ€ํ™” ํžˆ์Šคํ† ๋ฆฌ ๋กœ๋“œ (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');
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);
// alert ๋Œ€์‹  ์ฝ˜์†”์—๋งŒ ํ‘œ์‹œํ•˜๊ณ  ํžˆ์Šคํ† ๋ฆฌ ์ƒˆ๋กœ๊ณ ์นจ
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';
});
// Enter ํ‚ค ์ฒ˜๋ฆฌ
messageInput.addEventListener('keydown', function(e) {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
sendMessage();
}
});
// [๊ทผ๊ฑฐ: ] ํ˜•์‹์„ ๊ฐ์ฃผ๋กœ ๋ณ€ํ™˜ํ•˜๋Š” ํ•จ์ˆ˜
// ๋งˆํฌ๋‹ค์šด์„ HTML๋กœ ๋ณ€ํ™˜ํ•˜๋Š” ํ•จ์ˆ˜
function markdownToHtml(text) {
if (!text) return '';
let html = text;
// FOOTNOTE ํ”Œ๋ ˆ์ด์Šคํ™€๋” ๋ณดํ˜ธ (๋งˆํฌ๋‹ค์šด ๋ณ€ํ™˜ ์ „์— ๋ณดํ˜ธ)
// ์ด ๋ถ€๋ถ„์€ formatContentWithFootnotes์—์„œ ์ฒ˜๋ฆฌํ•˜๋ฏ€๋กœ ์—ฌ๊ธฐ์„œ๋Š” ๋ณดํ˜ธํ•˜์ง€ ์•Š์Œ
// ์ฝ”๋“œ ๋ธ”๋ก ์ฒ˜๋ฆฌ (```๋กœ ๊ฐ์‹ธ์ง„ ๋ถ€๋ถ„) - ๋จผ์ € ์ฒ˜๋ฆฌํ•˜์—ฌ ๋‹ค๋ฅธ ๋ณ€ํ™˜์— ์˜ํ–ฅ๋ฐ›์ง€ ์•Š๋„๋ก
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>');
// ์—ฐ์†๋œ <li> ํƒœ๊ทธ๋ฅผ <ul>๋กœ ๊ฐ์‹ธ๊ธฐ
html = html.replace(/(<li>.*?<\/li>(?:\s*<li>.*?<\/li>)*)/gs, '<ul>$1</ul>');
// ์ˆœ์„œ ์žˆ๋Š” ๋ฆฌ์ŠคํŠธ ์ฒ˜๋ฆฌ (1. 2. 3.)
html = html.replace(/^\d+\. (.+)$/gim, '<li>$1</li>');
// ์—ฐ์†๋œ <li> ํƒœ๊ทธ๋ฅผ <ol>๋กœ ๊ฐ์‹ธ๊ธฐ (์ด๋ฏธ <ul>๋กœ ๊ฐ์‹ธ์ง€์ง€ ์•Š์€ ๊ฒฝ์šฐ๋งŒ)
html = html.replace(/(?<!<ul>)(<li>.*?<\/li>(?:\s*<li>.*?<\/li>)*)(?!<\/ul>)/gs, '<ol>$1</ol>');
// ๋ณผ๋“œ ์ฒ˜๋ฆฌ (**text** ๋˜๋Š” __text__) - ์ฝ”๋“œ ๋ธ”๋ก/์ธ๋ผ์ธ ์ฝ”๋“œ๊ฐ€ ์•„๋‹Œ ๋ถ€๋ถ„๋งŒ
html = html.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>');
html = html.replace(/__(.+?)__/g, '<strong>$1</strong>');
// ์ดํƒค๋ฆญ ์ฒ˜๋ฆฌ (*text* ๋˜๋Š” _text_) - ๋ณผ๋“œ๊ฐ€ ์•„๋‹Œ ๊ฒฝ์šฐ๋งŒ
html = html.replace(/(?<!\*)\*([^*]+)\*(?!\*)/g, '<em>$1</em>');
html = html.replace(/(?<!_)_([^_]+)_(?!_)/g, '<em>$1</em>');
// ๋งํฌ ์ฒ˜๋ฆฌ ([text](url))
html = html.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2" target="_blank" rel="noopener noreferrer">$1</a>');
// ์ด๋ฏธ์ง€ ์ฒ˜๋ฆฌ (![alt](url))
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]/)) {
// ์ด๋ฏธ HTML ํƒœ๊ทธ๊ฐ€ ์•„๋‹Œ ์ผ๋ฐ˜ ํ…์ŠคํŠธ๋งŒ <p>๋กœ ๊ฐ์‹ธ๊ธฐ
if (!para.trim().startsWith('<')) {
return `<p>${para.trim()}</p>`;
}
}
return para;
}).join('\n');
// ๋‹จ์ผ ์ค„๋ฐ”๊ฟˆ์€ <br>๋กœ (์ด๋ฏธ ํƒœ๊ทธ๋กœ ๊ฐ์‹ธ์ง„ ๋ถ€๋ถ„์€ ์ œ์™ธ)
html = html.replace(/(?<!>)\n(?!<)/g, '<br>');
// ๋นˆ <p> ํƒœ๊ทธ ์ œ๊ฑฐ
html = html.replace(/<p>\s*<\/p>/g, '');
html = html.replace(/<p>(<[^>]+>)/g, '$1');
html = html.replace(/(<\/[^>]+>)<\/p>/g, '$1');
// ๋‚˜๋จธ์ง€ ํ…์ŠคํŠธ ์ด์Šค์ผ€์ดํ”„ (์ด๋ฏธ HTML ํƒœ๊ทธ๊ฐ€ ์•„๋‹Œ ๋ถ€๋ถ„๋งŒ)
// ํ•˜์ง€๋งŒ ์ด๋ฏธ escapeHtml์„ ์‚ฌ์šฉํ•œ ๋ถ€๋ถ„์ด ์žˆ์œผ๋ฏ€๋กœ ์ฃผ์˜ ํ•„์š”
// ๊ธฐ๋ณธ์ ์œผ๋กœ ๋งˆํฌ๋‹ค์šด ๋ณ€ํ™˜ ํ›„ ๋‚จ์€ ํ…์ŠคํŠธ๋Š” ์•ˆ์ „ํ•˜๊ฒŒ ์ฒ˜๋ฆฌ๋จ
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');
// ๋จผ์ € ๋งˆํฌ๋‹ค์šด์„ HTML๋กœ ๋ณ€ํ™˜
let htmlContent = markdownToHtml(cleanedContent);
// HTML์—์„œ๋„ "[๋‚ด์šฉ์„ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค]" ์ œ๊ฑฐ
htmlContent = htmlContent.replace(/\[๋‚ด์šฉ์„ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค\]/g, '');
htmlContent = htmlContent.replace(/๋‚ด์šฉ์„ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค/g, '');
htmlContent = htmlContent.replace(/<[^>]*>๋‚ด์šฉ์„ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค<\/[^>]*>/g, '');
// [๊ทผ๊ฑฐ: ๋‚ด์šฉ] ํŒจํ„ด ์ฐพ๊ธฐ (๋งˆํฌ๋‹ค์šด ๋ณ€ํ™˜ ํ›„์— ์ฒ˜๋ฆฌ)
const footnotePattern = /\[๊ทผ๊ฑฐ:\s*([^\]]+)\]/g;
const footnotes = [];
let footnoteIndex = 0;
// HTML์—์„œ [๊ทผ๊ฑฐ: ] ํŒจํ„ด์„ ์ฐพ์•„์„œ ๊ฐ์ฃผ๋กœ ๋ณ€ํ™˜
htmlContent = htmlContent.replace(footnotePattern, (match, footnoteText) => {
const cleanText = footnoteText.trim();
// "๋‚ด์šฉ์„ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค"๋Š” ๊ฐ์ฃผ์— ์ถ”๊ฐ€ํ•˜์ง€ ์•Š์Œ
if (cleanText.includes('๋‚ด์šฉ์„ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค')) {
return '';
}
footnoteIndex++;
footnotes.push(cleanText);
// ์ง์ ‘ HTML๋กœ ๋ณ€ํ™˜ (ํ”Œ๋ ˆ์ด์Šคํ™€๋” ์‚ฌ์šฉํ•˜์ง€ ์•Š์Œ)
return `<sup class="footnote-ref" data-footnote="${footnoteIndex}">[${footnoteIndex}]<span class="footnote-tooltip">${escapeHtml(cleanText)}</span></sup>`;
});
return { formattedContent: htmlContent, footnotes };
}
// HTML ์ด์Šค์ผ€์ดํ”„ ํ—ฌํผ
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
// ๋ฉ”์‹œ์ง€ ์ถ”๊ฐ€
function addMessage(role, content, save = true) {
// ๋‚ด์šฉ์ด ์—†์œผ๋ฉด ์ถ”๊ฐ€ํ•˜์ง€ ์•Š์Œ (๋‹จ, AI ์‘๋‹ต์ธ ๊ฒฝ์šฐ ๊ธฐ๋ณธ ๋ฉ”์‹œ์ง€ ํ‘œ์‹œ)
if (!content || (typeof content === 'string' && content.trim() === '')) {
if (role === 'ai') {
// 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';
// AI ์‘๋‹ต์ธ ๊ฒฝ์šฐ AI ์ •๋ณด ํ‘œ์‹œ
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';
// AI ์‘๋‹ต์ธ ๊ฒฝ์šฐ [๊ทผ๊ฑฐ: ] ํ˜•์‹์„ ๊ฐ์ฃผ๋กœ ๋ณ€ํ™˜
if (role === 'ai') {
// "[๋‚ด์šฉ์„ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค]" ํ…์ŠคํŠธ ์ œ๊ฑฐ๋Š” formatContentWithFootnotes์—์„œ ์ฒ˜๋ฆฌ
// ์—ฌ๊ธฐ์„œ๋Š” ์›๋ณธ ๋‚ด์šฉ์„ ๊ทธ๋Œ€๋กœ ์ „๋‹ฌ
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;
}
// AI ๋ชจ๋ธ ์ •๋ณด ๊ฐ€์ ธ์˜ค๊ธฐ
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();
}
// ์‚ฌ์šฉ์ž ๋ฉ”์‹œ์ง€ ํ‘œ์‹œ (DB ์ €์žฅ์€ /api/chat์—์„œ ์ฒ˜๋ฆฌ)
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); // 1.5์ดˆ๋งˆ๋‹ค ๋‹ค์Œ ๋‹จ๊ณ„๋กœ
try {
// API ํ˜ธ์ถœ
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);
}
// DB์— AI ์‘๋‹ต ์ €์žฅ (์ด๋ฏธ ๋ฐฑ์—”๋“œ์—์„œ ์ €์žฅ๋จ)
// ์„ธ์…˜ ๋ชฉ๋ก ์ƒˆ๋กœ๊ณ ์นจ (์ œ๋ชฉ ์—…๋ฐ์ดํŠธ ๋ฐ˜์˜)
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();
// API ์‘๋‹ต์€ { files: [...], model_stats: {...} } ํ˜•ํƒœ
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;
// HTML์ด ์žˆ๋Š” ๊ฒฝ์šฐ (ํ•˜์ด๋ผ์ดํŠธ๋œ ๊ฒฝ์šฐ)
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 {
// ํ…์ŠคํŠธ๋งŒ ์žˆ๋Š” ๊ฒฝ์šฐ Range API ์‚ฌ์šฉ
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);
// Range์˜ ์œ„์น˜ ์ •๋ณด ๊ฐ€์ ธ์˜ค๊ธฐ
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(); // ์‚ฌ์šฉ ๊ฐ€๋Šฅํ•œ AI ๋ชฉ๋ก ๋กœ๋“œ (๋ชจ๋“  ๋ชจ๋ธ)
await loadModels(); // AI ๋ชจ๋ธ ์„ ํƒ ๋กœ๋“œ (ํ•™์Šต๋œ ์›น์†Œ์„ค์ด ์žˆ๋Š” ๋ชจ๋ธ๋งŒ)
// ๋ชจ๋ธ ์„ ํƒ ์—ฌ๋ถ€์™€ ๊ด€๊ณ„์—†์ด ์›น์†Œ์„ค ๋ชฉ๋ก ๋กœ๋“œ (๋ชจ๋ธ์ด ์„ ํƒ๋˜์–ด ์žˆ์œผ๋ฉด ํ•ด๋‹น ๋ชจ๋ธ์˜ ํŒŒ์ผ๋งŒ, ์—†์œผ๋ฉด ๋ชจ๋“  ํŒŒ์ผ)
loadNovels();
// ์ดˆ๊ธฐ ๋ชจ๋ธ ์ƒํƒœ ์—…๋ฐ์ดํŠธ
if (selectedModel) {
updateModelStatus('connected');
}
messageInput.focus();
});
</script>
</body>
</html>