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:
hyungi
2025-08-14 08:09:48 +09:00
parent ef64aaec84
commit cb009f7393
13 changed files with 2781 additions and 4 deletions

39
templates/base.html Normal file
View 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>&copy; 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
View 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
View 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
View 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
View 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 %}