feat: 완전한 웹 UI 구현 및 문서 처리 파이프라인 완성
✨ 새로운 기능: - FastAPI 기반 완전한 웹 UI 구현 - 반응형 디자인 (모바일/태블릿 지원) - 드래그앤드롭 파일 업로드 인터페이스 - 실시간 AI 챗봇 인터페이스 - 문서 관리 및 검색 시스템 - 진행률 표시 및 상태 알림 🎨 UI 구성: - 메인 대시보드: 서버 상태, 통계, 빠른 접근 - 파일 업로드: 드래그앤드롭, 처리 옵션, 진행률 - 문서 관리: 검색, 정렬, 미리보기, 다운로드 - AI 챗봇: 실시간 대화, 문서 기반 RAG, 빠른 질문 🔧 기술 스택: - FastAPI + Jinja2 템플릿 - 모던 CSS (그라디언트, 애니메이션) - Font Awesome 아이콘 - JavaScript (ES6+) 🚀 완성된 기능: - 파일 업로드 → 텍스트 추출 → 임베딩 → 번역 → HTML 생성 - 벡터 검색 및 RAG 기반 질의응답 - 다중 모델 지원 (기본/부스팅/영어 전용) - API 키 인증 및 CORS 설정 - NAS 연동 및 파일 내보내기
This commit is contained in:
39
templates/base.html
Normal file
39
templates/base.html
Normal file
@@ -0,0 +1,39 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{% block title %}AI 문서 처리 서버{% endblock %}</title>
|
||||
<link href="/static/css/style.css" rel="stylesheet">
|
||||
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css" rel="stylesheet">
|
||||
</head>
|
||||
<body>
|
||||
<nav class="navbar">
|
||||
<div class="nav-container">
|
||||
<a href="/" class="nav-logo">
|
||||
<i class="fas fa-robot"></i> AI 문서 서버
|
||||
</a>
|
||||
<ul class="nav-menu">
|
||||
<li><a href="/" class="nav-link">대시보드</a></li>
|
||||
<li><a href="/upload" class="nav-link">업로드</a></li>
|
||||
<li><a href="/documents" class="nav-link">문서관리</a></li>
|
||||
<li><a href="/chat" class="nav-link">AI 챗봇</a></li>
|
||||
<li><a href="/docs" class="nav-link" target="_blank">API 문서</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<main class="main-content">
|
||||
{% block content %}{% endblock %}
|
||||
</main>
|
||||
|
||||
<footer class="footer">
|
||||
<div class="footer-content">
|
||||
<p>© 2025 AI 문서 처리 서버 | Mac Mini M4 Pro 64GB</p>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<script src="/static/js/main.js"></script>
|
||||
{% block scripts %}{% endblock %}
|
||||
</body>
|
||||
</html>
|
||||
631
templates/chat.html
Normal file
631
templates/chat.html
Normal file
@@ -0,0 +1,631 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}AI 챗봇 - AI 문서 처리 서버{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="grid grid-2">
|
||||
<!-- 채팅 영역 -->
|
||||
<div class="card chat-card">
|
||||
<div class="card-header">
|
||||
<h2 class="card-title">
|
||||
<i class="fas fa-robot"></i> AI 문서 챗봇
|
||||
</h2>
|
||||
<div class="chat-status">
|
||||
<span class="status status-success">
|
||||
<i class="fas fa-circle"></i> 온라인
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="chat-container">
|
||||
<div class="chat-messages" id="chatMessages">
|
||||
<div class="message assistant">
|
||||
<div class="message-content">
|
||||
<i class="fas fa-robot message-icon"></i>
|
||||
<div class="message-text">
|
||||
안녕하세요! 저는 문서 기반 AI 어시스턴트입니다.
|
||||
업로드된 문서들에 대해 질문하시면 답변해드리겠습니다.
|
||||
</div>
|
||||
</div>
|
||||
<div class="message-time">{{ current_time }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="chat-input-area">
|
||||
<div class="input-group">
|
||||
<input type="text" class="form-control chat-input" id="messageInput"
|
||||
placeholder="메시지를 입력하세요..." maxlength="1000">
|
||||
<button type="button" class="btn btn-primary" id="sendBtn" onclick="sendMessage()">
|
||||
<i class="fas fa-paper-plane"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div class="input-options">
|
||||
<label class="checkbox-label">
|
||||
<input type="checkbox" id="useRAG" checked>
|
||||
<i class="fas fa-search"></i> 문서 검색 사용
|
||||
</label>
|
||||
<label class="checkbox-label">
|
||||
<input type="checkbox" id="forceBoost">
|
||||
<i class="fas fa-rocket"></i> 고성능 모델 사용
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 설정 및 정보 패널 -->
|
||||
<div class="settings-panel">
|
||||
<!-- 빠른 질문 -->
|
||||
<div class="card">
|
||||
<h3><i class="fas fa-lightning-bolt"></i> 빠른 질문</h3>
|
||||
<div class="quick-questions">
|
||||
<button onclick="askQuickQuestion('전체 문서를 요약해주세요')" class="btn btn-sm btn-outline">
|
||||
문서 요약
|
||||
</button>
|
||||
<button onclick="askQuickQuestion('주요 키워드를 추출해주세요')" class="btn btn-sm btn-outline">
|
||||
키워드 추출
|
||||
</button>
|
||||
<button onclick="askQuickQuestion('이 문서의 핵심 내용은 무엇인가요?')" class="btn btn-sm btn-outline">
|
||||
핵심 내용
|
||||
</button>
|
||||
<button onclick="askQuickQuestion('관련된 다른 문서가 있나요?')" class="btn btn-sm btn-outline">
|
||||
관련 문서
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 검색 결과 -->
|
||||
<div class="card">
|
||||
<h3><i class="fas fa-search"></i> 관련 문서 검색</h3>
|
||||
<input type="text" class="form-control mb-2" id="searchInput"
|
||||
placeholder="문서 내용 검색..." onkeyup="searchDocuments(event)">
|
||||
<div class="search-results" id="searchResults">
|
||||
<p class="text-muted">검색어를 입력하세요</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 모델 정보 -->
|
||||
<div class="card">
|
||||
<h3><i class="fas fa-cog"></i> 모델 설정</h3>
|
||||
<div class="model-info">
|
||||
<div class="info-item">
|
||||
<strong>기본 모델:</strong> {{ status.base_model }}
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<strong>부스트 모델:</strong> {{ status.boost_model }}
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<strong>임베딩 모델:</strong> {{ status.embedding_model }}
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<strong>인덱스 문서:</strong> {{ status.index_loaded }}개
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 채팅 기록 관리 -->
|
||||
<div class="card">
|
||||
<h3><i class="fas fa-history"></i> 대화 관리</h3>
|
||||
<div class="chat-controls">
|
||||
<button onclick="clearChat()" class="btn btn-sm btn-secondary w-100 mb-2">
|
||||
<i class="fas fa-trash"></i> 대화 기록 삭제
|
||||
</button>
|
||||
<button onclick="exportChat()" class="btn btn-sm btn-outline w-100">
|
||||
<i class="fas fa-download"></i> 대화 내용 내보내기
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.chat-card {
|
||||
height: 600px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.chat-card .card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.chat-status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.chat-container {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.chat-messages {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 1rem;
|
||||
background-color: #f8f9fa;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.message {
|
||||
margin-bottom: 1rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.message.user {
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.message.assistant {
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.message-content {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.5rem;
|
||||
max-width: 80%;
|
||||
}
|
||||
|
||||
.message.user .message-content {
|
||||
flex-direction: row-reverse;
|
||||
}
|
||||
|
||||
.message-icon {
|
||||
font-size: 1.2rem;
|
||||
padding: 0.5rem;
|
||||
border-radius: 50%;
|
||||
min-width: 40px;
|
||||
min-height: 40px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.message.user .message-icon {
|
||||
background-color: #667eea;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.message.assistant .message-icon {
|
||||
background-color: #28a745;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.message-text {
|
||||
background: white;
|
||||
padding: 0.75rem 1rem;
|
||||
border-radius: 10px;
|
||||
border: 1px solid #e9ecef;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.message.user .message-text {
|
||||
background: #667eea;
|
||||
color: white;
|
||||
border-color: #667eea;
|
||||
}
|
||||
|
||||
.message-time {
|
||||
font-size: 0.75rem;
|
||||
color: #6c757d;
|
||||
margin-top: 0.25rem;
|
||||
margin-left: 3rem;
|
||||
}
|
||||
|
||||
.message.user .message-time {
|
||||
margin-left: 0;
|
||||
margin-right: 3rem;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.chat-input-area {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.input-group {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.input-group .form-control {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.input-options {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
margin-top: 0.5rem;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.checkbox-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
cursor: pointer;
|
||||
color: #6c757d;
|
||||
}
|
||||
|
||||
.settings-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.quick-questions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.btn-outline {
|
||||
background: transparent;
|
||||
border: 1px solid #667eea;
|
||||
color: #667eea;
|
||||
text-align: left;
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
.btn-outline:hover {
|
||||
background: #667eea;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.search-results {
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.search-result-item {
|
||||
padding: 0.5rem;
|
||||
border: 1px solid #e9ecef;
|
||||
border-radius: 5px;
|
||||
margin-bottom: 0.5rem;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.search-result-item:hover {
|
||||
background-color: #f8f9fa;
|
||||
}
|
||||
|
||||
.search-result-title {
|
||||
font-weight: 500;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.search-result-snippet {
|
||||
font-size: 0.85rem;
|
||||
color: #6c757d;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.info-item {
|
||||
margin-bottom: 0.5rem;
|
||||
padding-bottom: 0.5rem;
|
||||
border-bottom: 1px solid #f8f9fa;
|
||||
}
|
||||
|
||||
.info-item:last-child {
|
||||
border-bottom: none;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.chat-controls .btn {
|
||||
justify-content: center;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.w-100 { width: 100%; }
|
||||
.mb-2 { margin-bottom: 0.5rem; }
|
||||
.text-muted { color: #6c757d; }
|
||||
|
||||
/* 로딩 애니메이션 */
|
||||
.typing-indicator {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
padding: 0.75rem 1rem;
|
||||
background: white;
|
||||
border-radius: 10px;
|
||||
border: 1px solid #e9ecef;
|
||||
}
|
||||
|
||||
.typing-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
background-color: #6c757d;
|
||||
border-radius: 50%;
|
||||
animation: typing 1.5s infinite;
|
||||
}
|
||||
|
||||
.typing-dot:nth-child(2) { animation-delay: 0.2s; }
|
||||
.typing-dot:nth-child(3) { animation-delay: 0.4s; }
|
||||
|
||||
@keyframes typing {
|
||||
0%, 60%, 100% { opacity: 0.3; }
|
||||
30% { opacity: 1; }
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
let chatHistory = [];
|
||||
let isWaitingForResponse = false;
|
||||
|
||||
// DOM 요소들
|
||||
const chatMessages = document.getElementById('chatMessages');
|
||||
const messageInput = document.getElementById('messageInput');
|
||||
const sendBtn = document.getElementById('sendBtn');
|
||||
const searchInput = document.getElementById('searchInput');
|
||||
const searchResults = document.getElementById('searchResults');
|
||||
const useRAGCheckbox = document.getElementById('useRAG');
|
||||
const forceBoostCheckbox = document.getElementById('forceBoost');
|
||||
|
||||
// 페이지 로드 시 초기화
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
messageInput.addEventListener('keypress', function(e) {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
sendMessage();
|
||||
}
|
||||
});
|
||||
|
||||
// URL 파라미터 확인 (특정 문서로 대화하기)
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const doc = urlParams.get('doc');
|
||||
if (doc) {
|
||||
addMessage('assistant', `"${doc}" 문서에 대해 질문해주세요. 어떤 내용이 궁금하신가요?`);
|
||||
}
|
||||
});
|
||||
|
||||
// 메시지 전송
|
||||
async function sendMessage() {
|
||||
const message = messageInput.value.trim();
|
||||
if (!message || isWaitingForResponse) return;
|
||||
|
||||
// 사용자 메시지 추가
|
||||
addMessage('user', message);
|
||||
messageInput.value = '';
|
||||
isWaitingForResponse = true;
|
||||
sendBtn.disabled = true;
|
||||
|
||||
// 타이핑 인디케이터 표시
|
||||
const typingId = addTypingIndicator();
|
||||
|
||||
try {
|
||||
const response = await fetch('/chat', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-API-Key': '{{ api_key }}'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
messages: chatHistory,
|
||||
use_rag: useRAGCheckbox.checked,
|
||||
force_boost: forceBoostCheckbox.checked,
|
||||
top_k: 5
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
// 타이핑 인디케이터 제거
|
||||
removeTypingIndicator(typingId);
|
||||
|
||||
// AI 응답 추가
|
||||
addMessage('assistant', data.response.message?.content || data.response.response || '응답을 받지 못했습니다.');
|
||||
|
||||
} catch (error) {
|
||||
console.error('Chat error:', error);
|
||||
removeTypingIndicator(typingId);
|
||||
addMessage('assistant', '죄송합니다. 응답 중 오류가 발생했습니다. 다시 시도해주세요.');
|
||||
} finally {
|
||||
isWaitingForResponse = false;
|
||||
sendBtn.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
// 메시지 추가
|
||||
function addMessage(sender, content) {
|
||||
const messageDiv = document.createElement('div');
|
||||
messageDiv.className = `message ${sender}`;
|
||||
|
||||
const now = new Date().toLocaleTimeString('ko-KR', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
});
|
||||
|
||||
const icon = sender === 'user' ? 'fas fa-user' : 'fas fa-robot';
|
||||
|
||||
messageDiv.innerHTML = `
|
||||
<div class="message-content">
|
||||
<i class="${icon} message-icon"></i>
|
||||
<div class="message-text">${content}</div>
|
||||
</div>
|
||||
<div class="message-time">${now}</div>
|
||||
`;
|
||||
|
||||
chatMessages.appendChild(messageDiv);
|
||||
chatMessages.scrollTop = chatMessages.scrollHeight;
|
||||
|
||||
// 채팅 히스토리에 추가
|
||||
chatHistory.push({
|
||||
role: sender === 'user' ? 'user' : 'assistant',
|
||||
content: content
|
||||
});
|
||||
}
|
||||
|
||||
// 타이핑 인디케이터 추가
|
||||
function addTypingIndicator() {
|
||||
const typingDiv = document.createElement('div');
|
||||
typingDiv.className = 'message assistant';
|
||||
typingDiv.id = 'typing-' + Date.now();
|
||||
|
||||
typingDiv.innerHTML = `
|
||||
<div class="message-content">
|
||||
<i class="fas fa-robot message-icon"></i>
|
||||
<div class="typing-indicator">
|
||||
<div class="typing-dot"></div>
|
||||
<div class="typing-dot"></div>
|
||||
<div class="typing-dot"></div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
chatMessages.appendChild(typingDiv);
|
||||
chatMessages.scrollTop = chatMessages.scrollHeight;
|
||||
|
||||
return typingDiv.id;
|
||||
}
|
||||
|
||||
// 타이핑 인디케이터 제거
|
||||
function removeTypingIndicator(typingId) {
|
||||
const typingDiv = document.getElementById(typingId);
|
||||
if (typingDiv) {
|
||||
typingDiv.remove();
|
||||
}
|
||||
}
|
||||
|
||||
// 빠른 질문
|
||||
function askQuickQuestion(question) {
|
||||
messageInput.value = question;
|
||||
sendMessage();
|
||||
}
|
||||
|
||||
// 문서 검색
|
||||
async function searchDocuments(event) {
|
||||
if (event.key !== 'Enter') return;
|
||||
|
||||
const query = searchInput.value.trim();
|
||||
if (!query) {
|
||||
searchResults.innerHTML = '<p class="text-muted">검색어를 입력하세요</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch('/search', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
query: query,
|
||||
top_k: 5
|
||||
})
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
displaySearchResults(data.results);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Search error:', error);
|
||||
searchResults.innerHTML = '<p class="text-muted">검색 중 오류가 발생했습니다</p>';
|
||||
}
|
||||
}
|
||||
|
||||
// 검색 결과 표시
|
||||
function displaySearchResults(results) {
|
||||
if (!results || results.length === 0) {
|
||||
searchResults.innerHTML = '<p class="text-muted">검색 결과가 없습니다</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
const resultsHtml = results.map(result => `
|
||||
<div class="search-result-item" onclick="useSearchResult('${result.text.replace(/'/g, "\\'")}')">
|
||||
<div class="search-result-title">문서 ID: ${result.id}</div>
|
||||
<div class="search-result-snippet">${result.text.substring(0, 100)}...</div>
|
||||
<small class="text-muted">유사도: ${(result.score * 100).toFixed(1)}%</small>
|
||||
</div>
|
||||
`).join('');
|
||||
|
||||
searchResults.innerHTML = resultsHtml;
|
||||
}
|
||||
|
||||
// 검색 결과 활용
|
||||
function useSearchResult(text) {
|
||||
messageInput.value = `다음 내용에 대해 설명해주세요: "${text.substring(0, 50)}..."`;
|
||||
}
|
||||
|
||||
// 대화 기록 삭제
|
||||
function clearChat() {
|
||||
if (!confirm('정말로 대화 기록을 모두 삭제하시겠습니까?')) return;
|
||||
|
||||
chatHistory = [];
|
||||
chatMessages.innerHTML = `
|
||||
<div class="message assistant">
|
||||
<div class="message-content">
|
||||
<i class="fas fa-robot message-icon"></i>
|
||||
<div class="message-text">
|
||||
안녕하세요! 저는 문서 기반 AI 어시스턴트입니다.
|
||||
업로드된 문서들에 대해 질문하시면 답변해드리겠습니다.
|
||||
</div>
|
||||
</div>
|
||||
<div class="message-time">${new Date().toLocaleTimeString('ko-KR', { hour: '2-digit', minute: '2-digit' })}</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// 대화 내용 내보내기
|
||||
function exportChat() {
|
||||
if (chatHistory.length === 0) {
|
||||
showToast('내보낼 대화 내용이 없습니다.', 'info');
|
||||
return;
|
||||
}
|
||||
|
||||
const chatText = chatHistory.map(msg =>
|
||||
`${msg.role === 'user' ? '사용자' : 'AI'}: ${msg.content}`
|
||||
).join('\n\n');
|
||||
|
||||
const blob = new Blob([chatText], { type: 'text/plain' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `chat-export-${new Date().toISOString().slice(0, 10)}.txt`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
// 토스트 알림
|
||||
function showToast(message, type = 'info') {
|
||||
const toast = document.createElement('div');
|
||||
toast.className = `alert alert-${type}`;
|
||||
toast.textContent = message;
|
||||
toast.style.cssText = `
|
||||
position: fixed;
|
||||
top: 20px;
|
||||
right: 20px;
|
||||
z-index: 1001;
|
||||
min-width: 300px;
|
||||
animation: slideIn 0.3s ease;
|
||||
`;
|
||||
|
||||
document.body.appendChild(toast);
|
||||
|
||||
setTimeout(() => {
|
||||
toast.style.animation = 'slideOut 0.3s ease';
|
||||
setTimeout(() => {
|
||||
if (document.body.contains(toast)) {
|
||||
document.body.removeChild(toast);
|
||||
}
|
||||
}, 300);
|
||||
}, 3000);
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
348
templates/documents.html
Normal file
348
templates/documents.html
Normal file
@@ -0,0 +1,348 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}문서 관리 - AI 문서 처리 서버{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h1 class="card-title">
|
||||
<i class="fas fa-folder-open"></i> 문서 관리
|
||||
</h1>
|
||||
<p class="card-subtitle">처리된 문서들을 확인하고 관리하세요</p>
|
||||
</div>
|
||||
|
||||
<!-- 통계 요약 -->
|
||||
<div class="grid grid-3 mb-4">
|
||||
<div class="card">
|
||||
<h3><i class="fas fa-file-alt"></i> 총 문서 수</h3>
|
||||
<div class="stat-number">{{ documents|length }}</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<h3><i class="fas fa-database"></i> 총 용량</h3>
|
||||
<div class="stat-number" id="totalSize">계산 중...</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<h3><i class="fas fa-calendar"></i> 최근 업로드</h3>
|
||||
<div class="stat-number">
|
||||
{% if documents %}
|
||||
{{ documents[0].created.split(' ')[0] }}
|
||||
{% else %}
|
||||
없음
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 검색 및 필터 -->
|
||||
<div class="search-controls mb-4">
|
||||
<div class="grid grid-2">
|
||||
<div>
|
||||
<input type="text" class="form-control" id="searchInput"
|
||||
placeholder="파일명으로 검색..." onkeyup="filterDocuments()">
|
||||
</div>
|
||||
<div>
|
||||
<select class="form-control" id="sortSelect" onchange="sortDocuments()">
|
||||
<option value="name">이름순</option>
|
||||
<option value="date" selected>날짜순 (최신)</option>
|
||||
<option value="size">크기순</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 문서 목록 -->
|
||||
{% if documents %}
|
||||
<div class="table-responsive">
|
||||
<table class="table" id="documentsTable">
|
||||
<thead>
|
||||
<tr>
|
||||
<th><i class="fas fa-file"></i> 파일명</th>
|
||||
<th><i class="fas fa-calendar"></i> 생성일시</th>
|
||||
<th><i class="fas fa-weight"></i> 크기</th>
|
||||
<th><i class="fas fa-cogs"></i> 액션</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for doc in documents %}
|
||||
<tr class="document-row" data-name="{{ doc.name|lower }}" data-date="{{ doc.created }}" data-size="{{ doc.size }}">
|
||||
<td>
|
||||
<div class="file-info">
|
||||
<i class="fas fa-file-alt text-primary"></i>
|
||||
<strong>{{ doc.name }}</strong>
|
||||
<br>
|
||||
<small class="text-muted">{{ formatFileSize(doc.size) }}</small>
|
||||
</div>
|
||||
</td>
|
||||
<td>{{ doc.created }}</td>
|
||||
<td>
|
||||
<span class="file-size" data-bytes="{{ doc.size }}">{{ formatFileSize(doc.size) }}</span>
|
||||
</td>
|
||||
<td>
|
||||
<div class="action-buttons">
|
||||
<a href="/html/{{ doc.name }}" class="btn btn-sm btn-primary" target="_blank" title="HTML 보기">
|
||||
<i class="fas fa-eye"></i>
|
||||
</a>
|
||||
<button onclick="openChatWithDoc('{{ doc.name }}')" class="btn btn-sm btn-success" title="이 문서로 대화하기">
|
||||
<i class="fas fa-comments"></i>
|
||||
</button>
|
||||
<button onclick="downloadDoc('{{ doc.name }}')" class="btn btn-sm btn-secondary" title="다운로드">
|
||||
<i class="fas fa-download"></i>
|
||||
</button>
|
||||
<button onclick="deleteDoc('{{ doc.name }}')" class="btn btn-sm btn-danger" title="삭제">
|
||||
<i class="fas fa-trash"></i>
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="empty-state">
|
||||
<i class="fas fa-inbox fa-4x text-muted mb-3"></i>
|
||||
<h3>처리된 문서가 없습니다</h3>
|
||||
<p class="text-muted mb-4">파일을 업로드하여 AI 처리를 시작하세요.</p>
|
||||
<a href="/upload" class="btn btn-primary btn-lg">
|
||||
<i class="fas fa-cloud-upload-alt"></i> 첫 문서 업로드하기
|
||||
</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- 파일 미리보기 모달 -->
|
||||
<div class="modal" id="previewModal" style="display: none;">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h3 id="previewTitle">문서 미리보기</h3>
|
||||
<button onclick="closePreview()" class="btn btn-sm btn-secondary">
|
||||
<i class="fas fa-times"></i> 닫기
|
||||
</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<iframe id="previewFrame" style="width: 100%; height: 500px; border: none;"></iframe>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.search-controls {
|
||||
background-color: #f8f9fa;
|
||||
padding: 1.5rem;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #e9ecef;
|
||||
}
|
||||
|
||||
.file-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.file-info i {
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
|
||||
.action-buttons {
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.btn-sm {
|
||||
padding: 0.25rem 0.5rem;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 4rem 2rem;
|
||||
}
|
||||
|
||||
.modal {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-color: rgba(0, 0, 0, 0.5);
|
||||
z-index: 1000;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
background: white;
|
||||
border-radius: 10px;
|
||||
width: 90%;
|
||||
max-width: 900px;
|
||||
max-height: 90%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
padding: 1rem 1.5rem;
|
||||
border-bottom: 1px solid #e9ecef;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.modal-body {
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.stat-number {
|
||||
font-size: 2rem;
|
||||
font-weight: bold;
|
||||
color: #667eea;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.mb-4 { margin-bottom: 2rem; }
|
||||
.text-primary { color: #667eea; }
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
let allDocuments = [];
|
||||
|
||||
// 페이지 로드 시 초기화
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// 문서 데이터 수집
|
||||
const rows = document.querySelectorAll('.document-row');
|
||||
allDocuments = Array.from(rows).map(row => ({
|
||||
element: row,
|
||||
name: row.dataset.name,
|
||||
date: new Date(row.dataset.date),
|
||||
size: parseInt(row.dataset.size)
|
||||
}));
|
||||
|
||||
// 총 용량 계산
|
||||
const totalBytes = allDocuments.reduce((sum, doc) => sum + doc.size, 0);
|
||||
document.getElementById('totalSize').textContent = formatFileSize(totalBytes);
|
||||
|
||||
// 기본 정렬 (날짜순)
|
||||
sortDocuments();
|
||||
});
|
||||
|
||||
// 파일 크기 포맷팅
|
||||
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 parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
||||
}
|
||||
|
||||
// 문서 검색 필터링
|
||||
function filterDocuments() {
|
||||
const searchTerm = document.getElementById('searchInput').value.toLowerCase();
|
||||
const rows = document.querySelectorAll('.document-row');
|
||||
|
||||
rows.forEach(row => {
|
||||
const fileName = row.dataset.name;
|
||||
if (fileName.includes(searchTerm)) {
|
||||
row.style.display = '';
|
||||
} else {
|
||||
row.style.display = 'none';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 문서 정렬
|
||||
function sortDocuments() {
|
||||
const sortBy = document.getElementById('sortSelect').value;
|
||||
const tbody = document.querySelector('#documentsTable tbody');
|
||||
|
||||
allDocuments.sort((a, b) => {
|
||||
switch (sortBy) {
|
||||
case 'name':
|
||||
return a.name.localeCompare(b.name);
|
||||
case 'date':
|
||||
return b.date - a.date; // 최신순
|
||||
case 'size':
|
||||
return b.size - a.size; // 큰 것부터
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
});
|
||||
|
||||
// DOM 재정렬
|
||||
allDocuments.forEach(doc => {
|
||||
tbody.appendChild(doc.element);
|
||||
});
|
||||
}
|
||||
|
||||
// 문서로 채팅 시작
|
||||
function openChatWithDoc(fileName) {
|
||||
const docName = fileName.replace('.html', '');
|
||||
window.location.href = `/chat?doc=${encodeURIComponent(docName)}`;
|
||||
}
|
||||
|
||||
// 문서 다운로드
|
||||
function downloadDoc(fileName) {
|
||||
const link = document.createElement('a');
|
||||
link.href = `/html/${fileName}`;
|
||||
link.download = fileName;
|
||||
link.click();
|
||||
}
|
||||
|
||||
// 문서 삭제
|
||||
async function deleteDoc(fileName) {
|
||||
if (!confirm(`정말로 "${fileName}" 문서를 삭제하시겠습니까?`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// 실제로는 DELETE API를 구현해야 함
|
||||
showToast('삭제 기능은 아직 구현되지 않았습니다.', 'info');
|
||||
// TODO: DELETE /api/documents/{fileName} 구현
|
||||
} catch (error) {
|
||||
console.error('Delete error:', error);
|
||||
showToast('삭제 중 오류가 발생했습니다.', 'danger');
|
||||
}
|
||||
}
|
||||
|
||||
// 미리보기 열기
|
||||
function openPreview(fileName) {
|
||||
document.getElementById('previewTitle').textContent = `${fileName} 미리보기`;
|
||||
document.getElementById('previewFrame').src = `/html/${fileName}`;
|
||||
document.getElementById('previewModal').style.display = 'flex';
|
||||
}
|
||||
|
||||
// 미리보기 닫기
|
||||
function closePreview() {
|
||||
document.getElementById('previewModal').style.display = 'none';
|
||||
document.getElementById('previewFrame').src = '';
|
||||
}
|
||||
|
||||
// 토스트 알림 (main.js에서 가져옴)
|
||||
function showToast(message, type = 'info') {
|
||||
const toast = document.createElement('div');
|
||||
toast.className = `alert alert-${type}`;
|
||||
toast.textContent = message;
|
||||
toast.style.cssText = `
|
||||
position: fixed;
|
||||
top: 20px;
|
||||
right: 20px;
|
||||
z-index: 1001;
|
||||
min-width: 300px;
|
||||
animation: slideIn 0.3s ease;
|
||||
`;
|
||||
|
||||
document.body.appendChild(toast);
|
||||
|
||||
setTimeout(() => {
|
||||
toast.style.animation = 'slideOut 0.3s ease';
|
||||
setTimeout(() => {
|
||||
if (document.body.contains(toast)) {
|
||||
document.body.removeChild(toast);
|
||||
}
|
||||
}, 300);
|
||||
}, 3000);
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
229
templates/index.html
Normal file
229
templates/index.html
Normal file
@@ -0,0 +1,229 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}AI 문서 처리 서버 - 대시보드{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h1 class="card-title">
|
||||
<i class="fas fa-tachometer-alt"></i> 대시보드
|
||||
</h1>
|
||||
<p class="card-subtitle">AI 문서 처리 서버 현황을 확인하세요</p>
|
||||
</div>
|
||||
|
||||
<!-- 시스템 상태 -->
|
||||
<div class="grid grid-3">
|
||||
<div class="card">
|
||||
<h3><i class="fas fa-server"></i> 서버 상태</h3>
|
||||
<div class="status status-success">
|
||||
<i class="fas fa-check-circle"></i> 정상 운영
|
||||
</div>
|
||||
<p class="mt-2">
|
||||
<strong>모델:</strong> {{ status.base_model }}<br>
|
||||
<strong>임베딩:</strong> {{ status.embedding_model }}<br>
|
||||
<strong>인덱스:</strong> {{ status.index_loaded }}개 문서
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h3><i class="fas fa-upload"></i> 빠른 업로드</h3>
|
||||
<div class="drop-zone-mini" onclick="window.location.href='/upload'">
|
||||
<i class="fas fa-cloud-upload-alt"></i>
|
||||
<p>파일을 업로드하려면 클릭하세요</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h3><i class="fas fa-search"></i> 빠른 검색</h3>
|
||||
<form onsubmit="quickSearch(event)">
|
||||
<input type="text" class="form-control" placeholder="문서 내용 검색..." id="quickSearchInput">
|
||||
<button type="submit" class="btn btn-primary mt-2 w-100">
|
||||
<i class="fas fa-search"></i> 검색
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 최근 문서 -->
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h2 class="card-title">
|
||||
<i class="fas fa-file-alt"></i> 최근 처리된 문서
|
||||
</h2>
|
||||
<a href="/documents" class="btn btn-secondary">모든 문서 보기</a>
|
||||
</div>
|
||||
|
||||
{% if recent_documents %}
|
||||
<div class="table-responsive">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>문서 ID</th>
|
||||
<th>처리 시간</th>
|
||||
<th>청크 수</th>
|
||||
<th>상태</th>
|
||||
<th>액션</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for doc in recent_documents %}
|
||||
<tr>
|
||||
<td>{{ doc.id }}</td>
|
||||
<td>{{ doc.created_at }}</td>
|
||||
<td>{{ doc.chunks }}</td>
|
||||
<td>
|
||||
<span class="status status-success">
|
||||
<i class="fas fa-check"></i> 완료
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<a href="/html/{{ doc.html_file }}" class="btn btn-sm btn-primary" target="_blank">
|
||||
<i class="fas fa-eye"></i> 보기
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="text-center py-4">
|
||||
<i class="fas fa-inbox fa-3x text-muted mb-3"></i>
|
||||
<p class="text-muted">아직 처리된 문서가 없습니다.</p>
|
||||
<a href="/upload" class="btn btn-primary">첫 문서 업로드하기</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- 통계 -->
|
||||
<div class="grid grid-2">
|
||||
<div class="card">
|
||||
<h3><i class="fas fa-chart-bar"></i> 처리 통계</h3>
|
||||
<div class="stats">
|
||||
<div class="stat-item">
|
||||
<span class="stat-number">{{ stats.total_documents }}</span>
|
||||
<span class="stat-label">총 문서 수</span>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<span class="stat-number">{{ stats.total_chunks }}</span>
|
||||
<span class="stat-label">총 청크 수</span>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<span class="stat-number">{{ stats.today_processed }}</span>
|
||||
<span class="stat-label">오늘 처리</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h3><i class="fas fa-robot"></i> AI 챗봇</h3>
|
||||
<p>문서 기반 질의응답을 시작하세요.</p>
|
||||
<div class="quick-chat">
|
||||
<input type="text" class="form-control mb-2" placeholder="질문을 입력하세요..." id="quickChatInput">
|
||||
<button onclick="quickChat()" class="btn btn-success w-100">
|
||||
<i class="fas fa-paper-plane"></i> 질문하기
|
||||
</button>
|
||||
</div>
|
||||
<a href="/chat" class="btn btn-secondary w-100 mt-2">전체 채팅 화면으로</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.drop-zone-mini {
|
||||
border: 2px dashed #ced4da;
|
||||
border-radius: 10px;
|
||||
padding: 2rem 1rem;
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s;
|
||||
background-color: #f8f9fa;
|
||||
}
|
||||
|
||||
.drop-zone-mini:hover {
|
||||
border-color: #667eea;
|
||||
background-color: rgba(102, 126, 234, 0.05);
|
||||
}
|
||||
|
||||
.drop-zone-mini i {
|
||||
font-size: 2rem;
|
||||
color: #6c757d;
|
||||
margin-bottom: 0.5rem;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(100px, 1fr));
|
||||
gap: 1rem;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.stat-item {
|
||||
text-align: center;
|
||||
padding: 1rem;
|
||||
background-color: #f8f9fa;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.stat-number {
|
||||
display: block;
|
||||
font-size: 2rem;
|
||||
font-weight: bold;
|
||||
color: #667eea;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 0.85rem;
|
||||
color: #6c757d;
|
||||
}
|
||||
|
||||
.mt-2 { margin-top: 0.5rem; }
|
||||
.mb-2 { margin-bottom: 0.5rem; }
|
||||
.mb-3 { margin-bottom: 1rem; }
|
||||
.py-4 { padding: 2rem 0; }
|
||||
.w-100 { width: 100%; }
|
||||
.text-center { text-align: center; }
|
||||
.text-muted { color: #6c757d; }
|
||||
.table-responsive { overflow-x: auto; }
|
||||
.btn-sm { padding: 0.25rem 0.5rem; font-size: 0.875rem; }
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
function quickSearch(event) {
|
||||
event.preventDefault();
|
||||
const query = document.getElementById('quickSearchInput').value;
|
||||
if (query.trim()) {
|
||||
window.location.href = `/search?q=${encodeURIComponent(query)}`;
|
||||
}
|
||||
}
|
||||
|
||||
function quickChat() {
|
||||
const question = document.getElementById('quickChatInput').value;
|
||||
if (question.trim()) {
|
||||
// 간단한 챗봇 미리보기 (실제 구현은 /chat 페이지에서)
|
||||
alert('채팅 기능은 "AI 챗봇" 페이지에서 이용하실 수 있습니다.');
|
||||
window.location.href = '/chat';
|
||||
}
|
||||
}
|
||||
|
||||
// 페이지 로드 시 서버 상태 업데이트
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// 실시간 상태 업데이트 (선택사항)
|
||||
updateStatus();
|
||||
});
|
||||
|
||||
async function updateStatus() {
|
||||
try {
|
||||
const response = await fetch('/health');
|
||||
const data = await response.json();
|
||||
// 상태 업데이트 로직
|
||||
console.log('서버 상태:', data);
|
||||
} catch (error) {
|
||||
console.error('상태 확인 실패:', error);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
395
templates/upload.html
Normal file
395
templates/upload.html
Normal file
@@ -0,0 +1,395 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}파일 업로드 - AI 문서 처리 서버{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h1 class="card-title">
|
||||
<i class="fas fa-cloud-upload-alt"></i> 파일 업로드
|
||||
</h1>
|
||||
<p class="card-subtitle">PDF 또는 TXT 파일을 업로드하여 AI 처리를 시작하세요</p>
|
||||
</div>
|
||||
|
||||
<form id="uploadForm" enctype="multipart/form-data">
|
||||
<div class="grid grid-2">
|
||||
<!-- 파일 선택 영역 -->
|
||||
<div>
|
||||
<h3><i class="fas fa-file"></i> 파일 선택</h3>
|
||||
|
||||
<div class="drop-zone" id="dropZone">
|
||||
<i class="fas fa-cloud-upload-alt"></i>
|
||||
<h4>파일을 드래그하거나 클릭하여 선택</h4>
|
||||
<p>PDF, TXT 파일 지원 (최대 200MB)</p>
|
||||
<input type="file" id="fileInput" name="file" accept=".pdf,.txt" style="display: none;">
|
||||
<button type="button" class="btn btn-primary mt-2" onclick="document.getElementById('fileInput').click()">
|
||||
<i class="fas fa-folder-open"></i> 파일 선택
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div id="fileInfo" class="file-info" style="display: none;">
|
||||
<h4><i class="fas fa-file-check"></i> 선택된 파일</h4>
|
||||
<div id="fileDetails"></div>
|
||||
<button type="button" class="btn btn-secondary btn-sm mt-2" onclick="clearFile()">
|
||||
<i class="fas fa-times"></i> 다른 파일 선택
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 옵션 설정 -->
|
||||
<div>
|
||||
<h3><i class="fas fa-cogs"></i> 처리 옵션</h3>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label" for="docId">문서 ID</label>
|
||||
<input type="text" class="form-control" id="docId" name="doc_id"
|
||||
placeholder="예: report-2025-001" required>
|
||||
<small class="text-muted">고유한 문서 식별자를 입력하세요</small>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<div class="checkbox-group">
|
||||
<label class="checkbox-label">
|
||||
<input type="checkbox" id="generateHtml" name="generate_html" checked>
|
||||
<i class="fas fa-code"></i> HTML 파일 생성
|
||||
</label>
|
||||
<small class="text-muted">읽기 쉬운 HTML 형태로 변환합니다</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<div class="checkbox-group">
|
||||
<label class="checkbox-label">
|
||||
<input type="checkbox" id="translate" name="translate">
|
||||
<i class="fas fa-language"></i> 한국어로 번역
|
||||
</label>
|
||||
<small class="text-muted">영어 문서를 한국어로 번역합니다</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group" id="targetLangGroup" style="display: none;">
|
||||
<label class="form-label" for="targetLanguage">번역 언어</label>
|
||||
<select class="form-control" id="targetLanguage" name="target_language">
|
||||
<option value="ko">한국어</option>
|
||||
<option value="en">영어</option>
|
||||
<option value="ja">일본어</option>
|
||||
<option value="zh">중국어</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<div class="checkbox-group">
|
||||
<label class="checkbox-label">
|
||||
<input type="checkbox" id="summarize" name="summarize">
|
||||
<i class="fas fa-compress-alt"></i> 요약 생성
|
||||
</label>
|
||||
<small class="text-muted">긴 문서를 요약하여 처리합니다</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group" id="summaryGroup" style="display: none;">
|
||||
<label class="form-label" for="summarySentences">요약 문장 수</label>
|
||||
<input type="number" class="form-control" id="summarySentences"
|
||||
name="summary_sentences" value="5" min="3" max="10">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="upload-actions">
|
||||
<button type="submit" class="btn btn-success btn-lg" id="uploadBtn" disabled>
|
||||
<i class="fas fa-rocket"></i> 업로드 및 처리 시작
|
||||
</button>
|
||||
<button type="button" class="btn btn-secondary" onclick="resetForm()">
|
||||
<i class="fas fa-redo"></i> 초기화
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<!-- 진행 상황 -->
|
||||
<div id="progressSection" style="display: none;">
|
||||
<h3><i class="fas fa-tasks"></i> 처리 진행 상황</h3>
|
||||
<div class="progress">
|
||||
<div class="progress-bar" id="progressBar" style="width: 0%"></div>
|
||||
</div>
|
||||
<div id="progressText">준비 중...</div>
|
||||
</div>
|
||||
|
||||
<!-- 결과 -->
|
||||
<div id="resultSection" style="display: none;">
|
||||
<h3><i class="fas fa-check-circle"></i> 처리 완료</h3>
|
||||
<div id="resultContent"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.checkbox-group {
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.checkbox-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
cursor: pointer;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.checkbox-label input[type="checkbox"] {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.file-info {
|
||||
margin-top: 1rem;
|
||||
padding: 1rem;
|
||||
background-color: #f8f9fa;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #e9ecef;
|
||||
}
|
||||
|
||||
.file-details {
|
||||
display: grid;
|
||||
gap: 0.5rem;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.upload-actions {
|
||||
margin-top: 2rem;
|
||||
padding-top: 2rem;
|
||||
border-top: 1px solid #e9ecef;
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
#progressSection {
|
||||
margin-top: 2rem;
|
||||
padding: 1.5rem;
|
||||
background-color: #f8f9fa;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #e9ecef;
|
||||
}
|
||||
|
||||
#resultSection {
|
||||
margin-top: 2rem;
|
||||
padding: 1.5rem;
|
||||
background-color: #d4edda;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #c3e6cb;
|
||||
}
|
||||
|
||||
.btn-lg {
|
||||
padding: 1rem 2rem;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
let selectedFile = null;
|
||||
|
||||
// DOM 요소들
|
||||
const dropZone = document.getElementById('dropZone');
|
||||
const fileInput = document.getElementById('fileInput');
|
||||
const fileInfo = document.getElementById('fileInfo');
|
||||
const fileDetails = document.getElementById('fileDetails');
|
||||
const uploadBtn = document.getElementById('uploadBtn');
|
||||
const uploadForm = document.getElementById('uploadForm');
|
||||
const progressSection = document.getElementById('progressSection');
|
||||
const progressBar = document.getElementById('progressBar');
|
||||
const progressText = document.getElementById('progressText');
|
||||
const resultSection = document.getElementById('resultSection');
|
||||
const resultContent = document.getElementById('resultContent');
|
||||
|
||||
// 체크박스 이벤트
|
||||
document.getElementById('translate').addEventListener('change', function() {
|
||||
const targetLangGroup = document.getElementById('targetLangGroup');
|
||||
targetLangGroup.style.display = this.checked ? 'block' : 'none';
|
||||
});
|
||||
|
||||
document.getElementById('summarize').addEventListener('change', function() {
|
||||
const summaryGroup = document.getElementById('summaryGroup');
|
||||
summaryGroup.style.display = this.checked ? 'block' : 'none';
|
||||
});
|
||||
|
||||
// 드래그 앤 드롭 이벤트
|
||||
dropZone.addEventListener('dragover', (e) => {
|
||||
e.preventDefault();
|
||||
dropZone.classList.add('dragover');
|
||||
});
|
||||
|
||||
dropZone.addEventListener('dragleave', () => {
|
||||
dropZone.classList.remove('dragover');
|
||||
});
|
||||
|
||||
dropZone.addEventListener('drop', (e) => {
|
||||
e.preventDefault();
|
||||
dropZone.classList.remove('dragover');
|
||||
|
||||
const files = e.dataTransfer.files;
|
||||
if (files.length > 0) {
|
||||
handleFileSelect(files[0]);
|
||||
}
|
||||
});
|
||||
|
||||
// 파일 선택 이벤트
|
||||
fileInput.addEventListener('change', (e) => {
|
||||
if (e.target.files.length > 0) {
|
||||
handleFileSelect(e.target.files[0]);
|
||||
}
|
||||
});
|
||||
|
||||
// 파일 처리 함수
|
||||
function handleFileSelect(file) {
|
||||
// 파일 타입 검증
|
||||
const allowedTypes = ['application/pdf', 'text/plain'];
|
||||
if (!allowedTypes.includes(file.type) &&
|
||||
!file.name.toLowerCase().endsWith('.pdf') &&
|
||||
!file.name.toLowerCase().endsWith('.txt')) {
|
||||
showToast('PDF 또는 TXT 파일만 업로드할 수 있습니다.', 'danger');
|
||||
return;
|
||||
}
|
||||
|
||||
// 파일 크기 검증 (200MB)
|
||||
if (file.size > 200 * 1024 * 1024) {
|
||||
showToast('파일 크기는 200MB를 초과할 수 없습니다.', 'danger');
|
||||
return;
|
||||
}
|
||||
|
||||
selectedFile = file;
|
||||
|
||||
// 파일 정보 표시
|
||||
fileDetails.innerHTML = `
|
||||
<div class="file-details">
|
||||
<div><strong>파일명:</strong> ${file.name}</div>
|
||||
<div><strong>크기:</strong> ${formatFileSize(file.size)}</div>
|
||||
<div><strong>타입:</strong> ${file.type || '알 수 없음'}</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
dropZone.style.display = 'none';
|
||||
fileInfo.style.display = 'block';
|
||||
uploadBtn.disabled = false;
|
||||
|
||||
// 문서 ID 자동 생성
|
||||
const timestamp = new Date().toISOString().slice(0, 10);
|
||||
const baseName = file.name.replace(/\.[^/.]+$/, "");
|
||||
document.getElementById('docId').value = `${baseName}-${timestamp}`;
|
||||
}
|
||||
|
||||
// 파일 선택 취소
|
||||
function clearFile() {
|
||||
selectedFile = null;
|
||||
fileInput.value = '';
|
||||
dropZone.style.display = 'block';
|
||||
fileInfo.style.display = 'none';
|
||||
uploadBtn.disabled = true;
|
||||
progressSection.style.display = 'none';
|
||||
resultSection.style.display = 'none';
|
||||
}
|
||||
|
||||
// 폼 초기화
|
||||
function resetForm() {
|
||||
clearFile();
|
||||
uploadForm.reset();
|
||||
document.getElementById('generateHtml').checked = true;
|
||||
document.getElementById('targetLangGroup').style.display = 'none';
|
||||
document.getElementById('summaryGroup').style.display = 'none';
|
||||
}
|
||||
|
||||
// 폼 제출
|
||||
uploadForm.addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!selectedFile) {
|
||||
showToast('파일을 선택해주세요.', 'danger');
|
||||
return;
|
||||
}
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('file', selectedFile);
|
||||
formData.append('doc_id', document.getElementById('docId').value);
|
||||
formData.append('generate_html', document.getElementById('generateHtml').checked);
|
||||
formData.append('translate', document.getElementById('translate').checked);
|
||||
formData.append('target_language', document.getElementById('targetLanguage').value);
|
||||
|
||||
// 진행 상황 표시
|
||||
progressSection.style.display = 'block';
|
||||
resultSection.style.display = 'none';
|
||||
setLoading(uploadBtn);
|
||||
|
||||
try {
|
||||
updateProgress(20, '파일 업로드 중...');
|
||||
|
||||
const response = await fetch('/pipeline/ingest_file', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'X-API-Key': '{{ api_key }}'
|
||||
},
|
||||
body: formData
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
|
||||
updateProgress(80, '처리 중...');
|
||||
const result = await response.json();
|
||||
|
||||
updateProgress(100, '완료!');
|
||||
|
||||
// 결과 표시
|
||||
setTimeout(() => {
|
||||
showResult(result);
|
||||
}, 500);
|
||||
|
||||
showToast('파일이 성공적으로 처리되었습니다!', 'success');
|
||||
|
||||
} catch (error) {
|
||||
console.error('Upload error:', error);
|
||||
showToast('업로드 중 오류가 발생했습니다: ' + error.message, 'danger');
|
||||
progressSection.style.display = 'none';
|
||||
} finally {
|
||||
uploadBtn.disabled = false;
|
||||
uploadBtn.innerHTML = '<i class="fas fa-rocket"></i> 업로드 및 처리 시작';
|
||||
}
|
||||
});
|
||||
|
||||
function updateProgress(percent, text) {
|
||||
progressBar.style.width = percent + '%';
|
||||
progressText.textContent = text;
|
||||
}
|
||||
|
||||
function showResult(result) {
|
||||
resultContent.innerHTML = `
|
||||
<div class="result-details">
|
||||
<h4><i class="fas fa-info-circle"></i> 처리 결과</h4>
|
||||
<div class="grid grid-2" style="margin-top: 1rem;">
|
||||
<div>
|
||||
<strong>문서 ID:</strong> ${result.doc_id}<br>
|
||||
<strong>처리된 청크:</strong> ${result.added}개<br>
|
||||
<strong>전체 청크:</strong> ${result.chunks}개
|
||||
</div>
|
||||
<div class="result-actions">
|
||||
${result.html_path ? `
|
||||
<a href="/html/${result.html_path.split('/').pop()}"
|
||||
class="btn btn-primary" target="_blank">
|
||||
<i class="fas fa-eye"></i> HTML 보기
|
||||
</a>
|
||||
` : ''}
|
||||
<a href="/documents" class="btn btn-secondary">
|
||||
<i class="fas fa-list"></i> 문서 목록
|
||||
</a>
|
||||
<a href="/chat" class="btn btn-success">
|
||||
<i class="fas fa-comments"></i> 문서로 대화하기
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
resultSection.style.display = 'block';
|
||||
progressSection.style.display = 'none';
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
Reference in New Issue
Block a user