|
|
<!DOCTYPE html> |
|
|
<html lang="ko"> |
|
|
<head> |
|
|
<meta charset="UTF-8"> |
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0"> |
|
|
<title>ํ์ผ ๋ชฉ๋ก - SOY NV AI</title> |
|
|
<link rel="preconnect" href="https://fonts.googleapis.com" crossorigin> |
|
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&display=swap" rel="stylesheet"> |
|
|
<style> |
|
|
* { |
|
|
margin: 0; |
|
|
padding: 0; |
|
|
box-sizing: border-box; |
|
|
} |
|
|
|
|
|
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: 12px; |
|
|
align-items: center; |
|
|
} |
|
|
|
|
|
.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-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; |
|
|
} |
|
|
|
|
|
|
|
|
.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> |
|
|
<a href="{{ url_for('main.admin') }}" class="btn btn-secondary">์ฌ์ฉ์ ๊ด๋ฆฌ</a> |
|
|
<a href="{{ url_for('main.admin_webnovels') }}" class="btn btn-secondary">์น์์ค ๊ด๋ฆฌ</a> |
|
|
<a href="{{ url_for('main.admin_messages') }}" class="btn btn-secondary">๋ฉ์์ง ํ์ธ</a> |
|
|
<a href="{{ url_for('main.admin_prompts') }}" class="btn btn-secondary">ํ๋กฌํํธ ๊ด๋ฆฌ</a> |
|
|
<a href="{{ url_for('main.admin_settings') }}" class="btn btn-secondary">AI ์ค์ </a> |
|
|
<a href="{{ url_for('main.index') }}" class="btn btn-secondary">๋ฉ์ธ์ผ๋ก</a> |
|
|
<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="๋ฉ๋ด ๋ซ๊ธฐ">×</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.admin') }}" class="mobile-menu-item" onclick="closeMobileMenu()">์ฌ์ฉ์ ๊ด๋ฆฌ</a> |
|
|
<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> |
|
|
<a href="{{ url_for('main.admin_prompts') }}" class="mobile-menu-item" onclick="closeMobileMenu()">ํ๋กฌํํธ ๊ด๋ฆฌ</a> |
|
|
<a href="{{ url_for('main.admin_settings') }}" class="mobile-menu-item" onclick="closeMobileMenu()">AI ์ค์ </a> |
|
|
<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()">×</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()">×</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()">×</button> |
|
|
</div> |
|
|
<div id="chunkContent" class="chunk-content"> |
|
|
๋ด์ฉ์ ๋ถ๋ฌ์ค๋ ์ค... |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
|
|
|
|
|
|
<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()">×</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> |
|
|
|
|
|
|
|
|
<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()">×</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> |
|
|
|
|
|
|
|
|
<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 = ''; |
|
|
|
|
|
|
|
|
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>'; |
|
|
} |
|
|
|
|
|
|
|
|
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'); |
|
|
} |
|
|
|
|
|
|
|
|
function sortEpisodesByNumber(episodes) { |
|
|
return episodes.slice().sort((a, b) => { |
|
|
|
|
|
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; |
|
|
|
|
|
|
|
|
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'); |
|
|
} |
|
|
|
|
|
|
|
|
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(); |
|
|
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); |
|
|
} |
|
|
} |
|
|
}); |
|
|
|
|
|
|
|
|
graphNetwork.on('stabilizationEnd', function() { |
|
|
try { |
|
|
graphNetwork.fit({ |
|
|
animation: { |
|
|
duration: 500, |
|
|
easingFunction: 'easeInOutQuad' |
|
|
} |
|
|
}); |
|
|
} catch (error) { |
|
|
console.error('์๋ fit() ์ค๋ฅ:', error); |
|
|
} |
|
|
}); |
|
|
|
|
|
|
|
|
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 { |
|
|
|
|
|
|
|
|
if (typeof graphNetwork.fit === 'function') { |
|
|
|
|
|
graphNetwork.fit({ |
|
|
animation: { |
|
|
duration: 1000, |
|
|
easingFunction: 'easeInOutQuad' |
|
|
} |
|
|
}); |
|
|
} else { |
|
|
|
|
|
console.warn('fit() ๋ฉ์๋๋ฅผ ์ฐพ์ ์ ์์ต๋๋ค.'); |
|
|
} |
|
|
} catch (error) { |
|
|
console.error('๋ทฐ ๋ฆฌ์
์ค๋ฅ:', error); |
|
|
|
|
|
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> |
|
|
|
|
|
|