soyailabs / templates /webnovels.html
GitHub Actions
Auto-deploy from GitHub Actions - 2025-12-12 07:40:57
9851df5
<!DOCTYPE html>
<html lang="ko">
<head>
<script type="text/javascript">
(function(c,l,a,r,i,t,y){
c[a]=c[a]||function(){(c[a].q=c[a].q||[]).push(arguments)};
t=l.createElement(r);t.async=1;t.src="https://www.clarity.ms/tag/"+i;
y=l.getElementsByTagName(r)[0];y.parentNode.insertBefore(t,y);
})(window, document, "clarity", "script", "ujskfvh0bu");
</script>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>์—…๋กœ๋“œ๋œ ์›น์†Œ์„ค - SOY NV AI</title>
<link rel="preconnect" href="https://fonts.googleapis.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&display=swap" rel="stylesheet">
<script src="https://cdn.jsdelivr.net/npm/marked@11.1.1/marked.min.js"></script>
<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;
--ai-bg: #e8f0fe;
}
body {
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: var(--bg-secondary);
color: var(--text-primary);
}
.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 rgba(0, 0, 0, 0.1);
}
.header-title {
font-size: 20px;
font-weight: 500;
display: flex;
align-items: center;
gap: 12px;
}
.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;
}
.header-title span:first-child {
display: none;
}
.menu-toggle {
display: block;
}
.header-actions {
display: none;
}
}
.btn {
padding: 8px 16px;
border: none;
border-radius: 6px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
text-decoration: none;
display: inline-block;
}
.btn-primary {
background: var(--accent);
color: white;
}
.btn-primary:hover {
background: var(--accent-hover);
}
.btn-secondary {
background: var(--bg-tertiary);
color: var(--text-primary);
}
.btn-secondary:hover {
background: var(--border);
}
.container {
max-width: 1200px;
margin: 0 auto;
padding: 24px;
}
.page-header {
margin-bottom: 24px;
}
.page-header h1 {
font-size: 28px;
font-weight: 600;
margin-bottom: 8px;
}
.page-header p {
color: var(--text-secondary);
}
.card {
background: var(--bg-primary);
border-radius: 8px;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
padding: 24px;
margin-bottom: 24px;
}
.filter-section {
margin-bottom: 16px;
display: flex;
gap: 12px;
align-items: center;
}
.filter-section select {
padding: 8px 12px;
border: 1px solid var(--border);
border-radius: 6px;
font-size: 14px;
background: var(--bg-primary);
color: var(--text-primary);
flex: 1;
max-width: 300px;
}
.webnovel-item {
padding: 16px;
margin-bottom: 12px;
border: 1px solid var(--border);
border-radius: 8px;
background: var(--bg-secondary);
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;
margin-bottom: 12px;
}
.webnovel-item-actions {
display: flex;
gap: 8px;
}
.webnovel-item-btn {
padding: 6px 16px;
background: var(--accent);
color: white;
border: none;
border-radius: 6px;
font-size: 13px;
font-weight: 500;
cursor: pointer;
transition: background 0.2s;
}
.webnovel-item-btn:hover {
background: var(--accent-hover);
transform: translateY(-1px);
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
/* ๋ชจ๋‹ฌ ์Šคํƒ€์ผ */
.modal {
display: none;
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.5);
z-index: 1000;
align-items: center;
justify-content: center;
}
.modal.active {
display: flex;
}
.modal-content {
background: var(--bg-primary);
border-radius: 8px;
padding: 0;
width: 90%;
max-width: 1000px;
max-height: 90vh;
overflow: hidden;
display: flex;
flex-direction: column;
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 20px 24px;
border-bottom: 1px solid var(--border);
}
.modal-title {
font-size: 20px;
font-weight: 500;
}
.modal-close {
background: none;
border: none;
font-size: 24px;
cursor: pointer;
color: var(--text-secondary);
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;
flex: 1;
}
mark {
background: #ffeb3b;
padding: 2px 0;
border-radius: 2px;
}
/* ๋งˆํฌ๋‹ค์šด ์Šคํƒ€์ผ */
.markdown-content {
line-height: 1.6;
}
.markdown-content h1,
.markdown-content h2,
.markdown-content h3,
.markdown-content h4,
.markdown-content h5,
.markdown-content h6 {
margin-top: 24px;
margin-bottom: 16px;
font-weight: 600;
line-height: 1.25;
}
.markdown-content h1 {
font-size: 2em;
border-bottom: 1px solid var(--border);
padding-bottom: 8px;
}
.markdown-content h2 {
font-size: 1.5em;
border-bottom: 1px solid var(--border);
padding-bottom: 8px;
}
.markdown-content h3 {
font-size: 1.25em;
}
.markdown-content p {
margin-bottom: 16px;
}
.markdown-content ul,
.markdown-content ol {
margin-bottom: 16px;
padding-left: 30px;
}
.markdown-content li {
margin-bottom: 8px;
}
.markdown-content code {
background: var(--bg-tertiary);
padding: 2px 6px;
border-radius: 3px;
font-family: 'Courier New', monospace;
font-size: 0.9em;
}
.markdown-content pre {
background: var(--bg-tertiary);
padding: 16px;
border-radius: 6px;
overflow-x: auto;
margin-bottom: 16px;
}
.markdown-content pre code {
background: none;
padding: 0;
}
.markdown-content blockquote {
border-left: 4px solid var(--accent);
padding-left: 16px;
margin-left: 0;
margin-bottom: 16px;
color: var(--text-secondary);
}
.markdown-content table {
border-collapse: collapse;
width: 100%;
margin-bottom: 16px;
}
.markdown-content table th,
.markdown-content table td {
border: 1px solid var(--border);
padding: 8px 12px;
text-align: left;
}
.markdown-content table th {
background: var(--bg-tertiary);
font-weight: 600;
}
.markdown-content strong {
font-weight: 600;
}
.markdown-content em {
font-style: italic;
}
.markdown-content a {
color: var(--accent);
text-decoration: none;
}
.markdown-content a:hover {
text-decoration: underline;
}
/* ๋ชฉ์ฐจ ์‚ฌ์ด๋“œ๋ฐ” ์Šคํƒ€์ผ */
.content-modal-wrapper {
display: flex;
height: calc(90vh - 100px);
overflow: hidden;
}
.content-sidebar {
width: 125px;
background: var(--bg-secondary);
border-right: 1px solid var(--border);
overflow-y: auto;
padding: 12px;
flex-shrink: 0;
}
.content-sidebar-title {
font-size: 12px;
font-weight: 600;
color: var(--text-primary);
margin-bottom: 8px;
padding-bottom: 6px;
border-bottom: 2px solid var(--accent);
}
.content-toc {
list-style: none;
padding: 0;
margin: 0;
}
.content-toc-item {
margin-bottom: 4px;
}
.content-toc-link {
display: block;
padding: 6px 8px;
color: var(--text-primary);
text-decoration: none;
border-radius: 4px;
font-size: 11px;
transition: all 0.2s;
cursor: pointer;
line-height: 1.4;
}
.content-toc-link:hover {
background: var(--bg-tertiary);
color: var(--accent);
}
.content-toc-link.active {
background: var(--accent);
color: white;
}
.content-main {
flex: 1;
overflow-y: auto;
padding: 24px;
}
.content-section {
scroll-margin-top: 20px;
}
/* GraphRAG ์‚ฌ์ด๋“œ๋ฐ” ์Šคํƒ€์ผ */
.episode-sidebar-item {
padding: 10px 12px;
border-radius: 6px;
cursor: pointer;
transition: all 0.2s;
font-size: 14px;
color: var(--text-primary);
margin-bottom: 4px;
border: 1px solid transparent;
}
.episode-sidebar-item:hover {
background: var(--ai-bg);
border-color: var(--accent);
}
.episode-sidebar-item.active {
background: var(--accent);
color: white;
font-weight: 500;
}
.episode-sidebar-item.active:hover {
background: var(--accent-hover);
}
</style>
</head>
<body>
<div class="header">
<div class="header-title">
<span>๐Ÿ“š</span>
<span>์—…๋กœ๋“œ๋œ ์›น์†Œ์„ค</span>
</div>
<button class="menu-toggle" onclick="toggleMobileMenu()" aria-label="๋ฉ”๋‰ด ์—ด๊ธฐ">โ˜ฐ</button>
<div class="header-actions">
<span style="margin-right: 12px; color: var(--text-secondary);">{{ current_user.nickname or current_user.username }}</span>
<a href="{{ url_for('main.index') }}" class="btn btn-secondary">์ฐฝ์ž‘ ์–ด์‹œ์Šคํ„ดํŠธ</a>
{% if current_user.is_admin %}
<a href="{{ url_for('main.admin') }}" class="btn btn-secondary">๊ด€๋ฆฌ์ž ํŽ˜์ด์ง€</a>
{% endif %}
<a href="{{ url_for('main.logout') }}" class="btn btn-secondary">๋กœ๊ทธ์•„์›ƒ</a>
</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.index') }}" 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="container">
<div class="page-header">
<h1>์—…๋กœ๋“œ๋œ ์›น์†Œ์„ค</h1>
<p>์—…๋กœ๋“œ๋œ ์›น์†Œ์„ค ๋ชฉ๋ก์„ ํ™•์ธํ•˜๊ณ  ๋‚ด์šฉ์„ ๋ณผ ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.</p>
</div>
<div class="card">
<div class="filter-section" style="display: flex; gap: 16px; align-items: center; flex-wrap: wrap;">
<div style="display: flex; align-items: center; gap: 8px; flex: 1; min-width: 250px;">
<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="webnovelTitleSearch" placeholder="์ œ๋ชฉ์œผ๋กœ ๊ฒ€์ƒ‰..."
style="flex: 1; padding: 8px 12px; border: 1px solid var(--border); border-radius: 6px; background: var(--bg-primary); color: var(--text-primary); font-size: 14px; outline: none;"
oninput="filterWebnovelsByTitle()"
onkeydown="if(event.key === 'Escape') { document.getElementById('webnovelTitleSearch').value = ''; filterWebnovelsByTitle(); }">
</div>
<div style="display: flex; align-items: center; gap: 8px;">
<label for="webnovelModelFilter" style="font-size: 14px; font-weight: 500; color: var(--text-primary);">๋ชจ๋ธ ํ•„ํ„ฐ:</label>
<select id="webnovelModelFilter" onchange="loadWebnovels()" style="min-width: 200px;">
<option value="">๋ชจ๋“  ๋ชจ๋ธ</option>
</select>
</div>
</div>
<div id="webnovelsList" style="min-height: 400px;">
<div style="text-align: center; color: var(--text-secondary); padding: 24px;">
์›น์†Œ์„ค ๋ชฉ๋ก์„ ๋ถˆ๋Ÿฌ์˜ค๋Š” ์ค‘...
</div>
</div>
</div>
</div>
<!-- ์›น์†Œ์„ค ์š”์•ฝ ๋ณด๊ธฐ ๋ชจ๋‹ฌ -->
<div id="webnovelSummaryModal" class="modal">
<div class="modal-content">
<div class="modal-header">
<h2 class="modal-title" id="webnovelSummaryTitle">์š”์•ฝ ๋‚ด์šฉ</h2>
<button class="modal-close" onclick="closeWebnovelSummaryModal()">&times;</button>
</div>
<!-- ๋ชฉ์ฐจ์™€ ๋‚ด์šฉ ์˜์—ญ -->
<div class="content-modal-wrapper">
<!-- ๋ชฉ์ฐจ ์‚ฌ์ด๋“œ๋ฐ” -->
<div class="content-sidebar">
<div class="content-sidebar-title">๐Ÿ“‘ ๋ชฉ์ฐจ</div>
<ul class="content-toc" id="webnovelSummaryTOC">
<li class="content-toc-item">
<div style="padding: 8px 12px; color: var(--text-secondary); font-size: 12px;">๋ชฉ์ฐจ๋ฅผ ๋ถˆ๋Ÿฌ์˜ค๋Š” ์ค‘...</div>
</li>
</ul>
</div>
<!-- ๋ฉ”์ธ ๋‚ด์šฉ ์˜์—ญ -->
<div class="content-main" id="webnovelSummaryContentContainer" style="height: calc(90vh - 100px); overflow-y: auto;">
<div id="webnovelSummaryContent" style="white-space: pre-wrap; font-family: inherit; line-height: 1.6; color: var(--text-primary); padding: 16px;">
๋‚ด์šฉ์„ ๋ถˆ๋Ÿฌ์˜ค๋Š” ์ค‘...
</div>
</div>
</div>
</div>
</div>
<!-- GraphRAG ๋ชจ๋‹ฌ -->
<div id="graphRAGModal" class="modal">
<div class="modal-content" style="max-width: 1600px; width: 95%; height: 90vh; display: flex; flex-direction: column; padding: 0;">
<div class="modal-header" style="flex-shrink: 0; padding: 24px 24px 16px 24px; margin-bottom: 0;">
<div class="modal-title" id="graphRAGModalTitle">ํšŒ์ฐจ๋ณ„ ์บ๋ฆญํ„ฐ ๊ด€๊ณ„ ๋ถ„์„</div>
<button class="modal-close" onclick="closeGraphRAGModal()">&times;</button>
</div>
<div style="display: flex; flex: 1; overflow: hidden;">
<!-- ์ขŒ์ธก ์‚ฌ์ด๋“œ๋ฐ” (ํšŒ์ฐจ ๋ชฉ๋ก) -->
<div id="graphRAGSidebar" style="width: 250px; background: var(--bg-secondary); border-right: 1px solid var(--border); overflow-y: auto; flex-shrink: 0; padding: 16px;">
<div style="font-size: 14px; font-weight: 600; color: var(--text-secondary); margin-bottom: 12px; padding-bottom: 8px; border-bottom: 1px solid var(--border);">
ํšŒ์ฐจ ๋ชฉ๋ก
</div>
<div id="graphRAGEpisodeList" style="display: flex; flex-direction: column; gap: 4px;">
<div style="text-align: center; padding: 24px; color: var(--text-secondary); font-size: 13px;">
ํšŒ์ฐจ ๋ชฉ๋ก์„ ๋ถˆ๋Ÿฌ์˜ค๋Š” ์ค‘...
</div>
</div>
</div>
<!-- ์šฐ์ธก ์ฝ˜ํ…์ธ  ์˜์—ญ -->
<div id="graphRAGContent" style="flex: 1; overflow-y: auto; padding: 24px;">
<div style="text-align: center; padding: 24px; color: var(--text-secondary);">
GraphRAG ๋ฐ์ดํ„ฐ๋ฅผ ๋ถˆ๋Ÿฌ์˜ค๋Š” ์ค‘...
</div>
</div>
</div>
</div>
</div>
<!-- GraphRAG ๊ทธ๋ž˜ํ”„ ์‹œ๊ฐํ™” ๋ชจ๋‹ฌ -->
<div id="graphRAGVisualizationModal" class="modal">
<div class="modal-content" style="max-width: 1600px; width: 95%; height: 90vh;">
<div class="modal-header">
<div class="modal-title" id="graphRAGVisualizationModalTitle">์บ๋ฆญํ„ฐ ๊ด€๊ณ„๋„ ์‹œ๊ฐํ™”</div>
<button class="modal-close" onclick="closeGraphRAGVisualizationModal()">&times;</button>
</div>
<div style="padding: 16px; border-bottom: 1px solid var(--border); background: var(--bg-secondary); display: flex; gap: 12px; align-items: center; flex-wrap: wrap;">
<div style="position: relative;">
<button id="episodeFilterToggle" onclick="toggleWebnovelEpisodeFilter()" style="padding: 8px 16px; background: var(--bg-primary); border: 1px solid var(--border); border-radius: 6px; font-size: 14px; cursor: pointer; display: flex; align-items: center; gap: 8px; color: var(--text-primary); font-weight: 500;">
<span>ํšŒ์ฐจ ํ•„ํ„ฐ</span>
<span id="episodeFilterToggleIcon" style="font-size: 12px; transition: transform 0.2s;">โ–ผ</span>
</button>
<div id="episodeFilterDropdown" style="display: none; position: absolute; top: 100%; left: 0; margin-top: 4px; background: var(--bg-primary); border: 1px solid var(--border); border-radius: 6px; box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); z-index: 1000; min-width: 250px; max-width: 400px; max-height: 400px; overflow-y: auto;">
<div style="padding: 12px; border-bottom: 1px solid var(--border);">
<label style="font-size: 13px; cursor: pointer; padding: 6px 8px; border-radius: 4px; display: flex; align-items: center; transition: background 0.2s;" onmouseover="this.style.background='var(--bg-secondary)'" onmouseout="this.style.background='transparent'">
<input type="checkbox" id="episodeFilterAll" onchange="handleWebnovelEpisodeFilterAllChange()" style="margin-right: 8px;">
<span style="font-weight: 500;">์ „์ฒด ํšŒ์ฐจ</span>
</label>
</div>
<div id="episodeFilterList" style="padding: 8px; display: flex; flex-direction: column; gap: 2px;">
<!-- ํšŒ์ฐจ ์ฒดํฌ๋ฐ•์Šค ๋ชฉ๋ก์ด ์—ฌ๊ธฐ์— ๋™์ ์œผ๋กœ ์ถ”๊ฐ€๋ฉ๋‹ˆ๋‹ค -->
</div>
</div>
</div>
<label style="font-size: 14px; font-weight: 500; margin-left: 16px;">๋…ธ๋“œ ํƒ€์ž…:</label>
<label style="font-size: 13px; margin-left: 8px;">
<input type="checkbox" id="showCharacters" checked onchange="updateGraphVisualization()" style="margin-right: 4px;">
์ธ๋ฌผ
</label>
<label style="font-size: 13px; margin-left: 8px;">
<input type="checkbox" id="showLocations" checked onchange="updateGraphVisualization()" style="margin-right: 4px;">
์žฅ์†Œ
</label>
<label style="font-size: 13px; margin-left: 8px;">
<input type="checkbox" id="showEvents" checked onchange="updateGraphVisualization()" style="margin-right: 4px;">
์‚ฌ๊ฑด
</label>
<button onclick="resetGraphView()" style="padding: 6px 16px; background: var(--accent); color: white; border: none; border-radius: 6px; cursor: pointer; font-size: 13px; margin-left: auto;">
๋ทฐ ๋ฆฌ์…‹
</button>
</div>
<div id="graphRAGVisualizationContent" style="height: calc(90vh - 120px); position: relative; background: var(--bg-primary);">
<div style="text-align: center; padding: 24px; color: var(--text-secondary); position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%);">
๊ทธ๋ž˜ํ”„๋ฅผ ๋ถˆ๋Ÿฌ์˜ค๋Š” ์ค‘...
</div>
</div>
</div>
</div>
<!-- vis-network ๋ผ์ด๋ธŒ๋Ÿฌ๋ฆฌ -->
<script type="text/javascript" src="https://unpkg.com/vis-network@latest/standalone/umd/vis-network.min.js"></script>
<!-- ์›น์†Œ์„ค ๋‚ด์šฉ ๋ณด๊ธฐ ๋ชจ๋‹ฌ -->
<div id="webnovelContentModal" class="modal">
<div class="modal-content">
<div class="modal-header">
<h2 class="modal-title" 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="content-modal-wrapper">
<!-- ๋ชฉ์ฐจ ์‚ฌ์ด๋“œ๋ฐ” -->
<div class="content-sidebar">
<div class="content-sidebar-title">๐Ÿ“‘ ๋ชฉ์ฐจ</div>
<ul class="content-toc" id="webnovelTOC">
<li class="content-toc-item">
<div style="padding: 8px 12px; color: var(--text-secondary); font-size: 12px;">๋ชฉ์ฐจ๋ฅผ ๋ถˆ๋Ÿฌ์˜ค๋Š” ์ค‘...</div>
</li>
</ul>
</div>
<!-- ๋ฉ”์ธ ๋‚ด์šฉ ์˜์—ญ -->
<div class="content-main" id="webnovelContentContainer">
<div id="webnovelContent" style="white-space: pre-wrap; font-family: inherit; line-height: 1.6; color: var(--text-primary);">
๋‚ด์šฉ์„ ๋ถˆ๋Ÿฌ์˜ค๋Š” ์ค‘...
</div>
</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();
}
}
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
function renderMarkdown(text) {
if (!text) return '';
try {
// marked.js๊ฐ€ ๋กœ๋“œ๋˜์—ˆ๋Š”์ง€ ํ™•์ธ
if (typeof marked !== 'undefined') {
return marked.parse(text);
} else {
// marked.js๊ฐ€ ๋กœ๋“œ๋˜์ง€ ์•Š์€ ๊ฒฝ์šฐ ๊ธฐ๋ณธ ํ…์ŠคํŠธ ๋ฐ˜ํ™˜
return escapeHtml(text).replace(/\n/g, '<br>');
}
} catch (e) {
console.error('๋งˆํฌ๋‹ค์šด ๋ Œ๋”๋ง ์˜ค๋ฅ˜:', e);
return escapeHtml(text).replace(/\n/g, '<br>');
}
}
function formatFileSize(bytes) {
if (bytes === 0) return '0 Bytes';
const k = 1024;
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return Math.round(bytes / Math.pow(k, i) * 100) / 100 + ' ' + sizes[i];
}
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);
}
}
let allWebnovels = []; // ์ „์ฒด ์›น์†Œ์„ค ๋ชฉ๋ก ์ €์žฅ
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 {
// ๊ณต๊ฐœ ํŒŒ์ผ๋งŒ ์กฐํšŒ (public_only=true)
const url = modelName
? `/api/files?model_name=${encodeURIComponent(modelName)}&public_only=true`
: '/api/files?public_only=true';
const response = await fetch(url, {
credentials: 'include'
});
if (!response.ok) throw new Error('์›น์†Œ์„ค ๋ชฉ๋ก์„ ๋ถˆ๋Ÿฌ์˜ฌ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.');
const data = await response.json();
// API ์‘๋‹ต์€ { files: [...], model_stats: {...} } ํ˜•ํƒœ
allWebnovels = data.files || [];
// ๊ฐ€๋‚˜๋‹ค์ˆœ ์ •๋ ฌ (๊ธฐ๋ณธ๊ฐ’)
allWebnovels.sort((a, b) => {
return a.original_filename.localeCompare(b.original_filename, 'ko');
});
if (!Array.isArray(allWebnovels)) {
console.error('์˜ˆ์ƒ์น˜ ๋ชปํ•œ ์‘๋‹ต ํ˜•์‹:', data);
throw new Error('์›น์†Œ์„ค ๋ชฉ๋ก ํ˜•์‹์ด ์˜ฌ๋ฐ”๋ฅด์ง€ ์•Š์Šต๋‹ˆ๋‹ค.');
}
// ์ œ๋ชฉ ๊ฒ€์ƒ‰ ํ•„ํ„ฐ ์ ์šฉ
filterWebnovelsByTitle();
} catch (error) {
console.error('์›น์†Œ์„ค ๋ชฉ๋ก ๋กœ๋“œ ์˜ค๋ฅ˜:', error);
listContainer.innerHTML = `<div style="text-align: center; color: #ea4335; padding: 24px;">์˜ค๋ฅ˜: ${error.message}</div>`;
}
}
function filterWebnovelsByTitle() {
const listContainer = document.getElementById('webnovelsList');
const searchInput = document.getElementById('webnovelTitleSearch');
const searchTerm = searchInput ? searchInput.value.trim().toLowerCase() : '';
if (allWebnovels.length === 0) {
listContainer.innerHTML = '<div style="text-align: center; color: var(--text-secondary); padding: 24px;">์—…๋กœ๋“œ๋œ ์›น์†Œ์„ค์ด ์—†์Šต๋‹ˆ๋‹ค.</div>';
return;
}
// ์ œ๋ชฉ์œผ๋กœ ํ•„ํ„ฐ๋ง
const filteredFiles = searchTerm
? allWebnovels.filter(file => file.original_filename.toLowerCase().includes(searchTerm))
: allWebnovels;
if (filteredFiles.length === 0) {
listContainer.innerHTML = `<div style="text-align: center; color: var(--text-secondary); padding: 24px;">๊ฒ€์ƒ‰ ๊ฒฐ๊ณผ๊ฐ€ ์—†์Šต๋‹ˆ๋‹ค. (๊ฒ€์ƒ‰์–ด: "${escapeHtml(searchTerm)}")</div>`;
return;
}
listContainer.innerHTML = '';
filteredFiles.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'
});
// ๊ฒ€์ƒ‰์–ด ํ•˜์ด๋ผ์ดํŠธ
let displayTitle = escapeHtml(file.original_filename);
if (searchTerm) {
const regex = new RegExp(`(${escapeRegex(searchTerm)})`, 'gi');
displayTitle = displayTitle.replace(regex, '<mark style="background: #ffeb3b; padding: 2px 4px; border-radius: 2px;">$1</mark>');
}
fileItem.innerHTML = `
<div class="webnovel-item-header">
<div class="webnovel-item-title">${displayTitle}</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="viewWebnovelSummary(${file.id}, '${escapeHtml(file.original_filename)}')" style="background: #17a2b8; color: white; margin-right: 8px;">๐Ÿ“‹ ์š”์•ฝ ๋ณด๊ธฐ</button>
<button class="webnovel-item-btn" onclick="viewWebnovelContent(${file.id}, '${escapeHtml(file.original_filename)}')" style="margin-right: 8px;">๐Ÿ“– ๋‚ด์šฉ ๋ณด๊ธฐ</button>
<button class="webnovel-item-btn" onclick="viewGraphRAG(${file.id}, '${escapeHtml(file.original_filename)}')" style="background: #9c27b0; color: white; margin-right: 8px;">๐Ÿ”— ํšŒ์ฐจ๋ณ„ ์บ๋ฆญํ„ฐ ๊ด€๊ณ„ ๋ถ„์„</button>
<button class="webnovel-item-btn" onclick="viewGraphRAGVisualization(${file.id}, '${escapeHtml(file.original_filename)}')" style="background: #28a745; color: white;">๐Ÿ“Š ์บ๋ฆญํ„ฐ ๊ด€๊ณ„๋„ ์‹œ๊ฐํ™”</button>
</div>
`;
listContainer.appendChild(fileItem);
});
}
let webnovelOriginalContent = '';
let webnovelSearchMatches = [];
let webnovelCurrentMatchIndex = -1;
let webnovelTOC = [];
function parseTableOfContents(content) {
const toc = [];
const lines = content.split('\n');
const episodePattern = /^#\s*(์ž‘ํ’ˆ์„ค๋ช…|\d+ํ™”)/;
lines.forEach((line, index) => {
const match = line.match(episodePattern);
if (match) {
const title = match[1];
const id = `section-${toc.length}`;
toc.push({
id: id,
title: title === '์ž‘ํ’ˆ์„ค๋ช…' ? '์ž‘ํ’ˆ์„ค๋ช…' : title,
lineIndex: index,
element: null
});
}
});
return toc;
}
function renderContentWithSections(content, toc) {
const lines = content.split('\n');
let html = '';
let currentSectionIndex = 0;
lines.forEach((line, index) => {
if (currentSectionIndex < toc.length && index === toc[currentSectionIndex].lineIndex) {
// ์„น์…˜ ์‹œ์ž‘
if (currentSectionIndex > 0) {
html += '</div>'; // ์ด์ „ ์„น์…˜ ๋‹ซ๊ธฐ
}
html += `<div id="${toc[currentSectionIndex].id}" class="content-section">`;
html += `<h2 style="font-size: 20px; font-weight: 600; margin-top: 24px; margin-bottom: 16px; padding-bottom: 8px; border-bottom: 2px solid var(--accent);">${escapeHtml(line)}</h2>`;
currentSectionIndex++;
} else {
html += escapeHtml(line) + '\n';
}
});
if (currentSectionIndex > 0) {
html += '</div>'; // ๋งˆ์ง€๋ง‰ ์„น์…˜ ๋‹ซ๊ธฐ
}
return html;
}
function renderTableOfContents(toc) {
const tocContainer = document.getElementById('webnovelTOC');
if (toc.length === 0) {
tocContainer.innerHTML = '<li class="content-toc-item"><div style="padding: 8px 12px; color: var(--text-secondary); font-size: 12px;">๋ชฉ์ฐจ๊ฐ€ ์—†์Šต๋‹ˆ๋‹ค.</div></li>';
return;
}
tocContainer.innerHTML = '';
toc.forEach((item, index) => {
const li = document.createElement('li');
li.className = 'content-toc-item';
const link = document.createElement('a');
link.className = 'content-toc-link';
link.href = `#${item.id}`;
link.textContent = item.title;
link.onclick = (e) => {
e.preventDefault();
scrollToSection(item.id);
// ํ™œ์„ฑ ์ƒํƒœ ์—…๋ฐ์ดํŠธ
document.querySelectorAll('.content-toc-link').forEach(l => l.classList.remove('active'));
link.classList.add('active');
};
li.appendChild(link);
tocContainer.appendChild(li);
});
}
function scrollToSection(sectionId) {
const section = document.getElementById(sectionId);
if (section) {
const container = document.getElementById('webnovelContentContainer');
const containerRect = container.getBoundingClientRect();
const sectionRect = section.getBoundingClientRect();
const scrollTop = container.scrollTop + (sectionRect.top - containerRect.top) - 20;
container.scrollTo({ top: scrollTop, behavior: 'smooth' });
}
}
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.classList.add('active');
title.textContent = filename;
content.textContent = '๋‚ด์šฉ์„ ๋ถˆ๋Ÿฌ์˜ค๋Š” ์ค‘...';
searchInput.value = '';
webnovelOriginalContent = '';
webnovelSearchMatches = [];
webnovelCurrentMatchIndex = -1;
webnovelTOC = [];
updateWebnovelSearchInfo();
// ๋ชฉ์ฐจ ์ดˆ๊ธฐํ™”
const tocContainer = document.getElementById('webnovelTOC');
tocContainer.innerHTML = '<li class="content-toc-item"><div style="padding: 8px 12px; color: var(--text-secondary); font-size: 12px;">๋ชฉ์ฐจ๋ฅผ ๋ถˆ๋Ÿฌ์˜ค๋Š” ์ค‘...</div></li>';
try {
const response = await fetch(`/api/files/${fileId}/content`, {
credentials: 'include'
});
if (!response.ok) throw new Error('์›น์†Œ์„ค ๋‚ด์šฉ์„ ๋ถˆ๋Ÿฌ์˜ฌ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.');
const data = await response.json();
webnovelOriginalContent = data.content;
// ๋ชฉ์ฐจ ํŒŒ์‹ฑ
webnovelTOC = parseTableOfContents(webnovelOriginalContent);
// ๋ชฉ์ฐจ ๋ Œ๋”๋ง
renderTableOfContents(webnovelTOC);
// ๋‚ด์šฉ์„ ์„น์…˜์œผ๋กœ ๋‚˜๋ˆ„์–ด ๋ Œ๋”๋ง
content.innerHTML = renderContentWithSections(webnovelOriginalContent, webnovelTOC);
// ๊ฒ€์ƒ‰ ์ž…๋ ฅ ํ•„๋“œ ํฌ์ปค์Šค
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) {
// HTML์—์„œ ํ…์ŠคํŠธ ์ถ”์ถœ
const tempDiv = document.createElement('div');
tempDiv.innerHTML = content.innerHTML;
webnovelOriginalContent = tempDiv.textContent || tempDiv.innerText || '';
}
// ์„น์…˜์œผ๋กœ ๋‚˜๋ˆˆ ๋‚ด์šฉ์„ ๋‹ค์‹œ ๋ Œ๋”๋งํ•œ ํ›„ ๊ฒ€์ƒ‰์–ด๋กœ ํ•˜์ด๋ผ์ดํŠธ ์ฒ˜๋ฆฌ
let contentHtml = renderContentWithSections(webnovelOriginalContent, webnovelTOC);
const regex = new RegExp(`(${escapeRegex(searchTerm)})`, 'gi');
contentHtml = contentHtml.replace(regex, '<mark style="background: #ffeb3b; padding: 2px 0; border-radius: 2px;">$1</mark>');
content.innerHTML = contentHtml;
// ๊ฒ€์ƒ‰ ๊ฒฐ๊ณผ ์œ„์น˜ ์ฐพ๊ธฐ
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.innerHTML = renderContentWithSections(webnovelOriginalContent, webnovelTOC);
}
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 parseSummaryTableOfContents(data) {
const toc = [];
let sectionIndex = 0;
// Parent Chunk ์„น์…˜
if (data.parent_chunk) {
toc.push({
id: 'summary-section-parent',
title: 'Parent Chunk',
level: 1
});
if (data.parent_chunk.world_view) {
toc.push({
id: 'summary-section-world-view',
title: '์„ธ๊ณ„๊ด€',
level: 2
});
}
if (data.parent_chunk.characters) {
toc.push({
id: 'summary-section-characters',
title: '์ฃผ์š” ์บ๋ฆญํ„ฐ',
level: 2
});
}
if (data.parent_chunk.story) {
toc.push({
id: 'summary-section-story',
title: '์ฃผ์š” ์Šคํ† ๋ฆฌ',
level: 2
});
}
if (data.parent_chunk.episodes) {
toc.push({
id: 'summary-section-episodes',
title: '์ฃผ์š” ์—ํ”ผ์†Œ๋“œ',
level: 2
});
}
if (data.parent_chunk.others) {
toc.push({
id: 'summary-section-others',
title: '๊ธฐํƒ€',
level: 2
});
}
}
// ํšŒ์ฐจ๋ณ„ ๋ถ„์„ ์„น์…˜
if (data.episode_analysis && data.episode_analysis.analysis_content) {
toc.push({
id: 'summary-section-episode-analysis',
title: 'ํšŒ์ฐจ๋ณ„ ๋ถ„์„',
level: 1
});
// ๋งˆํฌ๋‹ค์šด์—์„œ ## ํ—ค๋” ์ฐพ๊ธฐ (ํšŒ์ฐจ ์ œ๋ชฉ)
const lines = data.episode_analysis.analysis_content.split('\n');
let episodeIndex = 0;
lines.forEach((line) => {
const headerMatch = line.match(/^##\s+(.+)$/);
if (headerMatch) {
const title = headerMatch[1].trim();
const sectionId = `summary-episode-${episodeIndex}`;
toc.push({
id: sectionId,
title: title,
level: 2
});
episodeIndex++;
}
});
}
return toc;
}
function renderSummaryWithSections(data) {
let contentHtml = '';
let sectionIndex = 0;
// Parent Chunk ๋‚ด์šฉ
if (data.parent_chunk) {
contentHtml += '<div id="summary-section-parent" class="content-section" style="margin-bottom: 32px;">';
contentHtml += '<h3 style="font-size: 18px; font-weight: 600; margin-bottom: 16px; color: var(--accent); border-bottom: 2px solid var(--accent); padding-bottom: 8px;">Parent Chunk (์ž‘ํ’ˆ ์ „์ฒด ์š”์•ฝ)</h3>';
if (data.parent_chunk.world_view) {
contentHtml += '<div id="summary-section-world-view" class="content-section" style="margin-bottom: 16px;">';
contentHtml += '<h4 style="font-size: 16px; font-weight: 600; margin-bottom: 8px; color: #1967d2;">์„ธ๊ณ„๊ด€</h4>';
contentHtml += `<div class="markdown-content" style="padding: 12px; background: var(--bg-secondary); border-radius: 6px;">${renderMarkdown(data.parent_chunk.world_view)}</div>`;
contentHtml += '</div>';
}
if (data.parent_chunk.characters) {
contentHtml += '<div id="summary-section-characters" class="content-section" style="margin-bottom: 16px;">';
contentHtml += '<h4 style="font-size: 16px; font-weight: 600; margin-bottom: 8px; color: #1967d2;">์ฃผ์š” ์บ๋ฆญํ„ฐ</h4>';
contentHtml += `<div class="markdown-content" style="padding: 12px; background: var(--bg-secondary); border-radius: 6px;">${renderMarkdown(data.parent_chunk.characters)}</div>`;
contentHtml += '</div>';
}
if (data.parent_chunk.story) {
contentHtml += '<div id="summary-section-story" class="content-section" style="margin-bottom: 16px;">';
contentHtml += '<h4 style="font-size: 16px; font-weight: 600; margin-bottom: 8px; color: #1967d2;">์ฃผ์š” ์Šคํ† ๋ฆฌ</h4>';
contentHtml += `<div class="markdown-content" style="padding: 12px; background: var(--bg-secondary); border-radius: 6px;">${renderMarkdown(data.parent_chunk.story)}</div>`;
contentHtml += '</div>';
}
if (data.parent_chunk.episodes) {
contentHtml += '<div id="summary-section-episodes" class="content-section" style="margin-bottom: 16px;">';
contentHtml += '<h4 style="font-size: 16px; font-weight: 600; margin-bottom: 8px; color: #1967d2;">์ฃผ์š” ์—ํ”ผ์†Œ๋“œ</h4>';
contentHtml += `<div class="markdown-content" style="padding: 12px; background: var(--bg-secondary); border-radius: 6px;">${renderMarkdown(data.parent_chunk.episodes)}</div>`;
contentHtml += '</div>';
}
if (data.parent_chunk.others) {
contentHtml += '<div id="summary-section-others" class="content-section" style="margin-bottom: 16px;">';
contentHtml += '<h4 style="font-size: 16px; font-weight: 600; margin-bottom: 8px; color: #1967d2;">๊ธฐํƒ€</h4>';
contentHtml += `<div class="markdown-content" style="padding: 12px; background: var(--bg-secondary); border-radius: 6px;">${renderMarkdown(data.parent_chunk.others)}</div>`;
contentHtml += '</div>';
}
contentHtml += '</div>';
} else {
contentHtml += '<div style="margin-bottom: 32px; padding: 16px; background: #fff3cd; border-radius: 6px; color: #856404;">Parent Chunk๊ฐ€ ์ƒ์„ฑ๋˜์ง€ ์•Š์•˜์Šต๋‹ˆ๋‹ค.</div>';
}
// Episode Analysis ๋‚ด์šฉ
if (data.episode_analysis) {
contentHtml += '<div id="summary-section-episode-analysis" class="content-section" style="margin-top: 32px; border-top: 2px solid var(--border); padding-top: 24px;">';
contentHtml += '<h3 style="font-size: 18px; font-weight: 600; margin-bottom: 16px; color: var(--accent); border-bottom: 2px solid var(--accent); padding-bottom: 8px;">ํšŒ์ฐจ๋ณ„ ๋ถ„์„</h3>';
// ํšŒ์ฐจ๋ณ„ ๋ถ„์„ ๋‚ด์šฉ์„ ๋งˆํฌ๋‹ค์šด์œผ๋กœ ๋ Œ๋”๋งํ•˜๊ณ , ๊ฐ ## ํ—ค๋”์— ID ์ถ”๊ฐ€
let episodeContent = data.episode_analysis.analysis_content || '';
// ๋จผ์ € ๋งˆํฌ๋‹ค์šด์„ HTML๋กœ ๋ Œ๋”๋ง
let episodeHtml = renderMarkdown(episodeContent);
// ๋ Œ๋”๋ง๋œ HTML์—์„œ h2 ํ—ค๋”๋ฅผ ์ฐพ์•„์„œ ID ์ถ”๊ฐ€
const tempDiv = document.createElement('div');
tempDiv.innerHTML = episodeHtml;
const headers = tempDiv.querySelectorAll('h2');
let headerIndex = 0;
headers.forEach((header) => {
const sectionId = `summary-episode-${headerIndex}`;
header.id = sectionId;
header.className = 'content-section';
header.style.cssText = 'font-size: 16px; font-weight: 600; margin-top: 24px; margin-bottom: 12px; padding-bottom: 6px; border-bottom: 1px solid var(--border);';
headerIndex++;
});
episodeHtml = tempDiv.innerHTML;
contentHtml += `<div class="markdown-content" style="padding: 12px; background: var(--bg-secondary); border-radius: 6px;">${episodeHtml}</div>`;
contentHtml += '</div>';
} else {
contentHtml += '<div style="margin-top: 32px; padding: 16px; background: #fff3cd; border-radius: 6px; color: #856404;">ํšŒ์ฐจ๋ณ„ ๋ถ„์„์ด ์ƒ์„ฑ๋˜์ง€ ์•Š์•˜์Šต๋‹ˆ๋‹ค.</div>';
}
if (!data.parent_chunk && !data.episode_analysis) {
contentHtml = '<div style="text-align: center; padding: 24px; color: var(--text-secondary);">์š”์•ฝ ๋‚ด์šฉ์ด ์—†์Šต๋‹ˆ๋‹ค.</div>';
}
return contentHtml;
}
function renderSummaryTableOfContents(toc) {
const tocContainer = document.getElementById('webnovelSummaryTOC');
if (toc.length === 0) {
tocContainer.innerHTML = '<li class="content-toc-item"><div style="padding: 8px 12px; color: var(--text-secondary); font-size: 12px;">๋ชฉ์ฐจ๊ฐ€ ์—†์Šต๋‹ˆ๋‹ค.</div></li>';
return;
}
tocContainer.innerHTML = '';
toc.forEach((item) => {
const li = document.createElement('li');
li.className = 'content-toc-item';
const link = document.createElement('a');
link.className = 'content-toc-link';
link.href = `#${item.id}`;
link.textContent = item.title;
if (item.level === 2) {
link.style.paddingLeft = '16px';
link.style.fontSize = '10px';
}
link.onclick = (e) => {
e.preventDefault();
scrollToSummarySection(item.id);
// ํ™œ์„ฑ ์ƒํƒœ ์—…๋ฐ์ดํŠธ
document.querySelectorAll('#webnovelSummaryTOC .content-toc-link').forEach(l => l.classList.remove('active'));
link.classList.add('active');
};
li.appendChild(link);
tocContainer.appendChild(li);
});
}
function scrollToSummarySection(sectionId) {
const section = document.getElementById(sectionId);
if (section) {
const container = document.getElementById('webnovelSummaryContentContainer');
const containerRect = container.getBoundingClientRect();
const sectionRect = section.getBoundingClientRect();
const scrollTop = container.scrollTop + (sectionRect.top - containerRect.top) - 20;
container.scrollTo({ top: scrollTop, behavior: 'smooth' });
}
}
async function viewWebnovelSummary(fileId, fileName) {
const modal = document.getElementById('webnovelSummaryModal');
const title = document.getElementById('webnovelSummaryTitle');
const content = document.getElementById('webnovelSummaryContent');
console.log('[์š”์•ฝ ๋ณด๊ธฐ] ํŒŒ์ผ ID:', fileId, 'ํŒŒ์ผ๋ช…:', fileName);
title.textContent = `์š”์•ฝ ๋‚ด์šฉ - ${fileName}`;
content.textContent = '์š”์•ฝ ๋‚ด์šฉ์„ ๋ถˆ๋Ÿฌ์˜ค๋Š” ์ค‘...';
modal.classList.add('active');
// ๋ชฉ์ฐจ ์ดˆ๊ธฐํ™”
const tocContainer = document.getElementById('webnovelSummaryTOC');
tocContainer.innerHTML = '<li class="content-toc-item"><div style="padding: 8px 12px; color: var(--text-secondary); font-size: 12px;">๋ชฉ์ฐจ๋ฅผ ๋ถˆ๋Ÿฌ์˜ค๋Š” ์ค‘...</div></li>';
try {
const url = `/api/files/${fileId}/summary`;
console.log('[์š”์•ฝ ๋ณด๊ธฐ] API ์š”์ฒญ URL:', url);
const response = await fetch(url, {
credentials: 'include'
});
console.log('[์š”์•ฝ ๋ณด๊ธฐ] ์‘๋‹ต ์ƒํƒœ:', response.status, response.statusText);
if (!response.ok) {
const errorData = await response.json().catch(() => ({ error: '์š”์•ฝ ๋‚ด์šฉ์„ ๋ถˆ๋Ÿฌ์˜ฌ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.' }));
throw new Error(errorData.error || `์š”์•ฝ ๋‚ด์šฉ์„ ๋ถˆ๋Ÿฌ์˜ฌ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค. (${response.status})`);
}
const data = await response.json();
// ๋ชฉ์ฐจ ํŒŒ์‹ฑ
const summaryTOC = parseSummaryTableOfContents(data);
// ๋ชฉ์ฐจ ๋ Œ๋”๋ง
renderSummaryTableOfContents(summaryTOC);
// ๋‚ด์šฉ์„ ์„น์…˜์œผ๋กœ ๋‚˜๋ˆ„์–ด ๋ Œ๋”๋ง
content.innerHTML = renderSummaryWithSections(data);
} catch (error) {
console.error('์š”์•ฝ ๋‚ด์šฉ ๋กœ๋“œ ์˜ค๋ฅ˜:', error);
content.innerHTML = `<div style="text-align: center; padding: 24px; color: #ea4335;">
<p style="font-weight: 600; margin-bottom: 8px;">์š”์•ฝ ๋‚ด์šฉ์„ ๋ถˆ๋Ÿฌ์˜ค๋Š” ์ค‘ ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ–ˆ์Šต๋‹ˆ๋‹ค.</p>
<p style="font-size: 13px; color: var(--text-secondary);">${escapeHtml(error.message)}</p>
</div>`;
}
}
function closeWebnovelSummaryModal() {
document.getElementById('webnovelSummaryModal').classList.remove('active');
}
function closeWebnovelContentModal() {
document.getElementById('webnovelContentModal').classList.remove('active');
clearWebnovelSearch();
}
// ๋ชจ๋‹ฌ ์™ธ๋ถ€ ํด๋ฆญ ์‹œ ๋‹ซ๊ธฐ
document.getElementById('webnovelSummaryModal').addEventListener('click', function(e) {
if (e.target === this) {
closeWebnovelSummaryModal();
}
});
document.getElementById('webnovelContentModal').addEventListener('click', function(e) {
if (e.target === this) {
closeWebnovelContentModal();
}
});
document.getElementById('graphRAGModal').addEventListener('click', function(e) {
if (e.target === this) {
closeGraphRAGModal();
}
});
// ํšŒ์ฐจ๋ฅผ ์ˆซ์ž ์ˆœ์„œ๋กœ ์ •๋ ฌํ•˜๋Š” ํ•จ์ˆ˜ (1ํ™”, 2ํ™”... 99ํ™”, 100ํ™”, 101ํ™”)
function sortEpisodesByNumber(episodes) {
return episodes.slice().sort((a, b) => {
// ์ˆซ์ž ์ถ”์ถœ (์˜ˆ: "1ํ™”" -> 1, "100ํ™”" -> 100)
const numA = parseInt(a.match(/\d+/)?.[0] || '0');
const numB = parseInt(b.match(/\d+/)?.[0] || '0');
return numA - numB;
});
}
// ํŠน์ • ํšŒ์ฐจ๋กœ ์Šคํฌ๋กค
function scrollToEpisode(episodeId) {
const element = document.getElementById(episodeId);
if (element) {
element.scrollIntoView({ behavior: 'smooth', block: 'start' });
// ์‚ฌ์ด๋“œ๋ฐ”์—์„œ ํ™œ์„ฑ ์ƒํƒœ ์—…๋ฐ์ดํŠธ
const sidebarItems = document.querySelectorAll('.episode-sidebar-item');
sidebarItems.forEach(item => item.classList.remove('active'));
// ํด๋ฆญํ•œ ์•„์ดํ…œ ํ™œ์„ฑํ™”
const clickedItem = Array.from(sidebarItems).find(item => {
const itemEpisodeId = `episode-${item.textContent.replace(/[^a-zA-Z0-9]/g, '-')}`;
return itemEpisodeId === episodeId;
});
if (clickedItem) {
clickedItem.classList.add('active');
}
}
}
// ํ˜„์žฌ ๋ณด์ด๋Š” ํšŒ์ฐจ๋ฅผ ํ•˜์ด๋ผ์ดํŠธ
function updateActiveEpisode(episodes) {
const contentElement = document.getElementById('graphRAGContent');
const sidebarItems = document.querySelectorAll('.episode-sidebar-item');
if (!contentElement || sidebarItems.length === 0) return;
const scrollTop = contentElement.scrollTop;
const viewportHeight = contentElement.clientHeight;
const scrollBottom = scrollTop + viewportHeight;
// ํ†ต๊ณ„ ์ •๋ณด ์„น์…˜ ๋†’์ด ๊ณ ๋ ค (๋Œ€๋žต 150px)
const statsOffset = 150;
let activeEpisode = null;
let activeElement = null;
episodes.forEach(episode => {
const episodeId = `episode-${episode.replace(/[^a-zA-Z0-9]/g, '-')}`;
const element = document.getElementById(episodeId);
if (element) {
const elementTop = element.offsetTop - statsOffset;
const elementBottom = elementTop + element.offsetHeight;
// ์š”์†Œ๊ฐ€ ๋ทฐํฌํŠธ ์ƒ๋‹จ ๊ทผ์ฒ˜์— ์žˆ์œผ๋ฉด ํ™œ์„ฑํ™”
if (elementTop <= scrollTop + 100 && elementBottom > scrollTop) {
activeEpisode = episode;
activeElement = element;
}
}
});
// ์ฒซ ๋ฒˆ์งธ ํšŒ์ฐจ๊ฐ€ ๋ณด์ด์ง€ ์•Š์œผ๋ฉด ์ฒซ ๋ฒˆ์งธ ํšŒ์ฐจ๋ฅผ ํ™œ์„ฑํ™”
if (!activeEpisode && episodes.length > 0) {
const firstEpisode = episodes[0];
const firstElement = document.getElementById(`episode-${firstEpisode.replace(/[^a-zA-Z0-9]/g, '-')}`);
if (firstElement && firstElement.offsetTop - 150 > scrollTop + viewportHeight) {
activeEpisode = firstEpisode;
}
}
// ์‚ฌ์ด๋“œ๋ฐ” ์•„์ดํ…œ ํ™œ์„ฑ ์ƒํƒœ ์—…๋ฐ์ดํŠธ
sidebarItems.forEach(item => {
item.classList.remove('active');
if (activeEpisode && item.textContent === activeEpisode) {
item.classList.add('active');
}
});
}
async function viewGraphRAG(fileId, fileName) {
const modal = document.getElementById('graphRAGModal');
const title = document.getElementById('graphRAGModalTitle');
const content = document.getElementById('graphRAGContent');
const sidebar = document.getElementById('graphRAGEpisodeList');
title.textContent = `ํšŒ์ฐจ๋ณ„ ์บ๋ฆญํ„ฐ ๊ด€๊ณ„ ๋ถ„์„ - ${fileName}`;
content.innerHTML = '<div style="text-align: center; padding: 24px; color: var(--text-secondary);">GraphRAG ๋ฐ์ดํ„ฐ๋ฅผ ๋ถˆ๋Ÿฌ์˜ค๋Š” ์ค‘...</div>';
sidebar.innerHTML = '<div style="text-align: center; padding: 24px; color: var(--text-secondary); font-size: 13px;">ํšŒ์ฐจ ๋ชฉ๋ก์„ ๋ถˆ๋Ÿฌ์˜ค๋Š” ์ค‘...</div>';
modal.classList.add('active');
try {
const response = await fetch(`/api/files/${fileId}/graph`, {
credentials: 'include'
});
if (!response.ok) throw new Error('GraphRAG ๋ฐ์ดํ„ฐ๋ฅผ ๋ถˆ๋Ÿฌ์˜ฌ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.');
const data = await response.json();
// ํšŒ์ฐจ๋ณ„ ๋ฐ์ดํ„ฐ ํ‘œ์‹œ (์ˆซ์ž ์ˆœ์„œ๋กœ ์ •๋ ฌ)
const episodes = sortEpisodesByNumber(data.episodes || []);
// ์‚ฌ์ด๋“œ๋ฐ”์— ํšŒ์ฐจ ๋ชฉ๋ก ํ‘œ์‹œ
sidebar.innerHTML = '';
if (episodes.length === 0) {
sidebar.innerHTML = '<div style="text-align: center; padding: 24px; color: var(--text-secondary); font-size: 13px;">ํšŒ์ฐจ๊ฐ€ ์—†์Šต๋‹ˆ๋‹ค.</div>';
} else {
episodes.forEach((episode, index) => {
const episodeId = `episode-${episode.replace(/[^a-zA-Z0-9]/g, '-')}`;
const item = document.createElement('div');
item.className = 'episode-sidebar-item';
item.textContent = episode;
item.onclick = () => scrollToEpisode(episodeId);
if (index === 0) {
item.classList.add('active');
}
sidebar.appendChild(item);
});
}
let contentHtml = '';
// ํ†ต๊ณ„ ์ •๋ณด
if (data.statistics) {
contentHtml += '<div style="margin-bottom: 32px; padding: 16px; background: var(--bg-secondary); border-radius: 6px;">';
contentHtml += '<h3 style="font-size: 18px; font-weight: 600; margin-bottom: 16px; color: var(--accent);">ํ†ต๊ณ„ ์ •๋ณด</h3>';
contentHtml += '<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 16px;">';
contentHtml += `<div style="padding: 12px; background: var(--bg-primary); border-radius: 6px;"><strong>์—”ํ‹ฐํ‹ฐ:</strong> ${data.statistics.total_entities}๊ฐœ</div>`;
contentHtml += `<div style="padding: 12px; background: var(--bg-primary); border-radius: 6px;"><strong>๊ด€๊ณ„:</strong> ${data.statistics.total_relationships}๊ฐœ</div>`;
contentHtml += `<div style="padding: 12px; background: var(--bg-primary); border-radius: 6px;"><strong>์‚ฌ๊ฑด:</strong> ${data.statistics.total_events}๊ฐœ</div>`;
contentHtml += `<div style="padding: 12px; background: var(--bg-primary); border-radius: 6px;"><strong>ํšŒ์ฐจ ์ˆ˜:</strong> ${data.statistics.episodes_count}๊ฐœ</div>`;
contentHtml += '</div>';
contentHtml += '</div>';
}
if (episodes.length === 0) {
contentHtml += '<div style="text-align: center; padding: 24px; color: var(--text-secondary);">GraphRAG ๋ฐ์ดํ„ฐ๊ฐ€ ์—†์Šต๋‹ˆ๋‹ค.</div>';
} else {
episodes.forEach(episode => {
const episodeId = `episode-${episode.replace(/[^a-zA-Z0-9]/g, '-')}`;
contentHtml += `<div id="${episodeId}" style="margin-bottom: 32px; padding: 20px; background: var(--bg-secondary); border-radius: 8px; border-left: 4px solid var(--accent); scroll-margin-top: 20px;">`;
contentHtml += `<h3 style="font-size: 20px; font-weight: 600; margin-bottom: 20px; color: var(--accent);">${escapeHtml(episode)}</h3>`;
// ์—”ํ‹ฐํ‹ฐ (์ธ๋ฌผ)
const characters = data.entities_by_episode[episode]?.characters || [];
if (characters.length > 0) {
contentHtml += '<div style="margin-bottom: 20px;">';
contentHtml += '<h4 style="font-size: 16px; font-weight: 600; margin-bottom: 12px; color: #1967d2;">์ธ๋ฌผ</h4>';
contentHtml += '<div style="display: grid; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); gap: 12px;">';
characters.forEach(char => {
contentHtml += '<div style="padding: 12px; background: var(--bg-primary); border-radius: 6px; border: 1px solid var(--border);">';
contentHtml += `<div style="font-weight: 600; margin-bottom: 8px; color: var(--accent);">${escapeHtml(char.entity_name)}</div>`;
if (char.role) {
contentHtml += `<div style="font-size: 13px; color: var(--text-secondary); margin-bottom: 4px;"><strong>์—ญํ• :</strong> ${escapeHtml(char.role)}</div>`;
}
if (char.description) {
contentHtml += `<div style="font-size: 13px; color: var(--text-secondary);">${escapeHtml(char.description)}</div>`;
}
contentHtml += '</div>';
});
contentHtml += '</div>';
contentHtml += '</div>';
}
// ์—”ํ‹ฐํ‹ฐ (์žฅ์†Œ)
const locations = data.entities_by_episode[episode]?.locations || [];
if (locations.length > 0) {
contentHtml += '<div style="margin-bottom: 20px;">';
contentHtml += '<h4 style="font-size: 16px; font-weight: 600; margin-bottom: 12px; color: #1967d2;">์žฅ์†Œ</h4>';
contentHtml += '<div style="display: grid; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); gap: 12px;">';
locations.forEach(loc => {
contentHtml += '<div style="padding: 12px; background: var(--bg-primary); border-radius: 6px; border: 1px solid var(--border);">';
contentHtml += `<div style="font-weight: 600; margin-bottom: 8px; color: var(--accent);">${escapeHtml(loc.entity_name)}</div>`;
if (loc.category) {
contentHtml += `<div style="font-size: 13px; color: var(--text-secondary); margin-bottom: 4px;"><strong>์œ ํ˜•:</strong> ${escapeHtml(loc.category)}</div>`;
}
if (loc.description) {
contentHtml += `<div style="font-size: 13px; color: var(--text-secondary);">${escapeHtml(loc.description)}</div>`;
}
contentHtml += '</div>';
});
contentHtml += '</div>';
contentHtml += '</div>';
}
// ๊ด€๊ณ„
const relationships = data.relationships_by_episode[episode] || [];
if (relationships.length > 0) {
contentHtml += '<div style="margin-bottom: 20px;">';
contentHtml += '<h4 style="font-size: 16px; font-weight: 600; margin-bottom: 12px; color: #1967d2;">๊ด€๊ณ„</h4>';
contentHtml += '<div style="display: grid; grid-template-columns: repeat(auto-fill, minmax(400px, 1fr)); gap: 12px;">';
relationships.forEach(rel => {
contentHtml += '<div style="padding: 12px; background: var(--bg-primary); border-radius: 6px; border: 1px solid var(--border);">';
contentHtml += `<div style="margin-bottom: 8px;">`;
contentHtml += `<span style="font-weight: 600; color: var(--accent);">${escapeHtml(rel.source)}</span>`;
contentHtml += `<span style="margin: 0 8px; color: var(--text-secondary);">โ†’</span>`;
contentHtml += `<span style="font-weight: 600; color: var(--accent);">${escapeHtml(rel.target)}</span>`;
contentHtml += '</div>';
if (rel.relationship_type) {
contentHtml += `<div style="font-size: 13px; color: var(--text-secondary); margin-bottom: 4px;"><strong>๊ด€๊ณ„ ์œ ํ˜•:</strong> ${escapeHtml(rel.relationship_type)}</div>`;
}
if (rel.description) {
contentHtml += `<div style="font-size: 13px; color: var(--text-secondary); margin-bottom: 4px;">${escapeHtml(rel.description)}</div>`;
}
if (rel.event) {
contentHtml += `<div style="font-size: 12px; color: #856404; padding: 8px; background: #fff3cd; border-radius: 4px; margin-top: 8px;"><strong>๊ด€๋ จ ์‚ฌ๊ฑด:</strong> ${escapeHtml(rel.event)}</div>`;
}
contentHtml += '</div>';
});
contentHtml += '</div>';
contentHtml += '</div>';
}
// ์‚ฌ๊ฑด
const events = data.events_by_episode[episode] || [];
if (events.length > 0) {
contentHtml += '<div style="margin-bottom: 20px;">';
contentHtml += '<h4 style="font-size: 16px; font-weight: 600; margin-bottom: 12px; color: #1967d2;">์‚ฌ๊ฑด</h4>';
contentHtml += '<div style="display: grid; grid-template-columns: repeat(auto-fill, minmax(400px, 1fr)); gap: 12px;">';
events.forEach(event => {
contentHtml += '<div style="padding: 12px; background: var(--bg-primary); border-radius: 6px; border: 1px solid var(--border);">';
if (event.event_name) {
contentHtml += `<div style="font-weight: 600; margin-bottom: 8px; color: var(--accent);">${escapeHtml(event.event_name)}</div>`;
}
if (event.description) {
contentHtml += `<div style="font-size: 13px; color: var(--text-secondary); margin-bottom: 8px; line-height: 1.6;">${escapeHtml(event.description)}</div>`;
}
if (event.participants && event.participants.length > 0) {
contentHtml += `<div style="font-size: 13px; color: var(--text-secondary); margin-bottom: 4px;"><strong>๊ด€๋ จ ์ธ๋ฌผ:</strong> ${escapeHtml(event.participants.join(', '))}</div>`;
}
if (event.location) {
contentHtml += `<div style="font-size: 13px; color: var(--text-secondary); margin-bottom: 4px;"><strong>์žฅ์†Œ:</strong> ${escapeHtml(event.location)}</div>`;
}
if (event.significance) {
contentHtml += `<div style="font-size: 12px; color: #137333; padding: 6px; background: #e8f5e9; border-radius: 4px; margin-top: 8px; display: inline-block;"><strong>์ค‘์š”๋„:</strong> ${escapeHtml(event.significance)}</div>`;
}
contentHtml += '</div>';
});
contentHtml += '</div>';
contentHtml += '</div>';
}
contentHtml += '</div>';
});
}
content.innerHTML = contentHtml;
// ์Šคํฌ๋กค ์ด๋ฒคํŠธ ๋ฆฌ์Šค๋„ˆ ์ถ”๊ฐ€ (ํ˜„์žฌ ๋ณด์ด๋Š” ํšŒ์ฐจ ํ•˜์ด๋ผ์ดํŠธ)
const contentElement = document.getElementById('graphRAGContent');
contentElement.addEventListener('scroll', () => updateActiveEpisode(episodes));
// ์ดˆ๊ธฐ ํ™œ์„ฑ ํšŒ์ฐจ ์„ค์ •
updateActiveEpisode(episodes);
} catch (error) {
console.error('GraphRAG ๋ฐ์ดํ„ฐ ๋กœ๋“œ ์˜ค๋ฅ˜:', error);
content.innerHTML = '<div style="text-align: center; padding: 24px; color: #ea4335;">GraphRAG ๋ฐ์ดํ„ฐ๋ฅผ ๋ถˆ๋Ÿฌ์˜ค๋Š” ์ค‘ ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ–ˆ์Šต๋‹ˆ๋‹ค.</div>';
}
}
function closeGraphRAGModal() {
document.getElementById('graphRAGModal').classList.remove('active');
}
// GraphRAG ๊ทธ๋ž˜ํ”„ ์‹œ๊ฐํ™” ๊ด€๋ จ ๋ณ€์ˆ˜
let webnovelGraphData = null;
let webnovelGraphNetwork = null;
let webnovelAllGraphData = null;
async function viewGraphRAGVisualization(fileId, fileName) {
const modal = document.getElementById('graphRAGVisualizationModal');
const title = document.getElementById('graphRAGVisualizationModalTitle');
const content = document.getElementById('graphRAGVisualizationContent');
title.textContent = `์บ๋ฆญํ„ฐ ๊ด€๊ณ„๋„ ์‹œ๊ฐํ™” - ${fileName}`;
content.innerHTML = '<div style="text-align: center; padding: 24px; color: var(--text-secondary); position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%);">ํšŒ์ฐจ์™€ ๋…ธ๋“œ ํƒ€์ž…์„ ์„ ํƒํ•˜์—ฌ ๊ทธ๋ž˜ํ”„๋ฅผ ํ™•์ธํ•˜์„ธ์š”.</div>';
modal.classList.add('active');
// ๊ธฐ์กด ๋„คํŠธ์›Œํฌ ์ œ๊ฑฐ
if (webnovelGraphNetwork) {
webnovelGraphNetwork.destroy();
webnovelGraphNetwork = null;
}
// ์ฒดํฌ๋ฐ•์Šค ์ดˆ๊ธฐํ™”
document.getElementById('showCharacters').checked = false;
document.getElementById('showLocations').checked = false;
document.getElementById('showEvents').checked = false;
try {
const response = await fetch(`/api/files/${fileId}/graph`, {
credentials: 'include'
});
if (!response.ok) throw new Error('GraphRAG ๋ฐ์ดํ„ฐ๋ฅผ ๋ถˆ๋Ÿฌ์˜ฌ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.');
const data = await response.json();
webnovelAllGraphData = data;
// ํšŒ์ฐจ ํ•„ํ„ฐ ์ฒดํฌ๋ฐ•์Šค ์ƒ์„ฑ (์ˆซ์ž ์ˆœ์„œ๋กœ ์ •๋ ฌ)
const episodeFilterList = document.getElementById('episodeFilterList');
episodeFilterList.innerHTML = '';
const episodeFilterAll = document.getElementById('episodeFilterAll');
episodeFilterAll.checked = false;
if (data.episodes && data.episodes.length > 0) {
const sortedEpisodes = sortEpisodesByNumber(data.episodes);
sortedEpisodes.forEach(episode => {
const label = document.createElement('label');
label.style.cssText = 'font-size: 13px; cursor: pointer; padding: 6px 12px; border-radius: 4px; display: flex; align-items: center; transition: background 0.2s;';
label.onmouseover = function() { this.style.background = 'var(--bg-secondary)'; };
label.onmouseout = function() { this.style.background = 'transparent'; };
const checkbox = document.createElement('input');
checkbox.type = 'checkbox';
checkbox.value = episode;
checkbox.id = `episodeFilter_${episode.replace(/[^a-zA-Z0-9]/g, '_')}`;
checkbox.onchange = handleWebnovelIndividualEpisodeChange;
checkbox.style.marginRight = '8px';
checkbox.style.cursor = 'pointer';
const span = document.createElement('span');
span.textContent = episode;
span.style.flex = '1';
label.appendChild(checkbox);
label.appendChild(span);
episodeFilterList.appendChild(label);
});
}
// ๋ฒ„ํŠผ ํ…์ŠคํŠธ ์ดˆ๊ธฐํ™”
updateWebnovelEpisodeFilterButtonText();
// ์ดˆ๊ธฐ์—๋Š” ๊ทธ๋ž˜ํ”„๋ฅผ ์ƒ์„ฑํ•˜์ง€ ์•Š์Œ (๋นˆ ํ™”๋ฉด)
// ์‚ฌ์šฉ์ž๊ฐ€ ํ•„ํ„ฐ๋‚˜ ์ฒดํฌ๋ฐ•์Šค๋ฅผ ์„ ํƒํ•˜๋ฉด ๊ทธ๋ž˜ํ”„๊ฐ€ ์ƒ์„ฑ๋จ
} catch (error) {
console.error('GraphRAG ๊ทธ๋ž˜ํ”„ ๋กœ๋“œ ์˜ค๋ฅ˜:', error);
content.innerHTML = '<div style="text-align: center; padding: 24px; color: #ea4335; position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%);">๊ทธ๋ž˜ํ”„๋ฅผ ๋ถˆ๋Ÿฌ์˜ค๋Š” ์ค‘ ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ–ˆ์Šต๋‹ˆ๋‹ค.</div>';
}
}
function createWebnovelGraphVisualization(data, episodeFilter = 'all') {
const content = document.getElementById('graphRAGVisualizationContent');
// ํ•„ํ„ฐ๊ฐ€ ์„ ํƒ๋˜์ง€ ์•Š์•˜๊ฑฐ๋‚˜ ๋นˆ ๋ฐฐ์—ด์ธ ๊ฒฝ์šฐ ๋นˆ ํ™”๋ฉด ํ‘œ์‹œ
if (!episodeFilter || (Array.isArray(episodeFilter) && episodeFilter.length === 0)) {
content.innerHTML = '<div style="text-align: center; padding: 24px; color: var(--text-secondary); position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%);">ํšŒ์ฐจ๋ฅผ ์„ ํƒํ•˜์—ฌ ๊ทธ๋ž˜ํ”„๋ฅผ ํ™•์ธํ•˜์„ธ์š”.</div>';
return;
}
const showCharacters = document.getElementById('showCharacters').checked;
const showLocations = document.getElementById('showLocations').checked;
const showEvents = document.getElementById('showEvents').checked;
// ์ฒดํฌ๋ฐ•์Šค๊ฐ€ ๋ชจ๋‘ ํ•ด์ œ๋œ ๊ฒฝ์šฐ ๋นˆ ํ™”๋ฉด ํ‘œ์‹œ
if (!showCharacters && !showLocations && !showEvents) {
content.innerHTML = '<div style="text-align: center; padding: 24px; color: var(--text-secondary); position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%);">๋…ธ๋“œ ํƒ€์ž…(์ธ๋ฌผ, ์žฅ์†Œ, ์‚ฌ๊ฑด)์„ ํ•˜๋‚˜ ์ด์ƒ ์„ ํƒํ•˜์—ฌ ๊ทธ๋ž˜ํ”„๋ฅผ ํ™•์ธํ•˜์„ธ์š”.</div>';
return;
}
content.innerHTML = ''; // ๊ธฐ์กด ๋‚ด์šฉ ์ œ๊ฑฐ
// ๋…ธ๋“œ์™€ ์—ฃ์ง€ ๋ฐ์ดํ„ฐ ์ƒ์„ฑ
const nodes = new vis.DataSet([]);
const edges = new vis.DataSet([]);
const nodeMap = new Map(); // ๋…ธ๋“œ ID ๋งคํ•‘
let nodeIdCounter = 1;
// ํ•„ํ„ฐ๋งํ•  ํšŒ์ฐจ ๋ชฉ๋ก (๋‹ค์ค‘ ์„ ํƒ ์ง€์›)
const episodes = episodeFilter === 'all'
? (data.episodes || [])
: (Array.isArray(episodeFilter) ? episodeFilter : [episodeFilter]);
// ์—”ํ‹ฐํ‹ฐ ์ถ”๊ฐ€ (์ธ๋ฌผ, ์žฅ์†Œ)
episodes.forEach(episode => {
const entities = data.entities_by_episode?.[episode] || {};
// ์ธ๋ฌผ ์ถ”๊ฐ€
if (showCharacters && entities.characters) {
entities.characters.forEach(char => {
const nodeId = `char_${char.entity_name}`;
if (!nodeMap.has(nodeId)) {
const id = nodeIdCounter++;
nodeMap.set(nodeId, id);
nodes.add({
id: id,
label: char.entity_name,
title: `์ธ๋ฌผ: ${char.entity_name}\n์—ญํ• : ${char.role || '์—†์Œ'}\n์„ค๋ช…: ${char.description || '์—†์Œ'}`,
color: {
background: '#4285f4',
border: '#1967d2',
highlight: { background: '#1a73e8', border: '#1557b0' }
},
shape: 'ellipse',
font: { size: 14, face: 'Inter' },
size: 20
});
}
});
}
// ์žฅ์†Œ ์ถ”๊ฐ€
if (showLocations && entities.locations) {
entities.locations.forEach(loc => {
const nodeId = `loc_${loc.entity_name}`;
if (!nodeMap.has(nodeId)) {
const id = nodeIdCounter++;
nodeMap.set(nodeId, id);
nodes.add({
id: id,
label: loc.entity_name,
title: `์žฅ์†Œ: ${loc.entity_name}\n์œ ํ˜•: ${loc.category || '์—†์Œ'}\n์„ค๋ช…: ${loc.description || '์—†์Œ'}`,
color: {
background: '#34a853',
border: '#137333',
highlight: { background: '#2e7d32', border: '#1b5e20' }
},
shape: 'box',
font: { size: 14, face: 'Inter' },
size: 20
});
}
});
}
});
// ์‚ฌ๊ฑด ์ถ”๊ฐ€
if (showEvents) {
episodes.forEach(episode => {
const events = data.events_by_episode?.[episode] || [];
events.forEach(event => {
const eventName = event.event_name || `์‚ฌ๊ฑด_${episode}_${events.indexOf(event)}`;
const nodeId = `event_${eventName}`;
if (!nodeMap.has(nodeId)) {
const id = nodeIdCounter++;
nodeMap.set(nodeId, id);
nodes.add({
id: id,
label: eventName,
title: `์‚ฌ๊ฑด: ${eventName}\n์„ค๋ช…: ${event.description || '์—†์Œ'}\n๊ด€๋ จ ์ธ๋ฌผ: ${event.participants ? event.participants.join(', ') : '์—†์Œ'}\n์žฅ์†Œ: ${event.location || '์—†์Œ'}\n์ค‘์š”๋„: ${event.significance || '์—†์Œ'}`,
color: {
background: '#ff9800',
border: '#f57c00',
highlight: { background: '#fb8c00', border: '#e65100' }
},
shape: 'diamond',
font: { size: 13, face: 'Inter' },
size: 25
});
// ์‚ฌ๊ฑด๊ณผ ๊ด€๋ จ ์ธ๋ฌผ ์—ฐ๊ฒฐ
if (event.participants && Array.isArray(event.participants)) {
event.participants.forEach(participant => {
const participantNodeId = `char_${participant}`;
const participantId = nodeMap.get(participantNodeId);
if (participantId) {
edges.add({
from: participantId,
to: id,
label: '์ฐธ์—ฌ',
title: `${participant}์ด(๊ฐ€) ${eventName}์— ์ฐธ์—ฌ`,
color: {
color: '#ff9800',
highlight: '#f57c00'
},
arrows: 'to',
font: { size: 11, align: 'middle' },
dashes: true,
smooth: {
type: 'curvedCW',
roundness: 0.3
}
});
}
});
}
// ์‚ฌ๊ฑด๊ณผ ์žฅ์†Œ ์—ฐ๊ฒฐ
if (event.location) {
const locationNodeId = `loc_${event.location}`;
const locationId = nodeMap.get(locationNodeId);
if (locationId) {
edges.add({
from: locationId,
to: id,
label: '๋ฐœ์ƒ',
title: `${eventName}์ด(๊ฐ€) ${event.location}์—์„œ ๋ฐœ์ƒ`,
color: {
color: '#ff9800',
highlight: '#f57c00'
},
arrows: 'to',
font: { size: 11, align: 'middle' },
dashes: [5, 5],
smooth: {
type: 'curvedCW',
roundness: 0.3
}
});
}
}
}
});
});
}
// ๊ด€๊ณ„ ์ถ”๊ฐ€
episodes.forEach(episode => {
const relationships = data.relationships_by_episode?.[episode] || [];
relationships.forEach(rel => {
// ์†Œ์Šค์™€ ํƒ€๊ฒŸ์ด ์ธ๋ฌผ์ธ์ง€ ์žฅ์†Œ์ธ์ง€ ํ™•์ธ
let sourceNodeId = null;
let targetNodeId = null;
// ์†Œ์Šค ๋…ธ๋“œ ์ฐพ๊ธฐ
if (nodeMap.has(`char_${rel.source}`)) {
sourceNodeId = nodeMap.get(`char_${rel.source}`);
} else if (nodeMap.has(`loc_${rel.source}`)) {
sourceNodeId = nodeMap.get(`loc_${rel.source}`);
}
// ํƒ€๊ฒŸ ๋…ธ๋“œ ์ฐพ๊ธฐ
if (nodeMap.has(`char_${rel.target}`)) {
targetNodeId = nodeMap.get(`char_${rel.target}`);
} else if (nodeMap.has(`loc_${rel.target}`)) {
targetNodeId = nodeMap.get(`loc_${rel.target}`);
}
if (sourceNodeId && targetNodeId) {
edges.add({
from: sourceNodeId,
to: targetNodeId,
label: rel.relationship_type || '',
title: `๊ด€๊ณ„: ${rel.relationship_type || '์—†์Œ'}\n์„ค๋ช…: ${rel.description || '์—†์Œ'}${rel.event ? `\n๊ด€๋ จ ์‚ฌ๊ฑด: ${rel.event}` : ''}`,
color: {
color: '#ea4335',
highlight: '#c5221f'
},
arrows: 'to',
font: { size: 12, align: 'middle' },
smooth: {
type: 'curvedCW',
roundness: 0.2
}
});
}
});
});
// ๋„คํŠธ์›Œํฌ ์ƒ์„ฑ
const container = document.createElement('div');
container.id = 'webnovelGraphNetworkContainer';
container.style.width = '100%';
container.style.height = '100%';
content.appendChild(container);
const graphData = {
nodes: nodes,
edges: edges
};
const options = {
nodes: {
borderWidth: 2,
shadow: true,
font: {
size: 14,
face: 'Inter'
}
},
edges: {
width: 2,
shadow: true,
font: {
size: 12,
align: 'middle'
},
arrows: {
to: {
enabled: true,
scaleFactor: 0.8
}
}
},
physics: {
enabled: true,
stabilization: {
enabled: true,
iterations: 200
},
barnesHut: {
gravitationalConstant: -2000,
centralGravity: 0.1,
springLength: 200,
springConstant: 0.04,
damping: 0.09
}
},
interaction: {
hover: true,
tooltipDelay: 200,
zoomView: true,
dragView: true
},
layout: {
improvedLayout: true
}
};
webnovelGraphNetwork = new vis.Network(container, graphData, options);
// ๋„คํŠธ์›Œํฌ ์ด๋ฒคํŠธ ๋ฆฌ์Šค๋„ˆ
webnovelGraphNetwork.on('click', function(params) {
if (params.nodes.length > 0) {
const nodeId = params.nodes[0];
const node = nodes.get(nodeId);
if (node) {
console.log('์„ ํƒ๋œ ๋…ธ๋“œ:', node);
}
}
});
// stabilization ์™„๋ฃŒ ํ›„ ์ž๋™์œผ๋กœ fit() ํ˜ธ์ถœ
webnovelGraphNetwork.on('stabilizationEnd', function() {
try {
webnovelGraphNetwork.fit({
animation: {
duration: 500,
easingFunction: 'easeInOutQuad'
}
});
} catch (error) {
console.error('์ž๋™ fit() ์˜ค๋ฅ˜:', error);
}
});
// stabilization์ด ๋น„ํ™œ์„ฑํ™”๋œ ๊ฒฝ์šฐ๋ฅผ ๋Œ€๋น„ํ•ด ์งง์€ ์ง€์—ฐ ํ›„์—๋„ fit() ํ˜ธ์ถœ
setTimeout(() => {
if (webnovelGraphNetwork && nodes.length() > 0) {
try {
webnovelGraphNetwork.fit({
animation: {
duration: 500,
easingFunction: 'easeInOutQuad'
}
});
} catch (error) {
console.error('์ง€์—ฐ fit() ์˜ค๋ฅ˜:', error);
}
}
}, 500);
}
// ํšŒ์ฐจ ํ•„ํ„ฐ ํ† ๊ธ€ ํ•จ์ˆ˜
function toggleWebnovelEpisodeFilter() {
const dropdown = document.getElementById('episodeFilterDropdown');
const icon = document.getElementById('episodeFilterToggleIcon');
const isVisible = dropdown.style.display !== 'none';
if (isVisible) {
dropdown.style.display = 'none';
icon.style.transform = 'rotate(0deg)';
} else {
dropdown.style.display = 'block';
icon.style.transform = 'rotate(180deg)';
}
}
// ์™ธ๋ถ€ ํด๋ฆญ ์‹œ ํšŒ์ฐจ ํ•„ํ„ฐ ๋“œ๋กญ๋‹ค์šด ๋‹ซ๊ธฐ
document.addEventListener('click', function(event) {
const dropdown = document.getElementById('episodeFilterDropdown');
const toggle = document.getElementById('episodeFilterToggle');
if (dropdown && toggle && !dropdown.contains(event.target) && !toggle.contains(event.target)) {
dropdown.style.display = 'none';
const icon = document.getElementById('episodeFilterToggleIcon');
if (icon) {
icon.style.transform = 'rotate(0deg)';
}
}
});
// ์„ ํƒ๋œ ํšŒ์ฐจ ์ˆ˜ ์—…๋ฐ์ดํŠธ
function updateWebnovelEpisodeFilterButtonText() {
const toggle = document.getElementById('episodeFilterToggle');
const episodeFilterAll = document.getElementById('episodeFilterAll');
const episodeFilterList = document.getElementById('episodeFilterList');
if (!toggle || !episodeFilterList) return;
let selectedCount = 0;
let buttonText = 'ํšŒ์ฐจ ํ•„ํ„ฐ';
if (episodeFilterAll && episodeFilterAll.checked) {
buttonText = 'ํšŒ์ฐจ ํ•„ํ„ฐ (์ „์ฒด)';
} else {
const checkboxes = episodeFilterList.querySelectorAll('input[type="checkbox"]:checked');
selectedCount = checkboxes.length;
if (selectedCount > 0) {
buttonText = `ํšŒ์ฐจ ํ•„ํ„ฐ (${selectedCount}๊ฐœ ์„ ํƒ)`;
}
}
toggle.querySelector('span:first-child').textContent = buttonText;
}
// ์ „์ฒด ํšŒ์ฐจ ์ฒดํฌ๋ฐ•์Šค ๋ณ€๊ฒฝ ํ•ธ๋“ค๋Ÿฌ
function handleWebnovelEpisodeFilterAllChange() {
const episodeFilterAll = document.getElementById('episodeFilterAll');
const episodeFilterList = document.getElementById('episodeFilterList');
const checkboxes = episodeFilterList.querySelectorAll('input[type="checkbox"]');
// ์ „์ฒด ํšŒ์ฐจ๊ฐ€ ์ฒดํฌ๋˜๋ฉด ๋ชจ๋“  ๊ฐœ๋ณ„ ํšŒ์ฐจ ์ฒดํฌ ํ•ด์ œ
if (episodeFilterAll.checked) {
checkboxes.forEach(checkbox => {
checkbox.checked = false;
});
}
updateWebnovelEpisodeFilterButtonText();
updateGraphVisualization();
}
// ๊ฐœ๋ณ„ ํšŒ์ฐจ ์ฒดํฌ๋ฐ•์Šค ๋ณ€๊ฒฝ ํ•ธ๋“ค๋Ÿฌ (์ „์ฒด ํšŒ์ฐจ ์ž๋™ ํ•ด์ œ)
function handleWebnovelIndividualEpisodeChange() {
const episodeFilterAll = document.getElementById('episodeFilterAll');
const episodeFilterList = document.getElementById('episodeFilterList');
const checkboxes = episodeFilterList.querySelectorAll('input[type="checkbox"]');
const checkedCount = Array.from(checkboxes).filter(cb => cb.checked).length;
// ๊ฐœ๋ณ„ ํšŒ์ฐจ๊ฐ€ ํ•˜๋‚˜๋ผ๋„ ์ฒดํฌ๋˜๋ฉด ์ „์ฒด ํšŒ์ฐจ ์ฒดํฌ ํ•ด์ œ
if (checkedCount > 0) {
episodeFilterAll.checked = false;
}
updateWebnovelEpisodeFilterButtonText();
updateGraphVisualization();
}
function updateGraphVisualization() {
if (!webnovelAllGraphData) return;
// ์„ ํƒ๋œ ํšŒ์ฐจ๋“ค ๊ฐ€์ ธ์˜ค๊ธฐ
const episodeFilterAll = document.getElementById('episodeFilterAll');
let selectedEpisodes = [];
if (episodeFilterAll && episodeFilterAll.checked) {
// ์ „์ฒด ํšŒ์ฐจ ์„ ํƒ
selectedEpisodes = 'all';
} else {
// ๊ฐœ๋ณ„ ํšŒ์ฐจ ์ฒดํฌ๋ฐ•์Šค์—์„œ ์„ ํƒ๋œ ๊ฒƒ๋“ค ๊ฐ€์ ธ์˜ค๊ธฐ
const episodeFilterList = document.getElementById('episodeFilterList');
const checkboxes = episodeFilterList.querySelectorAll('input[type="checkbox"]:checked');
selectedEpisodes = Array.from(checkboxes).map(cb => cb.value);
}
// ๊ธฐ์กด ๋„คํŠธ์›Œํฌ ์ œ๊ฑฐ
if (webnovelGraphNetwork) {
webnovelGraphNetwork.destroy();
webnovelGraphNetwork = null;
}
// ์ƒˆ ๊ทธ๋ž˜ํ”„ ์ƒ์„ฑ
createWebnovelGraphVisualization(webnovelAllGraphData, selectedEpisodes);
}
function resetGraphView() {
if (!webnovelGraphNetwork) {
console.warn('๊ทธ๋ž˜ํ”„ ๋„คํŠธ์›Œํฌ๊ฐ€ ์•„์ง ์ƒ์„ฑ๋˜์ง€ ์•Š์•˜์Šต๋‹ˆ๋‹ค.');
return;
}
try {
// ๊ทธ๋ž˜ํ”„๋ฅผ ์ดˆ๊ธฐ ๋ทฐ๋กœ ๋ฆฌ์…‹ (๋ชจ๋“  ๋…ธ๋“œ๊ฐ€ ๋ณด์ด๋„๋ก)
// vis-network์˜ fit() ๋ฉ”์„œ๋“œ ํ˜ธ์ถœ
if (typeof webnovelGraphNetwork.fit === 'function') {
// ์˜ต์…˜ ๊ฐ์ฒด๋ฅผ ์‚ฌ์šฉํ•œ fit() ํ˜ธ์ถœ
webnovelGraphNetwork.fit({
animation: {
duration: 1000,
easingFunction: 'easeInOutQuad'
}
});
} else {
// fit() ๋ฉ”์„œ๋“œ๊ฐ€ ์—†๋Š” ๊ฒฝ์šฐ (์ด๋ก ์ ์œผ๋กœ๋Š” ๋ฐœ์ƒํ•˜์ง€ ์•Š์•„์•ผ ํ•จ)
console.warn('fit() ๋ฉ”์„œ๋“œ๋ฅผ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.');
}
} catch (error) {
console.error('๋ทฐ ๋ฆฌ์…‹ ์˜ค๋ฅ˜:', error);
// ์—๋Ÿฌ ๋ฐœ์ƒ ์‹œ ์˜ต์…˜ ์—†์ด fit() ํ˜ธ์ถœ๋กœ ์žฌ์‹œ๋„
try {
if (typeof webnovelGraphNetwork.fit === 'function') {
webnovelGraphNetwork.fit();
}
} catch (e) {
console.error('๋ทฐ ๋ฆฌ์…‹ ์‹คํŒจ:', e);
}
}
}
function closeGraphRAGVisualizationModal() {
document.getElementById('graphRAGVisualizationModal').classList.remove('active');
if (webnovelGraphNetwork) {
webnovelGraphNetwork.destroy();
webnovelGraphNetwork = null;
}
webnovelGraphData = null;
webnovelAllGraphData = null;
}
document.getElementById('graphRAGVisualizationModal').addEventListener('click', function(e) {
if (e.target === this) {
closeGraphRAGVisualizationModal();
}
});
// ํŽ˜์ด์ง€ ๋กœ๋“œ ์‹œ ์ดˆ๊ธฐํ™”
window.addEventListener('load', async () => {
await loadWebnovelModelFilter();
await loadWebnovels();
});
</script>
</body>
</html>