CareerBot/app/static/js/chat.js
ln0422 7344f3c9ce Fix duplicate const BASE_PATH declaration in chat.js
main.js and chat.js both declared const BASE_PATH at top level.
When both load on index.html, the second const throws SyntaxError,
preventing chat.js from executing (chat FAB button unresponsive).

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

277 lines
9.4 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 ──
// BASE_PATH is declared in main.js (loaded first on index.html)
if (typeof BASE_PATH === 'undefined') var BASE_PATH = window.BASE_PATH || '';
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(BASE_PATH + '/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(BASE_PATH + `/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(BASE_PATH + '/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(BASE_PATH + '/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;
}