- Thêm audit/sync_audit.py: Ghi lịch sử sync vào audit/sync_log.json
- Thêm API endpoints: /sync/history, /sync/history/{run_id}
- Frontend: Nút 'Lịch sử đồng bộ' + panel expand chi tiết từng file
- Sửa frontend served từ backend (http://localhost:8000)
- Cập nhật DEPLOYMENT_GUIDE với hướng dẫn chạy localhost
- Cập nhật ARCHITECTURE_MAP: Phase 8 hoàn thành
362 lines
13 KiB
JavaScript
362 lines
13 KiB
JavaScript
// API Base URL (relative khi served từ backend)
|
|
const API_BASE = window.location.origin;
|
|
|
|
// DOM Elements
|
|
const loginScreen = document.getElementById('login-screen');
|
|
const appContainer = document.getElementById('app-container');
|
|
const loginForm = document.getElementById('login-form');
|
|
const loginEmail = document.getElementById('login-email');
|
|
const chatWindow = document.getElementById('chat-window');
|
|
const userInput = document.getElementById('user-input');
|
|
const sendBtn = document.getElementById('send-btn');
|
|
const sourcePanel = document.getElementById('source-panel');
|
|
const sourceList = document.getElementById('source-list');
|
|
const closePanel = document.getElementById('close-panel');
|
|
const clearChatBtn = document.getElementById('clear-chat');
|
|
const userName = document.getElementById('user-name');
|
|
const userRole = document.getElementById('user-role');
|
|
const logoutBtn = document.getElementById('logout-btn');
|
|
const syncBtn = document.getElementById('sync-btn');
|
|
const syncStatus = document.getElementById('sync-status');
|
|
const ssoBtn = document.getElementById('sso-btn');
|
|
const historyBtn = document.getElementById('history-btn');
|
|
const historyPanel = document.getElementById('history-panel');
|
|
const historyList = document.getElementById('history-list');
|
|
const closeHistory = document.getElementById('close-history');
|
|
|
|
let chatHistory = [];
|
|
let currentUser = null;
|
|
|
|
// ====== AUTH ======
|
|
|
|
function checkLogin() {
|
|
// Kiểm tra SSO callback (user data trong URL)
|
|
const params = new URLSearchParams(window.location.search);
|
|
const userData = params.get('user');
|
|
if (userData) {
|
|
try {
|
|
currentUser = JSON.parse(decodeURIComponent(userData));
|
|
localStorage.setItem('vibecode_user', JSON.stringify(currentUser));
|
|
window.history.replaceState({}, '', '/'); // Xóa query param
|
|
showApp();
|
|
return;
|
|
} catch (e) {
|
|
console.error('Parse SSO user data failed:', e);
|
|
}
|
|
}
|
|
|
|
// Kiểm tra localStorage
|
|
const saved = localStorage.getItem('vibecode_user');
|
|
if (saved) {
|
|
currentUser = JSON.parse(saved);
|
|
showApp();
|
|
}
|
|
}
|
|
|
|
function showApp() {
|
|
loginScreen.style.display = 'none';
|
|
appContainer.style.display = 'flex';
|
|
userName.textContent = currentUser.display_name;
|
|
userRole.textContent = currentUser.role;
|
|
}
|
|
|
|
function showLogin() {
|
|
loginScreen.style.display = 'flex';
|
|
appContainer.style.display = 'none';
|
|
currentUser = null;
|
|
localStorage.removeItem('vibecode_user');
|
|
}
|
|
|
|
loginForm.onsubmit = async (e) => {
|
|
e.preventDefault();
|
|
const email = loginEmail.value.trim();
|
|
if (!email) return;
|
|
|
|
try {
|
|
const response = await fetch(`${API_BASE}/auth/login-email`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ email })
|
|
});
|
|
|
|
if (!response.ok) {
|
|
const err = await response.json();
|
|
alert(err.detail || 'Đăng nhập thất bại');
|
|
return;
|
|
}
|
|
|
|
currentUser = await response.json();
|
|
localStorage.setItem('vibecode_user', JSON.stringify(currentUser));
|
|
showApp();
|
|
} catch (error) {
|
|
console.error('Login error:', error);
|
|
alert('Không thể kết nối tới server. Vui lòng đảm bảo Backend đang chạy.');
|
|
}
|
|
};
|
|
|
|
logoutBtn.onclick = () => {
|
|
chatHistory = [];
|
|
chatWindow.innerHTML = '';
|
|
showLogin();
|
|
};
|
|
|
|
// SSO Login
|
|
ssoBtn.onclick = () => {
|
|
window.location.href = `${API_BASE}/auth/login`;
|
|
};
|
|
|
|
// ====== CHAT ======
|
|
|
|
// Tự động giãn nở ô nhập liệu
|
|
userInput.addEventListener('input', () => {
|
|
userInput.style.height = 'auto';
|
|
userInput.style.height = (userInput.scrollHeight) + 'px';
|
|
});
|
|
|
|
async function sendMessage() {
|
|
const text = userInput.value.trim();
|
|
if (!text) return;
|
|
|
|
appendMessage('user', text);
|
|
userInput.value = '';
|
|
userInput.style.height = 'auto';
|
|
|
|
const loadingId = appendMessage('ai', '<i class="fas fa-spinner fa-spin"></i> AI đang phân tích dữ liệu...');
|
|
|
|
try {
|
|
const headers = {
|
|
'Content-Type': 'application/json'
|
|
};
|
|
if (currentUser) {
|
|
headers['X-User-Email'] = currentUser.email;
|
|
headers['X-User-Role'] = currentUser.role;
|
|
}
|
|
|
|
const response = await fetch(`${API_BASE}/chat`, {
|
|
method: 'POST',
|
|
headers: headers,
|
|
body: JSON.stringify({
|
|
query: text,
|
|
history: chatHistory
|
|
})
|
|
});
|
|
|
|
if (!response.ok) {
|
|
throw new Error(`Server error: ${response.status}`);
|
|
}
|
|
|
|
const data = await response.json();
|
|
updateMessage(loadingId, data.answer, data.sources);
|
|
|
|
chatHistory.push({ role: 'user', content: text });
|
|
chatHistory.push({ role: 'assistant', content: data.answer });
|
|
if (chatHistory.length > 6) chatHistory = chatHistory.slice(-6);
|
|
|
|
} catch (error) {
|
|
console.error("Chat error:", error);
|
|
updateMessage(loadingId, "⚠️ Lỗi kết nối tới máy chủ AI. Vui lòng đảm bảo Backend đang chạy tại port 8000.");
|
|
}
|
|
}
|
|
|
|
function appendMessage(role, text) {
|
|
const id = Date.now();
|
|
const msgDiv = document.createElement('div');
|
|
msgDiv.className = `message ${role}`;
|
|
msgDiv.id = `msg-${id}`;
|
|
|
|
const icon = role === 'ai' ? 'robot' : 'user';
|
|
|
|
msgDiv.innerHTML = `
|
|
<div class="avatar"><i class="fas fa-${icon}"></i></div>
|
|
<div class="content">${text}</div>
|
|
`;
|
|
|
|
chatWindow.appendChild(msgDiv);
|
|
chatWindow.scrollTop = chatWindow.scrollHeight;
|
|
return id;
|
|
}
|
|
|
|
function updateMessage(id, text, sources = []) {
|
|
const msgDiv = document.getElementById(`msg-${id}`);
|
|
if (!msgDiv) return;
|
|
|
|
const contentDiv = msgDiv.querySelector('.content');
|
|
contentDiv.innerHTML = text.replace(/\n/g, '<br>');
|
|
|
|
if (sources && sources.length > 0) {
|
|
const tagDiv = document.createElement('div');
|
|
tagDiv.style.marginTop = '15px';
|
|
tagDiv.style.display = 'flex';
|
|
tagDiv.style.gap = '8px';
|
|
tagDiv.style.flexWrap = 'wrap';
|
|
|
|
sources.forEach((src, idx) => {
|
|
const tag = document.createElement('span');
|
|
tag.className = 'citation-tag';
|
|
tag.innerHTML = `<i class="fas fa-file-pdf"></i> Nguồn ${idx + 1}`;
|
|
tag.onclick = (e) => {
|
|
e.stopPropagation();
|
|
showSources(sources);
|
|
};
|
|
tagDiv.appendChild(tag);
|
|
});
|
|
contentDiv.appendChild(tagDiv);
|
|
}
|
|
chatWindow.scrollTop = chatWindow.scrollHeight;
|
|
}
|
|
|
|
function showSources(sources) {
|
|
sourceList.innerHTML = '';
|
|
sources.forEach(src => {
|
|
const item = document.createElement('div');
|
|
item.className = 'source-item';
|
|
item.innerHTML = `
|
|
<h4><i class="fas fa-file-alt"></i> ${src.file_name}</h4>
|
|
<p><strong>Vị trí:</strong> Trang ${src.page}</p>
|
|
${src.url ? `<a href="${src.url}" target="_blank" style="color: #06b6d4; font-size: 11px; text-decoration: none; display: block; margin-top: 5px;">
|
|
<i class="fas fa-external-link-alt"></i> Xem trên SharePoint
|
|
</a>` : ''}
|
|
${src.download_url ? `<a href="${src.download_url}" target="_blank" style="color: #10b981; font-size: 11px; text-decoration: none; display: block; margin-top: 5px;">
|
|
<i class="fas fa-download"></i> Tải xuống
|
|
</a>` : ''}
|
|
`;
|
|
sourceList.appendChild(item);
|
|
});
|
|
sourcePanel.classList.add('active');
|
|
}
|
|
|
|
// Event Listeners
|
|
closePanel.onclick = () => sourcePanel.classList.remove('active');
|
|
sendBtn.onclick = sendMessage;
|
|
userInput.onkeydown = (e) => {
|
|
if (e.key === 'Enter' && !e.shiftKey) {
|
|
e.preventDefault();
|
|
sendMessage();
|
|
}
|
|
};
|
|
|
|
clearChatBtn.onclick = () => {
|
|
chatWindow.innerHTML = '';
|
|
chatHistory = [];
|
|
appendMessage('ai', 'Lịch sử chat đã được làm sạch. Tôi có thể giúp gì tiếp cho bạn?');
|
|
};
|
|
|
|
// ====== SYNC ======
|
|
|
|
async function triggerSync() {
|
|
syncBtn.disabled = true;
|
|
syncStatus.style.display = 'flex';
|
|
syncStatus.className = 'sync-status';
|
|
syncStatus.querySelector('.sync-text').textContent = 'Đang đồng bộ...';
|
|
|
|
try {
|
|
const response = await fetch(`${API_BASE}/sync`, { method: 'POST' });
|
|
const data = await response.json();
|
|
|
|
if (data.status === 'already_running') {
|
|
syncStatus.querySelector('.sync-text').textContent = 'Đồng bộ đang chạy...';
|
|
} else {
|
|
syncStatus.querySelector('.sync-text').textContent = 'Đang xử lý...';
|
|
pollSyncStatus();
|
|
}
|
|
} catch (error) {
|
|
syncStatus.className = 'sync-status error';
|
|
syncStatus.querySelector('.sync-text').textContent = 'Lỗi kết nối server';
|
|
syncBtn.disabled = false;
|
|
}
|
|
}
|
|
|
|
async function pollSyncStatus() {
|
|
try {
|
|
const response = await fetch(`${API_BASE}/sync/status`);
|
|
const data = await response.json();
|
|
|
|
if (data.running) {
|
|
const count = data.processed + data.skipped;
|
|
syncStatus.querySelector('.sync-text').textContent = `Đang xử lý... (${count} file)`;
|
|
setTimeout(pollSyncStatus, 2000);
|
|
} else {
|
|
syncStatus.className = 'sync-status done';
|
|
syncStatus.querySelector('.sync-text').textContent =
|
|
`Xong! ${data.processed} file đã nạp, ${data.skipped} bỏ qua`;
|
|
syncBtn.disabled = false;
|
|
setTimeout(() => { syncStatus.style.display = 'none'; }, 5000);
|
|
}
|
|
} catch (error) {
|
|
syncStatus.className = 'sync-status error';
|
|
syncStatus.querySelector('.sync-text').textContent = 'Lỗi kiểm tra trạng thái';
|
|
syncBtn.disabled = false;
|
|
}
|
|
}
|
|
|
|
syncBtn.onclick = triggerSync;
|
|
|
|
// ====== SYNC HISTORY ======
|
|
|
|
async function loadSyncHistory() {
|
|
try {
|
|
const response = await fetch(`${API_BASE}/sync/history?limit=20`);
|
|
const data = await response.json();
|
|
|
|
historyList.innerHTML = '';
|
|
|
|
if (!data.runs || data.runs.length === 0) {
|
|
historyList.innerHTML = '<p style="color: var(--text-muted); font-size: 13px; text-align: center; padding: 20px;">Chưa có lần đồng bộ nào.</p>';
|
|
return;
|
|
}
|
|
|
|
data.runs.forEach(run => {
|
|
const item = document.createElement('div');
|
|
item.className = 'history-item';
|
|
|
|
const time = new Date(run.started_at).toLocaleString('vi-VN');
|
|
const statusClass = run.status;
|
|
const statusText = run.status === 'completed' ? 'Hoàn thành' :
|
|
run.status === 'failed' ? 'Thất bại' : 'Đang chạy';
|
|
|
|
item.innerHTML = `
|
|
<div class="history-header">
|
|
<span class="history-time">${time}</span>
|
|
<span class="history-status ${statusClass}">${statusText}</span>
|
|
</div>
|
|
<div class="history-summary">
|
|
<span><i class="fas fa-check-circle" style="color: #10b981"></i> ${run.summary.processed} nạp</span>
|
|
<span><i class="fas fa-forward" style="color: #eab308"></i> ${run.summary.skipped} bỏ</span>
|
|
<span><i class="fas fa-exclamation-circle" style="color: #ef4444"></i> ${run.summary.errors} lỗi</span>
|
|
</div>
|
|
<div class="history-files">
|
|
${run.files.map(f => `
|
|
<div class="history-file">
|
|
<span class="history-file-name" title="${f.name}">${f.name}</span>
|
|
<span class="history-file-status ${f.status}">${f.chunks > 0 ? f.chunks + ' chunks' : f.status}</span>
|
|
</div>
|
|
`).join('')}
|
|
${run.errors.length > 0 ? `
|
|
<div style="margin-top: 8px; font-size: 11px; color: #ef4444;">
|
|
${run.errors.map(e => `<div>⚠ ${e}</div>`).join('')}
|
|
</div>
|
|
` : ''}
|
|
</div>
|
|
`;
|
|
|
|
item.onclick = () => item.classList.toggle('expanded');
|
|
historyList.appendChild(item);
|
|
});
|
|
} catch (error) {
|
|
console.error('Load history error:', error);
|
|
historyList.innerHTML = '<p style="color: #ef4444; font-size: 13px; text-align: center;">Lỗi tải lịch sử.</p>';
|
|
}
|
|
}
|
|
|
|
historyBtn.onclick = () => {
|
|
historyPanel.classList.toggle('active');
|
|
if (historyPanel.classList.contains('active')) {
|
|
loadSyncHistory();
|
|
}
|
|
};
|
|
|
|
closeHistory.onclick = () => historyPanel.classList.remove('active');
|
|
|
|
// Init
|
|
checkLogin();
|