crypto.randomUUID() requires secure context (HTTPS). Use Date.now + Math.random fallback for session ID generation. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
276 lines
9.4 KiB
JavaScript
276 lines
9.4 KiB
JavaScript
// ── Chat Widget JavaScript ──
|
||
// NOTE: Uses BASE_PATH from main.js (loaded before this file on index.html)
|
||
|
||
let chatSessionId = sessionStorage.getItem('chat_session');
|
||
if (!chatSessionId) {
|
||
chatSessionId = 'sess-' + Date.now().toString(36) + '-' + Math.random().toString(36).slice(2, 10);
|
||
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;
|
||
}
|