Spaces:
No application file
No application file
| <html lang="vi"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| <title>AI-Nive Professional</title> | |
| <style> | |
| :root { | |
| --bg-main: #f8f8f8; --bg-chat: #ffffff; --bg-sidebar: #f0f0f0; | |
| --text-main: #333333; --text-muted: #666666; | |
| --accent: #da7756; --accent-hover: #c46445; | |
| --border: #e5e5e5; --user-msg: #f3f4f6; --ai-msg: #ffffff; | |
| --font-main: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; | |
| } | |
| @media (prefers-color-scheme: dark) { | |
| :root { | |
| --bg-main: #1a1a1a; --bg-chat: #2b2b2b; --bg-sidebar: #1e1e1e; | |
| --text-main: #e0e0e0; --text-muted: #999999; | |
| --border: #3a3a3a; --user-msg: #3b3b3b; --ai-msg: #2b2b2b; | |
| } | |
| } | |
| * { box-sizing: border-box; margin: 0; padding: 0; font-family: var(--font-main); } | |
| body { display: flex; height: 100vh; background: var(--bg-main); color: var(--text-main); overflow: hidden; } | |
| /* Sidebar */ | |
| #sidebar { width: 260px; background: var(--bg-sidebar); border-right: 1px solid var(--border); display: flex; flex-direction: column; transition: transform 0.3s ease; z-index: 100; } | |
| .sidebar-header { padding: 20px; border-bottom: 1px solid var(--border); } | |
| .new-chat-btn { width: 100%; padding: 10px; background: var(--accent); color: white; border: none; border-radius: 6px; cursor: pointer; font-weight: 600; display: flex; align-items: center; justify-content: center; gap: 8px; font-size: 14px; } | |
| .new-chat-btn:hover { background: var(--accent-hover); } | |
| #history-list { flex: 1; overflow-y: auto; padding: 10px; } | |
| .history-item { padding: 10px; border-radius: 6px; cursor: pointer; margin-bottom: 5px; font-size: 14px; display: flex; justify-content: space-between; align-items: center; color: var(--text-main); } | |
| .history-item:hover, .history-item.active { background: rgba(0,0,0,0.05); } | |
| @media (prefers-color-scheme: dark) { .history-item:hover, .history-item.active { background: rgba(255,255,255,0.05); } } | |
| .history-title { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 170px; } | |
| .history-actions button { background: none; border: none; color: var(--text-muted); cursor: pointer; padding: 2px 5px; font-size: 12px; } | |
| .history-actions button:hover { color: var(--accent); } | |
| /* Main Chat Area */ | |
| #main-area { flex: 1; display: flex; flex-direction: column; background: var(--bg-chat); position: relative; } | |
| #top-bar { height: 60px; border-bottom: 1px solid var(--border); display: flex; align-items: center; padding: 0 20px; justify-content: space-between; } | |
| .toggle-group { display: flex; align-items: center; gap: 10px; font-size: 14px; font-weight: 500; } | |
| /* Web Search Toggle */ | |
| .switch { position: relative; display: inline-block; width: 44px; height: 24px; } | |
| .switch input { opacity: 0; width: 0; height: 0; } | |
| .slider { position: absolute; cursor: pointer; top: 0; left: 0; right: 0; bottom: 0; background-color: #ccc; transition: .4s; border-radius: 24px; } | |
| .slider:before { position: absolute; content: ""; height: 16px; width: 16px; left: 4px; bottom: 4px; background-color: white; transition: .4s; border-radius: 50%; } | |
| input:checked + .slider { background-color: var(--accent); } | |
| input:checked + .slider:before { transform: translateX(20px); } | |
| /* Chat Container */ | |
| #chat-container { flex: 1; overflow-y: auto; padding: 20px 15%; display: flex; flex-direction: column; gap: 20px; scroll-behavior: smooth; } | |
| .message { display: flex; gap: 15px; max-width: 100%; line-height: 1.6; } | |
| .message.user { justify-content: flex-end; } | |
| .message.user .msg-content { background: var(--user-msg); border-radius: 12px 12px 0 12px; padding: 12px 16px; max-width: 80%; border: 1px solid var(--border); } | |
| .message.ai .msg-content { background: var(--ai-msg); border-radius: 12px 12px 12px 0; padding: 12px 16px; max-width: 100%; flex: 1; } | |
| .avatar { width: 32px; height: 32px; border-radius: 4px; display: flex; align-items: center; justify-content: center; font-weight: bold; flex-shrink: 0; } | |
| .user-av { background: #333; color: white; } | |
| .ai-av { background: var(--accent); color: white; } | |
| /* Code Blocks & Files */ | |
| pre { background: #1e1e1e; color: #d4d4d4; padding: 15px; border-radius: 8px; overflow-x: auto; margin: 10px 0; font-family: 'Courier New', Courier, monospace; font-size: 13px; position: relative; } | |
| .code-header { background: #2d2d2d; padding: 5px 10px; font-size: 12px; color: #aaa; border-top-left-radius: 8px; border-top-right-radius: 8px; margin-bottom: -10px; display: flex; justify-content: space-between; } | |
| .download-btn { background: var(--accent); color: white; border: none; border-radius: 4px; cursor: pointer; padding: 2px 8px; font-size: 11px; } | |
| /* Input Area */ | |
| #input-area { padding: 20px 15%; border-top: 1px solid var(--border); background: var(--bg-chat); } | |
| .input-box { border: 1px solid var(--border); border-radius: 12px; background: var(--bg-main); display: flex; flex-direction: column; padding: 10px; box-shadow: 0 4px 6px rgba(0,0,0,0.05); } | |
| #file-preview { display: flex; gap: 10px; flex-wrap: wrap; margin-bottom: 5px; } | |
| .file-chip { background: var(--bg-chat); border: 1px solid var(--border); padding: 4px 8px; border-radius: 4px; font-size: 12px; display: flex; align-items: center; gap: 5px; } | |
| .file-chip button { background: none; border: none; color: red; cursor: pointer; font-weight: bold; } | |
| .input-controls { display: flex; align-items: center; gap: 10px; } | |
| textarea { flex: 1; border: none; background: transparent; color: var(--text-main); resize: none; outline: none; padding: 8px; max-height: 150px; overflow-y: auto; font-size: 15px; } | |
| .action-btn { background: transparent; border: none; color: var(--text-muted); cursor: pointer; padding: 8px; border-radius: 6px; display: flex; align-items: center; justify-content: center; transition: 0.2s; } | |
| .action-btn:hover { background: rgba(0,0,0,0.05); color: var(--accent); } | |
| .send-btn { background: var(--accent); color: white; border-radius: 8px; padding: 8px 16px; font-weight: 600; } | |
| .send-btn:hover { background: var(--accent-hover); color: white; } | |
| #loading { display: none; align-items: center; gap: 5px; font-size: 13px; color: var(--accent); padding: 10px; } | |
| .dot { width: 6px; height: 6px; background: var(--accent); border-radius: 50%; animation: bounce 1.4s infinite ease-in-out both; } | |
| .dot:nth-child(1) { animation-delay: -0.32s; } | |
| .dot:nth-child(2) { animation-delay: -0.16s; } | |
| @keyframes bounce { 0%, 80%, 100% { transform: scale(0); } 40% { transform: scale(1); } } | |
| </style> | |
| </head> | |
| <body> | |
| <div id="sidebar"> | |
| <div class="sidebar-header"> | |
| <button class="new-chat-btn" onclick="startNewChat()">+ Chat mới</button> | |
| </div> | |
| <div id="history-list"></div> | |
| </div> | |
| <div id="main-area"> | |
| <div id="top-bar"> | |
| <div style="font-weight: 600; letter-spacing: 0.5px;">AI-Nive <span style="font-size:12px; color:var(--accent)">v1.0 Pro</span></div> | |
| <div class="toggle-group"> | |
| <label>Web Search</label> | |
| <label class="switch"> | |
| <input type="checkbox" id="web-search-toggle" checked> | |
| <span class="slider"></span> | |
| </label> | |
| </div> | |
| </div> | |
| <div id="chat-container"> | |
| </div> | |
| <div id="loading"> | |
| <div class="dot"></div><div class="dot"></div><div class="dot"></div> | |
| <span>Nive đang suy nghĩ...</span> | |
| </div> | |
| <div id="input-area"> | |
| <div class="input-box"> | |
| <div id="file-preview"></div> | |
| <div class="input-controls"> | |
| <input type="file" id="file-input" style="display: none;" multiple accept=".txt,.py,.js,.html,.css,.json,.png,.jpg,.jpeg,.zip"> | |
| <button class="action-btn" onclick="document.getElementById('file-input').click()" title="Đính kèm file"> | |
| <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21.44 11.05l-9.19 9.19a6 6 0 01-8.49-8.49l9.19-9.19a4 4 0 015.66 5.66l-9.2 9.19a2 2 0 01-2.83-2.83l8.49-8.48"></path></svg> | |
| </button> | |
| <textarea id="user-input" rows="1" placeholder="Nhắn tin cho Nive... (Enter để gửi, Shift+Enter xuống dòng)"></textarea> | |
| <button class="action-btn send-btn" onclick="sendMessage()">Gửi ➔</button> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| <script> | |
| // System Core LocalStorage | |
| let chatHistory = JSON.parse(localStorage.getItem('nive_chats')) || []; | |
| let currentChatId = null; | |
| let pendingFiles = []; | |
| const chatContainer = document.getElementById('chat-container'); | |
| const userInput = document.getElementById('user-input'); | |
| const fileInput = document.getElementById('file-input'); | |
| const filePreview = document.getElementById('file-preview'); | |
| const historyList = document.getElementById('history-list'); | |
| // Khởi tạo | |
| window.onload = () => { | |
| renderHistory(); | |
| if(chatHistory.length > 0) { loadChat(chatHistory[0].id); } | |
| else { startNewChat(); } | |
| }; | |
| // Quản lý lịch sử | |
| function startNewChat() { | |
| currentChatId = Date.now().toString(); | |
| chatHistory.unshift({ id: currentChatId, title: "Cuộc hội thoại mới", messages: [], pinned: false }); | |
| saveHistory(); | |
| renderHistory(); | |
| chatContainer.innerHTML = ''; | |
| addMessage('ai', 'Xin chào! Tôi là AI-Nive. Tôi có thể đọc code, phân tích ảnh, nén/giải nén file zip và tra cứu Internet. Bạn cần giúp gì?'); | |
| } | |
| function saveHistory() { localStorage.setItem('nive_chats', JSON.stringify(chatHistory)); } | |
| function renderHistory() { | |
| historyList.innerHTML = ''; | |
| // Sắp xếp: ghim lên đầu | |
| chatHistory.sort((a, b) => (b.pinned === a.pinned) ? 0 : a.pinned ? 1 : -1); | |
| chatHistory.forEach(chat => { | |
| let div = document.createElement('div'); | |
| div.className = `history-item ${chat.id === currentChatId ? 'active' : ''}`; | |
| div.innerHTML = ` | |
| <div class="history-title" onclick="loadChat('${chat.id}')"> | |
| ${chat.pinned ? '📌 ' : ''}${chat.title} | |
| </div> | |
| <div class="history-actions"> | |
| <button onclick="togglePin('${chat.id}')">📍</button> | |
| <button onclick="deleteChat('${chat.id}')">🗑️</button> | |
| </div> | |
| `; | |
| historyList.appendChild(div); | |
| }); | |
| } | |
| function loadChat(id) { | |
| currentChatId = id; | |
| renderHistory(); | |
| const chat = chatHistory.find(c => c.id === id); | |
| chatContainer.innerHTML = ''; | |
| chat.messages.forEach(m => renderMessageUI(m.role, m.content, false)); | |
| scrollToBottom(); | |
| } | |
| function togglePin(id) { | |
| let chat = chatHistory.find(c => c.id === id); | |
| chat.pinned = !chat.pinned; | |
| saveHistory(); renderHistory(); | |
| } | |
| function deleteChat(id) { | |
| chatHistory = chatHistory.filter(c => c.id !== id); | |
| saveHistory(); renderHistory(); | |
| if(currentChatId === id) startNewChat(); | |
| } | |
| // Xử lý Input & File | |
| userInput.addEventListener('input', function() { | |
| this.style.height = 'auto'; | |
| this.style.height = (this.scrollHeight) + 'px'; | |
| }); | |
| userInput.addEventListener('keydown', function(e) { | |
| if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); sendMessage(); } | |
| }); | |
| fileInput.addEventListener('change', function(e) { | |
| for(let file of this.files) { | |
| pendingFiles.push(file); | |
| let chip = document.createElement('div'); | |
| chip.className = 'file-chip'; | |
| chip.innerHTML = `📎 ${file.name} <button onclick="removeFile(this, '${file.name}')">×</button>`; | |
| filePreview.appendChild(chip); | |
| } | |
| }); | |
| function removeFile(btn, name) { | |
| pendingFiles = pendingFiles.filter(f => f.name !== name); | |
| btn.parentElement.remove(); | |
| } | |
| // Xử lý gửi tin nhắn | |
| async function sendMessage() { | |
| const text = userInput.value.trim(); | |
| if(!text && pendingFiles.length === 0) return; | |
| let userMsgContent = text; | |
| if(pendingFiles.length > 0) { | |
| userMsgContent += `<br><small style="color:var(--accent)">[Đã đính kèm ${pendingFiles.length} file]</small>`; | |
| } | |
| addMessage('user', userMsgContent); | |
| userInput.value = ''; userInput.style.height = 'auto'; | |
| document.getElementById('loading').style.display = 'flex'; | |
| // Chuẩn bị FormData gửi lên API FastAPI | |
| let formData = new FormData(); | |
| formData.append("message", text); | |
| formData.append("web_search", document.getElementById('web-search-toggle').checked); | |
| // Lấy lịch sử ngữ cảnh | |
| let chat = chatHistory.find(c => c.id === currentChatId); | |
| let historyContext = chat.messages.slice(-5); // Gửi 5 tin gần nhất để tiết kiệm token | |
| formData.append("history", JSON.stringify(historyContext)); | |
| for(let file of pendingFiles) { formData.append("files", file); } | |
| pendingFiles = []; filePreview.innerHTML = ''; | |
| try { | |
| // GỌI API BACKEND (Cùng domain trên Hugging Face) | |
| const response = await fetch('/api/chat', { method: 'POST', body: formData }); | |
| const data = await response.json(); | |
| if(data.error) throw new Error(data.error); | |
| addMessage('ai', formatAIResponse(data.reply)); | |
| // Cập nhật tiêu đề tự động | |
| if(chat.messages.length === 2) { | |
| chat.title = text.substring(0, 30) + "..."; | |
| saveHistory(); renderHistory(); | |
| } | |
| } catch (error) { | |
| addMessage('ai', `⚠️ Lỗi hệ thống: ${error.message}`); | |
| } finally { | |
| document.getElementById('loading').style.display = 'none'; | |
| } | |
| } | |
| function addMessage(role, content) { | |
| renderMessageUI(role, content, true); | |
| let chat = chatHistory.find(c => c.id === currentChatId); | |
| chat.messages.push({ role: role, content: content }); | |
| saveHistory(); | |
| } | |
| function renderMessageUI(role, content, animate) { | |
| let div = document.createElement('div'); | |
| div.className = `message ${role}`; | |
| let av = role === 'user' ? '<div class="avatar user-av">U</div>' : '<div class="avatar ai-av">N</div>'; | |
| div.innerHTML = role === 'user' | |
| ? `<div class="msg-content">${content}</div>${av}` | |
| : `${av}<div class="msg-content">${content}</div>`; | |
| chatContainer.appendChild(div); | |
| scrollToBottom(); | |
| } | |
| function formatAIResponse(text) { | |
| // Tự động chuyển đổi các khối code thành UI có nút tải xuống | |
| const codeBlockRegex = /```(\w+)?\n([\s\S]*?)```/g; | |
| return text.replace(codeBlockRegex, (match, lang, code) => { | |
| lang = lang || 'text'; | |
| let ext = lang === 'python' ? 'py' : lang === 'javascript' ? 'js' : lang === 'html' ? 'html' : 'txt'; | |
| let filename = `nive_code.${ext}`; | |
| let encodedCode = encodeURIComponent(code); | |
| return ` | |
| <div class="code-header"> | |
| <span>${lang.toUpperCase()}</span> | |
| <a href="data:text/plain;charset=utf-8,${encodedCode}" download="${filename}" class="download-btn">Tải file .${ext}</a> | |
| </div> | |
| <pre><code>${code.replace(/</g, '<').replace(/>/g, '>')}</code></pre>`; | |
| }).replace(/\n/g, '<br>'); | |
| } | |
| function scrollToBottom() { chatContainer.scrollTop = chatContainer.scrollHeight; } | |
| </script> | |
| </body> | |
| </html> | |