CareerBot/app/static/js/chat.js
ln0422 96997daed0 Initial commit: CareerBot full-stack career showcase with AI chatbot
- FastAPI backend with SQLAlchemy ORM and SQLite
- AI chatbot with OpenAI-compatible LLM integration (SSE streaming)
- Admin panel for content management, LLM config, token management
- Anonymous access with 3-question limit, token-based access control
- Recruiter intent detection with admin notification
- Resume generator (JD-based, Markdown to Word export)
- Chinese localized public interface

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-07 20:36:38 +08:00

275 lines
9.3 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// ── Chat Widget JavaScript ──
let chatSessionId = sessionStorage.getItem('chat_session');
if (!chatSessionId) {
chatSessionId = crypto.randomUUID();
sessionStorage.setItem('chat_session', chatSessionId);
}
let chatOpen = false;
let isSending = false;
let historyLoaded = false;
let usedQuestions = 0;
let maxQuestions = 10;
let limitReached = false;
let isAnonymous = false;
document.addEventListener('DOMContentLoaded', () => {
const fab = document.getElementById('chat-fab');
const panel = document.getElementById('chat-panel');
const closeBtn = document.getElementById('chat-close');
const sendBtn = document.getElementById('chat-send');
const input = document.getElementById('chat-input');
const fileInput = document.getElementById('chat-file');
const uploadBtn = document.getElementById('chat-upload');
const removeFileBtn = document.getElementById('remove-file');
loadChatConfig();
fab.addEventListener('click', () => {
chatOpen = !chatOpen;
panel.classList.toggle('open', chatOpen);
if (chatOpen && !historyLoaded) {
loadChatHistory();
historyLoaded = true;
}
if (chatOpen) input.focus();
});
closeBtn.addEventListener('click', () => {
chatOpen = false;
panel.classList.remove('open');
});
sendBtn.addEventListener('click', sendMessage);
input.addEventListener('keydown', e => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
sendMessage();
}
});
uploadBtn.addEventListener('click', () => fileInput.click());
fileInput.addEventListener('change', () => {
const preview = document.getElementById('file-preview');
const nameEl = document.getElementById('file-name');
if (fileInput.files.length > 0) {
nameEl.textContent = fileInput.files[0].name;
preview.classList.add('show');
}
});
removeFileBtn.addEventListener('click', () => {
fileInput.value = '';
document.getElementById('file-preview').classList.remove('show');
});
});
async function loadChatConfig() {
try {
const resp = await fetch('/api/chat/config');
if (resp.ok) {
const data = await resp.json();
maxQuestions = data.max_questions || 10;
usedQuestions = data.used_questions || 0;
isAnonymous = !!data.anonymous;
limitReached = usedQuestions >= maxQuestions;
// For anonymous users, also check from session storage
if (isAnonymous) {
const storedUsed = parseInt(sessionStorage.getItem('anon_used_q') || '0');
usedQuestions = storedUsed;
limitReached = usedQuestions >= maxQuestions;
}
}
} catch (e) { /* use defaults */ }
const greetingEl = document.getElementById('greeting-msg');
if (greetingEl) {
if (isAnonymous) {
greetingEl.textContent = `您好我是AI职业助手。您当前为匿名访问可提问${maxQuestions}次。如需更多次数,请联系管理员获取访问令牌。`;
} else {
greetingEl.textContent = `您好我是AI职业助手可以介绍候选人的背景、技能和经历也支持上传JD进行匹配分析。您可提问${maxQuestions}次。`;
}
}
updateCounter();
updateInputState();
}
async function loadChatHistory() {
try {
const resp = await fetch(`/api/chat/history/${chatSessionId}`);
if (!resp.ok) return;
const history = await resp.json();
for (const msg of history) {
appendBubble(msg.role, msg.content);
}
scrollToBottom();
} catch (e) {
console.error('Failed to load history:', e);
}
}
async function sendMessage() {
if (isSending || limitReached) return;
const input = document.getElementById('chat-input');
const fileInput = document.getElementById('chat-file');
const message = input.value.trim();
const file = fileInput.files[0] || null;
if (!message && !file) return;
// Show user message
let displayMsg = message;
if (file) {
displayMsg = message ? `${message}\n[附件: ${file.name}]` : `[附件: ${file.name}]`;
}
appendBubble('user', displayMsg);
usedQuestions++;
if (isAnonymous) {
sessionStorage.setItem('anon_used_q', String(usedQuestions));
}
updateCounter();
// Clear inputs
input.value = '';
fileInput.value = '';
document.getElementById('file-preview').classList.remove('show');
// Show typing indicator
const typing = document.getElementById('typing-indicator');
typing.classList.add('show');
scrollToBottom();
isSending = true;
try {
const formData = new FormData();
formData.append('session_id', chatSessionId);
formData.append('message', message);
if (file) formData.append('file', file);
const response = await fetch('/api/chat', {
method: 'POST',
body: formData,
});
typing.classList.remove('show');
if (!response.ok) {
appendBubble('assistant', '抱歉,出现了错误,请稍后再试。');
isSending = false;
return;
}
const bubble = appendBubble('assistant', '');
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
let fullText = '';
while (true) {
const { value, done } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop();
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed.startsWith('data:')) continue;
const dataStr = trimmed.slice(5).trim();
if (!dataStr) continue;
try {
const data = JSON.parse(dataStr);
if (data.limit_reached) limitReached = true;
if (data.done) continue;
if (data.content) {
fullText += data.content;
bubble.textContent = fullText;
scrollToBottom();
}
} catch (e) { /* skip */ }
}
}
// Check if limit reached after this question
if (usedQuestions >= maxQuestions) {
limitReached = true;
}
updateInputState();
} catch (e) {
document.getElementById('typing-indicator').classList.remove('show');
appendBubble('assistant', '网络异常,请稍后再试。');
}
isSending = false;
}
function updateCounter() {
let counter = document.getElementById('question-counter');
if (!counter) {
counter = document.createElement('div');
counter.id = 'question-counter';
counter.style.cssText = 'text-align:center;font-size:12px;color:#94a3b8;padding:4px 0;border-bottom:1px solid #e2e8f0;';
const header = document.querySelector('.chat-header');
if (header) header.parentNode.insertBefore(counter, header.nextSibling);
}
const remaining = maxQuestions - usedQuestions;
const label = isAnonymous ? '匿名提问剩余' : '剩余提问次数';
counter.innerHTML = `${label}: ${remaining < 0 ? 0 : remaining} / ${maxQuestions}`;
if (isAnonymous) {
counter.innerHTML += ` <a href="#" onclick="enterToken();return false;" style="color:#3b82f6;text-decoration:underline;font-size:12px;">输入令牌</a>`;
}
counter.style.color = remaining <= 2 ? '#ef4444' : '#94a3b8';
}
function updateInputState() {
const input = document.getElementById('chat-input');
const sendBtn = document.getElementById('chat-send');
const uploadBtn = document.getElementById('chat-upload');
if (limitReached) {
input.disabled = true;
input.placeholder = isAnonymous
? '匿名次数已用完,请获取令牌继续'
: '次数已用完,请联系管理员';
sendBtn.disabled = true;
sendBtn.style.opacity = '0.5';
uploadBtn.disabled = true;
uploadBtn.style.opacity = '0.5';
}
}
async function enterToken() {
const token = prompt('请输入访问令牌 (Access Token):');
if (!token || !token.trim()) return;
try {
const resp = await fetch('/api/verify-token', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ token: token.trim() }),
});
if (resp.ok) {
window.location.reload();
} else {
alert('无效或已过期的访问令牌');
}
} catch (e) {
alert('网络错误,请稍后重试');
}
}
function appendBubble(role, text) {
const container = document.getElementById('chat-messages');
const bubble = document.createElement('div');
bubble.className = `message-bubble message-${role}`;
bubble.textContent = text;
container.appendChild(bubble);
scrollToBottom();
return bubble;
}
function scrollToBottom() {
const container = document.getElementById('chat-messages');
container.scrollTop = container.scrollHeight;
}