soyailabs / templates /admin_files.html
GitHub Actions
Auto-deploy from GitHub Actions - 2025-12-12 16:41:27
1995f8f
<!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">
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: #f8f9fa;
color: #202124;
}
.header {
background: white;
border-bottom: 1px solid #dadce0;
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: 8px;
align-items: center;
flex-wrap: wrap;
}
/* ๋“œ๋กญ๋‹ค์šด ๋ฉ”๋‰ด ์Šคํƒ€์ผ */
.dropdown {
position: relative;
display: inline-block;
}
/* ๋ฒ„ํŠผ๊ณผ ๋ฉ”๋‰ด ์‚ฌ์ด 'ํ‹ˆ'์—์„œ hover๊ฐ€ ๋Š๊ฒจ ๋ฉ”๋‰ด๊ฐ€ ๋‹ซํžˆ๋Š” ํ˜„์ƒ ๋ฐฉ์ง€ */
.dropdown::after {
content: '';
position: absolute;
left: 0;
right: 0;
top: 100%;
height: 8px;
}
.dropdown-toggle {
padding: 8px 16px;
background: #f1f3f4;
color: #202124;
border: none;
border-radius: 6px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
display: flex;
align-items: center;
gap: 6px;
}
.dropdown-toggle:hover {
background: #e8eaed;
}
.dropdown-toggle::after {
content: 'โ–ผ';
font-size: 10px;
transition: transform 0.2s;
}
.dropdown:hover .dropdown-toggle::after {
transform: rotate(180deg);
}
.dropdown-menu {
position: absolute;
top: calc(100% + 4px);
left: 0;
margin-top: 0;
background: white;
border: 1px solid #dadce0;
border-radius: 6px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
min-width: 200px;
opacity: 0;
visibility: hidden;
transform: translateY(-8px);
transition: all 0.2s ease;
z-index: 10000;
padding: 4px 0;
pointer-events: none;
}
.dropdown:hover .dropdown-menu {
opacity: 1;
visibility: visible;
transform: translateY(0);
pointer-events: auto;
}
.dropdown-item {
display: block;
padding: 10px 16px;
color: #202124;
text-decoration: none;
font-size: 14px;
transition: background 0.2s;
}
.dropdown-item:hover {
background: #f8f9fa;
}
.dropdown-item:first-child {
border-top-left-radius: 6px;
border-top-right-radius: 6px;
}
.dropdown-item:last-child {
border-bottom-left-radius: 6px;
border-bottom-right-radius: 6px;
}
.menu-toggle {
display: none;
background: none;
border: none;
font-size: 24px;
cursor: pointer;
padding: 8px;
color: #202124;
}
.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: white;
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 #dadce0;
display: flex;
justify-content: space-between;
align-items: center;
background: white;
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: #202124;
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: #202124;
text-decoration: none;
border-bottom: 1px solid #f1f3f4;
transition: background 0.2s;
}
.mobile-menu-item:hover {
background: #f8f9fa;
}
.mobile-menu-user {
padding: 16px 20px;
border-bottom: 1px solid #dadce0;
color: #5f6368;
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: #1a73e8;
color: white;
}
.btn-primary:hover {
background: #1557b0;
}
.btn-secondary {
background: #f1f3f4;
color: #202124;
}
.btn-secondary:hover {
background: #e8eaed;
}
.btn-info {
background: #17a2b8;
color: white;
}
.btn-info:hover {
background: #138496;
}
.container {
max-width: 1400px;
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: #5f6368;
}
.card {
background: white;
border-radius: 8px;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
padding: 24px;
margin-bottom: 24px;
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
}
.card-title {
font-size: 18px;
font-weight: 500;
}
table {
width: 100%;
border-collapse: collapse;
}
thead {
background: #f8f9fa;
}
th, td {
padding: 12px;
text-align: left;
border-bottom: 1px solid #e8eaed;
}
th {
font-weight: 500;
font-size: 14px;
color: #5f6368;
}
td {
font-size: 14px;
}
.file-actions {
display: flex;
gap: 4px;
}
.badge {
display: inline-block;
padding: 4px 8px;
border-radius: 4px;
font-size: 12px;
font-weight: 500;
}
.badge-success {
background: #e8f5e9;
color: #137333;
}
.badge-info {
background: #e8f0fe;
color: #1967d2;
}
.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: white;
border-radius: 8px;
padding: 24px;
width: 90%;
max-width: 1200px;
max-height: 90vh;
overflow-y: auto;
}
.modal-body {
max-height: calc(90vh - 100px);
overflow-y: auto;
}
#tagsContent {
max-height: calc(90vh - 120px);
overflow-y: auto;
}
#simpleTagsContent, #detailedTagsContent {
max-height: calc(90vh - 180px);
overflow-y: auto;
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
border-bottom: 1px solid #e8eaed;
padding-bottom: 16px;
}
.modal-title {
font-size: 20px;
font-weight: 500;
}
.modal-close {
background: none;
border: none;
font-size: 24px;
cursor: pointer;
color: #5f6368;
}
.chunk-list {
max-height: 60vh;
overflow-y: auto;
border: 1px solid #e8eaed;
border-radius: 6px;
padding: 12px;
}
.chunk-item {
padding: 12px;
margin-bottom: 12px;
border: 1px solid #e8eaed;
border-radius: 6px;
background: #f8f9fa;
cursor: pointer;
transition: all 0.2s;
}
.chunk-item:hover {
background: #e8f0fe;
border-color: #1a73e8;
}
.chunk-item-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
}
.chunk-item-index {
font-weight: 600;
color: #1a73e8;
}
.chunk-item-preview {
color: #5f6368;
font-size: 13px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.chunk-content-modal {
max-width: 1000px;
}
.chunk-content {
white-space: pre-wrap;
font-family: inherit;
line-height: 1.6;
color: #202124;
padding: 16px;
background: #f8f9fa;
border-radius: 6px;
max-height: 70vh;
overflow-y: auto;
}
.chunk-metadata {
margin-top: 16px;
padding: 12px;
background: #e8f0fe;
border-radius: 6px;
font-size: 13px;
}
.chunk-metadata-title {
font-weight: 600;
margin-bottom: 8px;
color: #1967d2;
}
.chunk-metadata-item {
margin-bottom: 4px;
}
.filter-section {
margin-bottom: 16px;
display: flex;
gap: 12px;
align-items: center;
}
.filter-section select {
padding: 8px 12px;
border: 1px solid #dadce0;
border-radius: 6px;
font-size: 14px;
background: white;
}
/* GraphRAG ์‚ฌ์ด๋“œ๋ฐ” ์Šคํƒ€์ผ */
.episode-sidebar-item {
padding: 10px 12px;
border-radius: 6px;
cursor: pointer;
transition: all 0.2s;
font-size: 14px;
color: #202124;
margin-bottom: 4px;
border: 1px solid transparent;
}
.episode-sidebar-item:hover {
background: #e8f0fe;
border-color: #1a73e8;
}
.episode-sidebar-item.active {
background: #1a73e8;
color: white;
font-weight: 500;
}
.episode-sidebar-item.active:hover {
background: #1557b0;
}
</style>
</head>
<body>
<div class="header">
<div class="header-title">
<span>๐Ÿค–</span>
<span>SOY NV AI ๊ด€๋ฆฌ์ž ํŽ˜์ด์ง€</span>
</div>
<button class="menu-toggle" onclick="toggleMobileMenu()" aria-label="๋ฉ”๋‰ด ์—ด๊ธฐ">โ˜ฐ</button>
<div class="header-actions">
<span style="margin-right: 12px; color: #5f6368;">{{ current_user.nickname or current_user.username }}</span>
{# ์‚ฌ์ดํŠธ ๊ด€๋ฆฌ #}
<div class="dropdown">
<button type="button" class="dropdown-toggle">์‚ฌ์ดํŠธ ๊ด€๋ฆฌ</button>
<div class="dropdown-menu">
<a href="{{ url_for('main.admin') }}" class="dropdown-item">์‚ฌ์šฉ์ž ๊ด€๋ฆฌ</a>
<a href="{{ url_for('main.admin_tokens') }}" class="dropdown-item">ํ† ํฐ ํ†ต๊ณ„</a>
</div>
</div>
{# ์›น์†Œ์„ค ๊ด€๋ฆฌ #}
<div class="dropdown">
<button type="button" class="dropdown-toggle">์›น์†Œ์„ค ๊ด€๋ฆฌ</button>
<div class="dropdown-menu">
<a href="{{ url_for('main.admin_webnovels') }}" class="dropdown-item">์›น์†Œ์„ค ์—…๋กœ๋“œ ๋ฐ ๊ด€๋ฆฌ</a>
<a href="{{ url_for('main.admin_messages') }}" class="dropdown-item">๋ฉ”์‹œ์ง€ ํ™•์ธ</a>
</div>
</div>
{# AI ์„ค์ • #}
<div class="dropdown">
<button type="button" class="dropdown-toggle">AI ์„ค์ •</button>
<div class="dropdown-menu">
<a href="{{ url_for('main.admin_settings') }}" class="dropdown-item">AI ์„ค์ •</a>
<a href="{{ url_for('main.admin_prompts') }}" class="dropdown-item">ํ”„๋กฌํ”„ํŠธ ๊ด€๋ฆฌ</a>
</div>
</div>
{# ์ฑ—๋ด‡ #}
<div class="dropdown">
<button type="button" class="dropdown-toggle">์ฑ—๋ด‡</button>
<div class="dropdown-menu">
<a href="{{ url_for('main.admin_tags') }}" class="dropdown-item">ํƒœ๊ทธ/ํ”„๋กฌํ”„ํŠธ</a>
<a href="{{ url_for('main.admin_chatbot_prompts') }}" class="dropdown-item">์ฑ—๋ด‡ ํ”„๋กฌํ”„ํŠธ</a>
</div>
</div>
{# ํŽธ์˜๊ธฐ๋Šฅ #}
<div class="dropdown">
<button type="button" class="dropdown-toggle">ํŽธ์˜๊ธฐ๋Šฅ</button>
<div class="dropdown-menu">
<a href="{{ url_for('main.admin_utils') }}" class="dropdown-item">์œ ํ‹ธ</a>
</div>
</div>
{# ๋ฉ”์ธ์œผ๋กœ #}
<a href="{{ url_for('main.index') }}" class="btn btn-secondary" style="padding: 8px 16px; font-size: 14px; margin-right: 4px;">๋ฉ”์ธ์œผ๋กœ</a>
<a href="{{ url_for('main.logout') }}" class="btn btn-secondary" style="padding: 8px 16px; font-size: 14px;">๋กœ๊ทธ์•„์›ƒ</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">
<div style="padding: 8px 20px; font-size: 11px; font-weight: 600; color: #5f6368; text-transform: uppercase; border-bottom: 1px solid #f1f3f4;">์‚ฌ์ดํŠธ ๊ด€๋ฆฌ</div>
<a href="{{ url_for('main.admin') }}" class="mobile-menu-item" onclick="closeMobileMenu()">์‚ฌ์šฉ์ž ๊ด€๋ฆฌ</a>
<a href="{{ url_for('main.admin_tokens') }}" class="mobile-menu-item" onclick="closeMobileMenu()">ํ† ํฐ ํ†ต๊ณ„</a>
<div style="padding: 8px 20px; font-size: 11px; font-weight: 600; color: #5f6368; text-transform: uppercase; border-bottom: 1px solid #f1f3f4; margin-top: 8px;">์›น์†Œ์„ค ๊ด€๋ฆฌ</div>
<a href="{{ url_for('main.admin_webnovels') }}" class="mobile-menu-item" onclick="closeMobileMenu()">์›น์†Œ์„ค ์—…๋กœ๋“œ ๋ฐ ๊ด€๋ฆฌ</a>
<a href="{{ url_for('main.admin_messages') }}" class="mobile-menu-item" onclick="closeMobileMenu()">๋ฉ”์‹œ์ง€ ํ™•์ธ</a>
<div style="padding: 8px 20px; font-size: 11px; font-weight: 600; color: #5f6368; text-transform: uppercase; border-bottom: 1px solid #f1f3f4; margin-top: 8px;">AI ์„ค์ •</div>
<a href="{{ url_for('main.admin_settings') }}" class="mobile-menu-item" onclick="closeMobileMenu()">AI ์„ค์ •</a>
<a href="{{ url_for('main.admin_prompts') }}" class="mobile-menu-item" onclick="closeMobileMenu()">ํ”„๋กฌํ”„ํŠธ ๊ด€๋ฆฌ</a>
<div style="padding: 8px 20px; font-size: 11px; font-weight: 600; color: #5f6368; text-transform: uppercase; border-bottom: 1px solid #f1f3f4; margin-top: 8px;">์ฑ—๋ด‡</div>
<a href="{{ url_for('main.admin_tags') }}" class="mobile-menu-item" onclick="closeMobileMenu()">ํƒœ๊ทธ/ํ”„๋กฌํ”„ํŠธ</a>
<a href="{{ url_for('main.admin_chatbot_prompts') }}" class="mobile-menu-item" onclick="closeMobileMenu()">์ฑ—๋ด‡ ํ”„๋กฌํ”„ํŠธ</a>
<div style="padding: 8px 20px; font-size: 11px; font-weight: 600; color: #5f6368; text-transform: uppercase; border-bottom: 1px solid #f1f3f4; margin-top: 8px;">ํŽธ์˜๊ธฐ๋Šฅ</div>
<a href="{{ url_for('main.admin_utils') }}" class="mobile-menu-item" onclick="closeMobileMenu()">์œ ํ‹ธ</a>
<div style="padding: 8px 20px; font-size: 11px; font-weight: 600; color: #5f6368; text-transform: uppercase; border-bottom: 1px solid #f1f3f4; margin-top: 8px;">๊ธฐํƒ€</div>
<a href="{{ url_for('main.index') }}" class="mobile-menu-item" onclick="closeMobileMenu()">๋ฉ”์ธ์œผ๋กœ</a>
<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 id="alertContainer"></div>
<div class="card">
<div class="card-header">
<div class="card-title">ํŒŒ์ผ ๋ชฉ๋ก</div>
<div class="filter-section">
<select id="modelFilter" onchange="loadFiles()">
<option value="">๋ชจ๋“  ๋ชจ๋ธ</option>
</select>
</div>
</div>
<div id="filesTableContainer">
<table>
<thead>
<tr>
<th>ID</th>
<th>ํŒŒ์ผ๋ช…</th>
<th>๋ชจ๋ธ</th>
<th>์ฒญํฌ ์ˆ˜</th>
<th>ํŒŒ์ผ ํฌ๊ธฐ</th>
<th>์—…๋กœ๋“œ์ผ</th>
<th>์ž‘์—…</th>
</tr>
</thead>
<tbody id="filesTableBody">
<tr>
<td colspan="7" style="text-align: center; padding: 24px; color: #5f6368;">
ํŒŒ์ผ ๋ชฉ๋ก์„ ๋ถˆ๋Ÿฌ์˜ค๋Š” ์ค‘...
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
<!-- ์ฒญํฌ ๋ชฉ๋ก ๋ชจ๋‹ฌ -->
<div id="chunksModal" class="modal">
<div class="modal-content">
<div class="modal-header">
<div class="modal-title" id="chunksModalTitle">์ฒญํฌ ๋ชฉ๋ก</div>
<button class="modal-close" onclick="closeChunksModal()">&times;</button>
</div>
<div id="chunksList" class="chunk-list">
<div style="text-align: center; padding: 24px; color: #5f6368;">
์ฒญํฌ ๋ชฉ๋ก์„ ๋ถˆ๋Ÿฌ์˜ค๋Š” ์ค‘...
</div>
</div>
</div>
</div>
<!-- ์š”์•ฝ ๋‚ด์šฉ ๋ชจ๋‹ฌ -->
<div id="summaryModal" class="modal">
<div class="modal-content chunk-content-modal">
<div class="modal-header">
<div class="modal-title" id="summaryModalTitle">์š”์•ฝ ๋‚ด์šฉ</div>
<button class="modal-close" onclick="closeSummaryModal()">&times;</button>
</div>
<div id="summaryContent" class="chunk-content">
๋‚ด์šฉ์„ ๋ถˆ๋Ÿฌ์˜ค๋Š” ์ค‘...
</div>
</div>
</div>
<!-- ์ฒญํฌ ๋‚ด์šฉ ๋ชจ๋‹ฌ -->
<div id="chunkContentModal" class="modal">
<div class="modal-content chunk-content-modal">
<div class="modal-header">
<div class="modal-title" id="chunkContentModalTitle">์ฒญํฌ ๋‚ด์šฉ</div>
<button class="modal-close" onclick="closeChunkContentModal()">&times;</button>
</div>
<div id="chunkContent" class="chunk-content">
๋‚ด์šฉ์„ ๋ถˆ๋Ÿฌ์˜ค๋Š” ์ค‘...
</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">GraphRAG ๋ฐ์ดํ„ฐ</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: #f8f9fa; border-right: 1px solid #e8eaed; overflow-y: auto; flex-shrink: 0; padding: 16px;">
<div style="font-size: 14px; font-weight: 600; color: #5f6368; margin-bottom: 12px; padding-bottom: 8px; border-bottom: 1px solid #e8eaed;">
ํšŒ์ฐจ ๋ชฉ๋ก
</div>
<div id="graphRAGEpisodeList" style="display: flex; flex-direction: column; gap: 4px;">
<div style="text-align: center; padding: 24px; color: #5f6368; 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: #5f6368;">
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">GraphRAG ๊ทธ๋ž˜ํ”„ ์‹œ๊ฐํ™”</div>
<button class="modal-close" onclick="closeGraphRAGVisualizationModal()">&times;</button>
</div>
<div style="padding: 16px; border-bottom: 1px solid #dadce0; background: #f8f9fa; display: flex; gap: 12px; align-items: center; flex-wrap: wrap;">
<div style="position: relative;">
<button id="episodeFilterToggle" onclick="toggleEpisodeFilter()" style="padding: 8px 16px; background: white; border: 1px solid #dadce0; border-radius: 6px; font-size: 14px; cursor: pointer; display: flex; align-items: center; gap: 8px; color: #202124; 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: white; border: 1px solid #dadce0; 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 #e8eaed;">
<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='#f8f9fa'" onmouseout="this.style.background='transparent'">
<input type="checkbox" id="episodeFilterAll" onchange="handleEpisodeFilterAllChange()" 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: #1a73e8; 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: #ffffff;">
<div style="text-align: center; padding: 24px; color: #5f6368; 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>
<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 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];
}
function formatDate(dateString) {
const date = new Date(dateString);
return date.toLocaleDateString('ko-KR', {
year: 'numeric',
month: 'long',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
});
}
function showAlert(message, type = 'success') {
const container = document.getElementById('alertContainer');
container.innerHTML = `<div class="alert ${type}" style="padding: 12px 16px; border-radius: 6px; margin-bottom: 16px; font-size: 14px; background: ${type === 'success' ? '#e8f5e9' : '#fce8e6'}; color: ${type === 'success' ? '#137333' : '#c5221f'};">${message}</div>`;
setTimeout(() => {
container.innerHTML = '';
}, 5000);
}
async function loadModelFilter() {
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('modelFilter');
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 loadFiles() {
const tbody = document.getElementById('filesTableBody');
const modelFilter = document.getElementById('modelFilter');
const modelName = modelFilter ? modelFilter.value : '';
tbody.innerHTML = '<tr><td colspan="7" style="text-align: center; padding: 24px; color: #5f6368;">ํŒŒ์ผ ๋ชฉ๋ก์„ ๋ถˆ๋Ÿฌ์˜ค๋Š” ์ค‘...</td></tr>';
try {
const url = modelName ? `/api/files?model_name=${encodeURIComponent(modelName)}` : '/api/files';
console.log('[ํŒŒ์ผ ๋ชฉ๋ก] API ์š”์ฒญ:', url);
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);
const files = data.files || [];
console.log('[ํŒŒ์ผ ๋ชฉ๋ก] ํŒŒ์ผ ๊ฐœ์ˆ˜:', files.length);
if (files.length === 0) {
tbody.innerHTML = '<tr><td colspan="7" style="text-align: center; padding: 24px; color: #5f6368;">์—…๋กœ๋“œ๋œ ํŒŒ์ผ์ด ์—†์Šต๋‹ˆ๋‹ค.</td></tr>';
return;
}
tbody.innerHTML = '';
files.forEach(file => {
console.log('[ํŒŒ์ผ ๋ชฉ๋ก] ํŒŒ์ผ ์ฒ˜๋ฆฌ:', file);
const row = document.createElement('tr');
row.innerHTML = `
<td>${file.id}</td>
<td>${escapeHtml(file.original_filename)}</td>
<td>${escapeHtml(file.model_name || '๋ฏธ์ง€์ •')}</td>
<td><span class="badge badge-info">${file.chunk_count || 0}๊ฐœ</span></td>
<td>${formatFileSize(file.file_size || 0)}</td>
<td>${formatDate(file.uploaded_at)}</td>
<td>
<div class="file-actions">
<button class="btn btn-secondary" onclick="viewSummary(${file.id}, '${escapeHtml(file.original_filename)}')" style="padding: 4px 8px; font-size: 12px; margin-right: 4px;">์š”์•ฝ ๋‚ด์šฉ ๋ณด๊ธฐ</button>
<button class="btn btn-info" onclick="viewChunks(${file.id}, '${escapeHtml(file.original_filename)}')" style="padding: 4px 8px; font-size: 12px; margin-right: 4px;">์ฒญํฌ ๋ณด๊ธฐ</button>
<button class="btn btn-primary" onclick="viewGraphRAG(${file.id}, '${escapeHtml(file.original_filename)}')" style="padding: 4px 8px; font-size: 12px; margin-right: 4px;">GraphRAG ๋ณด๊ธฐ</button>
<button class="btn btn-success" onclick="viewGraphRAGVisualization(${file.id}, '${escapeHtml(file.original_filename)}')" style="padding: 4px 8px; font-size: 12px;">๊ทธ๋ž˜ํ”„ ์‹œ๊ฐํ™”</button>
</div>
</td>
`;
tbody.appendChild(row);
});
} catch (error) {
console.error('[ํŒŒ์ผ ๋ชฉ๋ก] ๋กœ๋“œ ์˜ค๋ฅ˜:', error);
tbody.innerHTML = `<tr><td colspan="7" style="text-align: center; padding: 24px; color: #c5221f;">ํŒŒ์ผ ๋ชฉ๋ก์„ ๋ถˆ๋Ÿฌ์˜ค๋Š” ์ค‘ ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ–ˆ์Šต๋‹ˆ๋‹ค.<br><small>${escapeHtml(error.message)}</small></td></tr>`;
showAlert(`ํŒŒ์ผ ๋ชฉ๋ก์„ ๋ถˆ๋Ÿฌ์˜ค๋Š” ์ค‘ ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ–ˆ์Šต๋‹ˆ๋‹ค: ${error.message}`, 'error');
}
}
async function viewChunks(fileId, fileName) {
const modal = document.getElementById('chunksModal');
const title = document.getElementById('chunksModalTitle');
const list = document.getElementById('chunksList');
title.textContent = `์ฒญํฌ ๋ชฉ๋ก - ${fileName}`;
list.innerHTML = '<div style="text-align: center; padding: 24px; color: #5f6368;">์ฒญํฌ ๋ชฉ๋ก์„ ๋ถˆ๋Ÿฌ์˜ค๋Š” ์ค‘...</div>';
modal.classList.add('active');
try {
const response = await fetch(`/api/files/${fileId}/chunks/all`);
if (!response.ok) throw new Error('์ฒญํฌ ๋ชฉ๋ก์„ ๋ถˆ๋Ÿฌ์˜ฌ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.');
const data = await response.json();
const chunks = data.chunks || [];
if (chunks.length === 0) {
list.innerHTML = '<div style="text-align: center; padding: 24px; color: #5f6368;">์ƒ์„ฑ๋œ ์ฒญํฌ๊ฐ€ ์—†์Šต๋‹ˆ๋‹ค.</div>';
return;
}
list.innerHTML = '';
chunks.forEach(chunk => {
const chunkItem = document.createElement('div');
chunkItem.className = 'chunk-item';
chunkItem.onclick = () => viewChunkContent(chunk, fileName);
const preview = chunk.content.length > 150
? chunk.content.substring(0, 150) + '...'
: chunk.content;
chunkItem.innerHTML = `
<div class="chunk-item-header">
<span class="chunk-item-index">์ฒญํฌ #${chunk.chunk_index}</span>
<span style="font-size: 12px; color: #5f6368;">${chunk.content_length}์ž</span>
</div>
<div class="chunk-item-preview">${escapeHtml(preview)}</div>
`;
list.appendChild(chunkItem);
});
} catch (error) {
console.error('์ฒญํฌ ๋ชฉ๋ก ๋กœ๋“œ ์˜ค๋ฅ˜:', error);
list.innerHTML = '<div style="text-align: center; padding: 24px; color: #c5221f;">์ฒญํฌ ๋ชฉ๋ก์„ ๋ถˆ๋Ÿฌ์˜ค๋Š” ์ค‘ ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ–ˆ์Šต๋‹ˆ๋‹ค.</div>';
showAlert('์ฒญํฌ ๋ชฉ๋ก์„ ๋ถˆ๋Ÿฌ์˜ค๋Š” ์ค‘ ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ–ˆ์Šต๋‹ˆ๋‹ค.', 'error');
}
}
function closeChunksModal() {
document.getElementById('chunksModal').classList.remove('active');
}
function viewChunkContent(chunk, fileName) {
const modal = document.getElementById('chunkContentModal');
const title = document.getElementById('chunkContentModalTitle');
const content = document.getElementById('chunkContent');
title.textContent = `${fileName} - ์ฒญํฌ #${chunk.chunk_index}`;
let contentHtml = `<div style="white-space: pre-wrap; font-family: inherit; line-height: 1.6; color: #202124; margin-bottom: 16px;">${escapeHtml(chunk.content)}</div>`;
if (chunk.metadata) {
contentHtml += '<div class="chunk-metadata">';
contentHtml += '<div class="chunk-metadata-title">๋ฉ”ํƒ€๋ฐ์ดํ„ฐ</div>';
if (chunk.metadata.chapter) {
contentHtml += `<div class="chunk-metadata-item"><strong>์ฑ•ํ„ฐ:</strong> ${escapeHtml(chunk.metadata.chapter)}</div>`;
}
if (chunk.metadata.pov) {
contentHtml += `<div class="chunk-metadata-item"><strong>์‹œ์ :</strong> ${escapeHtml(chunk.metadata.pov)}</div>`;
}
if (chunk.metadata.characters && chunk.metadata.characters.length > 0) {
contentHtml += `<div class="chunk-metadata-item"><strong>๋“ฑ์žฅ ์ธ๋ฌผ:</strong> ${escapeHtml(chunk.metadata.characters.join(', '))}</div>`;
}
if (chunk.metadata.time_background) {
contentHtml += `<div class="chunk-metadata-item"><strong>์‹œ๊ฐ„ ๋ฐฐ๊ฒฝ:</strong> ${escapeHtml(chunk.metadata.time_background)}</div>`;
}
if (chunk.metadata.character_relationships && chunk.metadata.character_relationships.length > 0) {
contentHtml += `<div class="chunk-metadata-item"><strong>์ธ๋ฌผ ๊ด€๊ณ„:</strong> ${escapeHtml(JSON.stringify(chunk.metadata.character_relationships, null, 2))}</div>`;
}
contentHtml += '</div>';
}
content.innerHTML = contentHtml;
modal.classList.add('active');
}
async function viewSummary(fileId, fileName) {
const modal = document.getElementById('summaryModal');
const title = document.getElementById('summaryModalTitle');
const content = document.getElementById('summaryContent');
title.textContent = `์š”์•ฝ ๋‚ด์šฉ - ${fileName}`;
content.innerHTML = '<div style="text-align: center; padding: 24px; color: #5f6368;">์š”์•ฝ ๋‚ด์šฉ์„ ๋ถˆ๋Ÿฌ์˜ค๋Š” ์ค‘...</div>';
modal.classList.add('active');
try {
const response = await fetch(`/api/files/${fileId}/summary`, {
credentials: 'include'
});
if (!response.ok) throw new Error('์š”์•ฝ ๋‚ด์šฉ์„ ๋ถˆ๋Ÿฌ์˜ฌ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.');
const data = await response.json();
let contentHtml = '';
// Parent Chunk ๋‚ด์šฉ
if (data.parent_chunk) {
contentHtml += '<div style="margin-bottom: 32px;">';
contentHtml += '<h3 style="font-size: 18px; font-weight: 600; margin-bottom: 16px; color: #1a73e8; border-bottom: 2px solid #1a73e8; padding-bottom: 8px;">Parent Chunk (์ž‘ํ’ˆ ์ „์ฒด ์š”์•ฝ)</h3>';
if (data.parent_chunk.world_view) {
contentHtml += '<div style="margin-bottom: 16px;">';
contentHtml += '<h4 style="font-size: 16px; font-weight: 600; margin-bottom: 8px; color: #1967d2;">์„ธ๊ณ„๊ด€</h4>';
contentHtml += `<div style="white-space: pre-wrap; line-height: 1.6; padding: 12px; background: #f8f9fa; border-radius: 6px;">${escapeHtml(data.parent_chunk.world_view)}</div>`;
contentHtml += '</div>';
}
if (data.parent_chunk.characters) {
contentHtml += '<div style="margin-bottom: 16px;">';
contentHtml += '<h4 style="font-size: 16px; font-weight: 600; margin-bottom: 8px; color: #1967d2;">์ฃผ์š” ์บ๋ฆญํ„ฐ</h4>';
contentHtml += `<div style="white-space: pre-wrap; line-height: 1.6; padding: 12px; background: #f8f9fa; border-radius: 6px;">${escapeHtml(data.parent_chunk.characters)}</div>`;
contentHtml += '</div>';
}
if (data.parent_chunk.story) {
contentHtml += '<div style="margin-bottom: 16px;">';
contentHtml += '<h4 style="font-size: 16px; font-weight: 600; margin-bottom: 8px; color: #1967d2;">์ฃผ์š” ์Šคํ† ๋ฆฌ</h4>';
contentHtml += `<div style="white-space: pre-wrap; line-height: 1.6; padding: 12px; background: #f8f9fa; border-radius: 6px;">${escapeHtml(data.parent_chunk.story)}</div>`;
contentHtml += '</div>';
}
if (data.parent_chunk.episodes) {
contentHtml += '<div style="margin-bottom: 16px;">';
contentHtml += '<h4 style="font-size: 16px; font-weight: 600; margin-bottom: 8px; color: #1967d2;">์ฃผ์š” ์—ํ”ผ์†Œ๋“œ</h4>';
contentHtml += `<div style="white-space: pre-wrap; line-height: 1.6; padding: 12px; background: #f8f9fa; border-radius: 6px;">${escapeHtml(data.parent_chunk.episodes)}</div>`;
contentHtml += '</div>';
}
if (data.parent_chunk.others) {
contentHtml += '<div style="margin-bottom: 16px;">';
contentHtml += '<h4 style="font-size: 16px; font-weight: 600; margin-bottom: 8px; color: #1967d2;">๊ธฐํƒ€</h4>';
contentHtml += `<div style="white-space: pre-wrap; line-height: 1.6; padding: 12px; background: #f8f9fa; border-radius: 6px;">${escapeHtml(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 style="margin-top: 32px; border-top: 2px solid #e8eaed; padding-top: 24px;">';
contentHtml += '<h3 style="font-size: 18px; font-weight: 600; margin-bottom: 16px; color: #1a73e8; border-bottom: 2px solid #1a73e8; padding-bottom: 8px;">ํšŒ์ฐจ๋ณ„ ๋ถ„์„</h3>';
contentHtml += `<div style="white-space: pre-wrap; line-height: 1.6; padding: 12px; background: #f8f9fa; border-radius: 6px;">${escapeHtml(data.episode_analysis.analysis_content)}</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: #5f6368;">์š”์•ฝ ๋‚ด์šฉ์ด ์—†์Šต๋‹ˆ๋‹ค.</div>';
}
content.innerHTML = contentHtml;
} catch (error) {
console.error('์š”์•ฝ ๋‚ด์šฉ ๋กœ๋“œ ์˜ค๋ฅ˜:', error);
content.innerHTML = '<div style="text-align: center; padding: 24px; color: #c5221f;">์š”์•ฝ ๋‚ด์šฉ์„ ๋ถˆ๋Ÿฌ์˜ค๋Š” ์ค‘ ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ–ˆ์Šต๋‹ˆ๋‹ค.</div>';
showAlert('์š”์•ฝ ๋‚ด์šฉ์„ ๋ถˆ๋Ÿฌ์˜ค๋Š” ์ค‘ ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ–ˆ์Šต๋‹ˆ๋‹ค.', 'error');
}
}
function closeSummaryModal() {
document.getElementById('summaryModal').classList.remove('active');
}
function closeChunkContentModal() {
document.getElementById('chunkContentModal').classList.remove('active');
}
// ํšŒ์ฐจ๋ฅผ ์ˆซ์ž ์ˆœ์„œ๋กœ ์ •๋ ฌํ•˜๋Š” ํ•จ์ˆ˜ (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;
});
}
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 = `GraphRAG ๋ฐ์ดํ„ฐ - ${fileName}`;
content.innerHTML = '<div style="text-align: center; padding: 24px; color: #5f6368;">GraphRAG ๋ฐ์ดํ„ฐ๋ฅผ ๋ถˆ๋Ÿฌ์˜ค๋Š” ์ค‘...</div>';
sidebar.innerHTML = '<div style="text-align: center; padding: 24px; color: #5f6368; 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: #5f6368; 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: #e8f0fe; border-radius: 6px;">';
contentHtml += '<h3 style="font-size: 18px; font-weight: 600; margin-bottom: 16px; color: #1a73e8;">ํ†ต๊ณ„ ์ •๋ณด</h3>';
contentHtml += '<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 16px;">';
contentHtml += `<div style="padding: 12px; background: white; border-radius: 6px;"><strong>์—”ํ‹ฐํ‹ฐ:</strong> ${data.statistics.total_entities}๊ฐœ</div>`;
contentHtml += `<div style="padding: 12px; background: white; border-radius: 6px;"><strong>๊ด€๊ณ„:</strong> ${data.statistics.total_relationships}๊ฐœ</div>`;
contentHtml += `<div style="padding: 12px; background: white; border-radius: 6px;"><strong>์‚ฌ๊ฑด:</strong> ${data.statistics.total_events}๊ฐœ</div>`;
contentHtml += `<div style="padding: 12px; background: white; 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: #5f6368;">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: #f8f9fa; border-radius: 8px; border-left: 4px solid #1a73e8; scroll-margin-top: 20px;">`;
contentHtml += `<h3 style="font-size: 20px; font-weight: 600; margin-bottom: 20px; color: #1a73e8;">${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: white; border-radius: 6px; border: 1px solid #e8eaed;">';
contentHtml += `<div style="font-weight: 600; margin-bottom: 8px; color: #1a73e8;">${escapeHtml(char.entity_name)}</div>`;
if (char.role) {
contentHtml += `<div style="font-size: 13px; color: #5f6368; margin-bottom: 4px;"><strong>์—ญํ• :</strong> ${escapeHtml(char.role)}</div>`;
}
if (char.description) {
contentHtml += `<div style="font-size: 13px; color: #5f6368;">${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: white; border-radius: 6px; border: 1px solid #e8eaed;">';
contentHtml += `<div style="font-weight: 600; margin-bottom: 8px; color: #1a73e8;">${escapeHtml(loc.entity_name)}</div>`;
if (loc.category) {
contentHtml += `<div style="font-size: 13px; color: #5f6368; margin-bottom: 4px;"><strong>์œ ํ˜•:</strong> ${escapeHtml(loc.category)}</div>`;
}
if (loc.description) {
contentHtml += `<div style="font-size: 13px; color: #5f6368;">${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: white; border-radius: 6px; border: 1px solid #e8eaed;">';
contentHtml += `<div style="margin-bottom: 8px;">`;
contentHtml += `<span style="font-weight: 600; color: #1a73e8;">${escapeHtml(rel.source)}</span>`;
contentHtml += `<span style="margin: 0 8px; color: #5f6368;">โ†’</span>`;
contentHtml += `<span style="font-weight: 600; color: #1a73e8;">${escapeHtml(rel.target)}</span>`;
contentHtml += '</div>';
if (rel.relationship_type) {
contentHtml += `<div style="font-size: 13px; color: #5f6368; margin-bottom: 4px;"><strong>๊ด€๊ณ„ ์œ ํ˜•:</strong> ${escapeHtml(rel.relationship_type)}</div>`;
}
if (rel.description) {
contentHtml += `<div style="font-size: 13px; color: #5f6368; 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: white; border-radius: 6px; border: 1px solid #e8eaed;">';
if (event.event_name) {
contentHtml += `<div style="font-weight: 600; margin-bottom: 8px; color: #1a73e8;">${escapeHtml(event.event_name)}</div>`;
}
if (event.description) {
contentHtml += `<div style="font-size: 13px; color: #5f6368; 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: #5f6368; margin-bottom: 4px;"><strong>๊ด€๋ จ ์ธ๋ฌผ:</strong> ${escapeHtml(event.participants.join(', '))}</div>`;
}
if (event.location) {
contentHtml += `<div style="font-size: 13px; color: #5f6368; 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: #c5221f;">GraphRAG ๋ฐ์ดํ„ฐ๋ฅผ ๋ถˆ๋Ÿฌ์˜ค๋Š” ์ค‘ ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ–ˆ์Šต๋‹ˆ๋‹ค.</div>';
showAlert('GraphRAG ๋ฐ์ดํ„ฐ๋ฅผ ๋ถˆ๋Ÿฌ์˜ค๋Š” ์ค‘ ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ–ˆ์Šต๋‹ˆ๋‹ค.', 'error');
}
}
// ํŠน์ • ํšŒ์ฐจ๋กœ ์Šคํฌ๋กค
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');
}
});
}
function closeGraphRAGModal() {
document.getElementById('graphRAGModal').classList.remove('active');
}
// GraphRAG ๊ทธ๋ž˜ํ”„ ์‹œ๊ฐํ™” ๊ด€๋ จ ๋ณ€์ˆ˜
let graphData = null;
let graphNetwork = null;
let allGraphData = null;
async function viewGraphRAGVisualization(fileId, fileName) {
const modal = document.getElementById('graphRAGVisualizationModal');
const title = document.getElementById('graphRAGVisualizationModalTitle');
const content = document.getElementById('graphRAGVisualizationContent');
title.textContent = `GraphRAG ๊ทธ๋ž˜ํ”„ ์‹œ๊ฐํ™” - ${fileName}`;
content.innerHTML = '<div style="text-align: center; padding: 24px; color: #5f6368; position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%);">ํšŒ์ฐจ์™€ ๋…ธ๋“œ ํƒ€์ž…์„ ์„ ํƒํ•˜์—ฌ ๊ทธ๋ž˜ํ”„๋ฅผ ํ™•์ธํ•˜์„ธ์š”.</div>';
modal.classList.add('active');
// ๊ธฐ์กด ๋„คํŠธ์›Œํฌ ์ œ๊ฑฐ
if (graphNetwork) {
graphNetwork.destroy();
graphNetwork = 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();
allGraphData = 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 = '#f8f9fa'; };
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 = handleIndividualEpisodeChange;
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);
});
}
// ๋ฒ„ํŠผ ํ…์ŠคํŠธ ์ดˆ๊ธฐํ™”
updateEpisodeFilterButtonText();
// ์ดˆ๊ธฐ์—๋Š” ๊ทธ๋ž˜ํ”„๋ฅผ ์ƒ์„ฑํ•˜์ง€ ์•Š์Œ (๋นˆ ํ™”๋ฉด)
// ์‚ฌ์šฉ์ž๊ฐ€ ํ•„ํ„ฐ๋‚˜ ์ฒดํฌ๋ฐ•์Šค๋ฅผ ์„ ํƒํ•˜๋ฉด ๊ทธ๋ž˜ํ”„๊ฐ€ ์ƒ์„ฑ๋จ
} catch (error) {
console.error('GraphRAG ๊ทธ๋ž˜ํ”„ ๋กœ๋“œ ์˜ค๋ฅ˜:', error);
content.innerHTML = '<div style="text-align: center; padding: 24px; color: #c5221f; position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%);">๊ทธ๋ž˜ํ”„๋ฅผ ๋ถˆ๋Ÿฌ์˜ค๋Š” ์ค‘ ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ–ˆ์Šต๋‹ˆ๋‹ค.</div>';
}
}
function createGraphVisualization(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: #5f6368; 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: #5f6368; 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 = 'graphNetworkContainer';
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
}
};
graphNetwork = new vis.Network(container, graphData, options);
// ๋„คํŠธ์›Œํฌ ์ด๋ฒคํŠธ ๋ฆฌ์Šค๋„ˆ
graphNetwork.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() ํ˜ธ์ถœ
graphNetwork.on('stabilizationEnd', function() {
try {
graphNetwork.fit({
animation: {
duration: 500,
easingFunction: 'easeInOutQuad'
}
});
} catch (error) {
console.error('์ž๋™ fit() ์˜ค๋ฅ˜:', error);
}
});
// stabilization์ด ๋น„ํ™œ์„ฑํ™”๋œ ๊ฒฝ์šฐ๋ฅผ ๋Œ€๋น„ํ•ด ์งง์€ ์ง€์—ฐ ํ›„์—๋„ fit() ํ˜ธ์ถœ
setTimeout(() => {
if (graphNetwork && nodes.length() > 0) {
try {
graphNetwork.fit({
animation: {
duration: 500,
easingFunction: 'easeInOutQuad'
}
});
} catch (error) {
console.error('์ง€์—ฐ fit() ์˜ค๋ฅ˜:', error);
}
}
}, 500);
}
// ํšŒ์ฐจ ํ•„ํ„ฐ ํ† ๊ธ€ ํ•จ์ˆ˜
function toggleEpisodeFilter() {
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';
document.getElementById('episodeFilterToggleIcon').style.transform = 'rotate(0deg)';
}
});
// ์„ ํƒ๋œ ํšŒ์ฐจ ์ˆ˜ ์—…๋ฐ์ดํŠธ
function updateEpisodeFilterButtonText() {
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 handleEpisodeFilterAllChange() {
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;
});
}
updateEpisodeFilterButtonText();
updateGraphVisualization();
}
// ๊ฐœ๋ณ„ ํšŒ์ฐจ ์ฒดํฌ๋ฐ•์Šค ๋ณ€๊ฒฝ ํ•ธ๋“ค๋Ÿฌ (์ „์ฒด ํšŒ์ฐจ ์ž๋™ ํ•ด์ œ)
function handleIndividualEpisodeChange() {
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;
}
updateEpisodeFilterButtonText();
updateGraphVisualization();
}
function updateGraphVisualization() {
if (!allGraphData) return;
// ์„ ํƒ๋œ ํšŒ์ฐจ๋“ค ๊ฐ€์ ธ์˜ค๊ธฐ
const episodeFilterAll = document.getElementById('episodeFilterAll');
let selectedEpisodes = [];
if (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 (graphNetwork) {
graphNetwork.destroy();
graphNetwork = null;
}
// ์ƒˆ ๊ทธ๋ž˜ํ”„ ์ƒ์„ฑ
createGraphVisualization(allGraphData, selectedEpisodes);
}
function resetGraphView() {
if (!graphNetwork) {
console.warn('๊ทธ๋ž˜ํ”„ ๋„คํŠธ์›Œํฌ๊ฐ€ ์•„์ง ์ƒ์„ฑ๋˜์ง€ ์•Š์•˜์Šต๋‹ˆ๋‹ค.');
return;
}
try {
// ๊ทธ๋ž˜ํ”„๋ฅผ ์ดˆ๊ธฐ ๋ทฐ๋กœ ๋ฆฌ์…‹ (๋ชจ๋“  ๋…ธ๋“œ๊ฐ€ ๋ณด์ด๋„๋ก)
// vis-network์˜ fit() ๋ฉ”์„œ๋“œ ํ˜ธ์ถœ
if (typeof graphNetwork.fit === 'function') {
// ์˜ต์…˜ ๊ฐ์ฒด๋ฅผ ์‚ฌ์šฉํ•œ fit() ํ˜ธ์ถœ
graphNetwork.fit({
animation: {
duration: 1000,
easingFunction: 'easeInOutQuad'
}
});
} else {
// fit() ๋ฉ”์„œ๋“œ๊ฐ€ ์—†๋Š” ๊ฒฝ์šฐ (์ด๋ก ์ ์œผ๋กœ๋Š” ๋ฐœ์ƒํ•˜์ง€ ์•Š์•„์•ผ ํ•จ)
console.warn('fit() ๋ฉ”์„œ๋“œ๋ฅผ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.');
}
} catch (error) {
console.error('๋ทฐ ๋ฆฌ์…‹ ์˜ค๋ฅ˜:', error);
// ์—๋Ÿฌ ๋ฐœ์ƒ ์‹œ ์˜ต์…˜ ์—†์ด fit() ํ˜ธ์ถœ๋กœ ์žฌ์‹œ๋„
try {
if (typeof graphNetwork.fit === 'function') {
graphNetwork.fit();
}
} catch (e) {
console.error('๋ทฐ ๋ฆฌ์…‹ ์‹คํŒจ:', e);
}
}
}
function closeGraphRAGVisualizationModal() {
document.getElementById('graphRAGVisualizationModal').classList.remove('active');
if (graphNetwork) {
graphNetwork.destroy();
graphNetwork = null;
}
graphData = null;
allGraphData = null;
}
// ๋ชจ๋‹ฌ ์™ธ๋ถ€ ํด๋ฆญ ์‹œ ๋‹ซ๊ธฐ
document.getElementById('chunksModal').addEventListener('click', function(e) {
if (e.target === this) {
closeChunksModal();
}
});
document.getElementById('summaryModal').addEventListener('click', function(e) {
if (e.target === this) {
closeSummaryModal();
}
});
document.getElementById('chunkContentModal').addEventListener('click', function(e) {
if (e.target === this) {
closeChunkContentModal();
}
});
document.getElementById('graphRAGModal').addEventListener('click', function(e) {
if (e.target === this) {
closeGraphRAGModal();
}
});
document.getElementById('graphRAGVisualizationModal').addEventListener('click', function(e) {
if (e.target === this) {
closeGraphRAGVisualizationModal();
}
});
// ํŽ˜์ด์ง€ ๋กœ๋“œ ์‹œ ์ดˆ๊ธฐํ™”
window.addEventListener('load', () => {
loadModelFilter();
loadFiles();
});
</script>
</body>
</html>